diff --git a/pyproject.toml b/pyproject.toml index 8b04502d..a4e25616 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "xgen-edit2docs" -version = "0.19.0" +version = "0.20.0" description = "AI-agent-native document engine: generate and chat-edit DOCX, XLSX and PPTX as a Python library, agent tool set, MCP server or hosted service. English-first with first-class Korean support. Sister project of edit2ppt." readme = "README.md" requires-python = ">=3.12" diff --git a/src/xgen_edit2docs/documents/docx_pages.py b/src/xgen_edit2docs/documents/docx_pages.py index 85a833f7..c6ece635 100644 --- a/src/xgen_edit2docs/documents/docx_pages.py +++ b/src/xgen_edit2docs/documents/docx_pages.py @@ -8,14 +8,16 @@ resvg/PyMuPDF raster layer for PNG/PDF — the piece LibreOffice used to provide. -Fidelity scope (deliberate): body paragraphs (runs with -bold/italic/size/color/underline), heading styles, bullet/numbered -lists, hard + automatic page breaks, tables (tblGrid widths, gridSpan/ -vMerge merges, cell shading), inline images (extent-scaled, base64), -single-section page size/margins, first-section header/footer text -with PAGE field support. Floating shapes, multi-column sections and -footnote blocks are out of scope — the HTML preview covers reading -those. +Fidelity scope (deliberate): body paragraphs (runs with bold/italic/ +size/color/underline/strike, w:jc alignment), heading styles, bullet/ +numbered lists, hard + automatic page breaks, tables (tblGrid widths, +gridSpan/vMerge merges drawn as one spanning rect, per-run cell styles ++ per-paragraph cell alignment, w:vAlign, trHeight minimums, cell +shading, row-boundary page splitting, nested tables flattened to +text), inline images (extent-scaled, base64), single-section page +size/margins, first-section header/footer text with PAGE field +support. Floating shapes, multi-column sections and footnote blocks +are out of scope — the HTML preview covers reading those. Addressing: each body paragraph's lines are wrapped in ```` and table cells carry ``data-e2d-table`` / @@ -105,12 +107,21 @@ class _Seg: bold: bool = False italic: bool = False underline: bool = False + strike: bool = False color: str = "#222222" family: Optional[str] = None def width(self) -> float: return _text_width(self.text, self.size_px, self.bold, self.family) + def deco(self) -> str: + parts = [] + if self.underline: + parts.append("underline") + if self.strike: + parts.append("line-through") + return " ".join(parts) + @dataclass class _Line: @@ -206,9 +217,14 @@ def close_para_group(self) -> None: # ── primitives ────────────────────────────────────────── - def emit_line(self, line: _Line, indent: float = 0.0) -> None: + def emit_line(self, line: _Line, indent: float = 0.0, align: str = "left") -> None: + """*align*: 'left' | 'center' | 'right' — 남는 폭만큼 시작점을 민다.""" self.ensure(line.height) x = self.m["left"] + indent + if align in ("center", "right"): + line_w = sum(s.width() for s in line.segs) + slack = max(0.0, self.content_w - indent - line_w) + x += slack / 2 if align == "center" else slack baseline = self.y + line.ascent for seg in line.segs: if seg.text: @@ -217,8 +233,9 @@ def emit_line(self, line: _Line, indent: float = 0.0) -> None: style.append('font-style="italic"') if seg.bold: style.append('font-weight="bold"') - if seg.underline: - style.append('text-decoration="underline"') + deco = seg.deco() + if deco: + style.append(f'text-decoration="{deco}"') fam = f"'{seg.family}', {_FONT_STACK}" if seg.family else _FONT_STACK self.pages[-1].append( f' str: + """w:pPr/w:jc → 'left' | 'center' | 'right'. justify(both)/distribute 는 + 좌측 흘림으로 근사한다 (이 엔진은 자간 조정을 하지 않는다).""" + jc = p_el.find(f"{_w('pPr')}/{_w('jc')}") + val = (jc.get(_w("val")) or "").lower() if jc is not None else "" + if val in ("center",): + return "center" + if val in ("right", "end"): + return "right" + return "left" + + def _effective_size_pt(run, paragraph, heading: int) -> float: try: if run.font.size is not None: @@ -337,11 +366,16 @@ def _paragraph_segments(paragraph, heading: int) -> list[list[_Seg]]: for run in paragraph.runs: size_px = _effective_size_pt(run, paragraph, heading) * 96.0 / 72.0 bold = bool(run.bold) or heading in (1, 2, 3) + try: + strike = bool(run.font.strike) + except Exception: # noqa: BLE001 + strike = False seg_style = dict( size_px=size_px, bold=bold, italic=bool(run.italic), underline=bool(run.underline), + strike=strike, color=_run_color(run), family=(run.font.name or None), ) @@ -442,77 +476,231 @@ def _cell_fill(tc_el) -> Optional[str]: return None -def _layout_table(writer: _PageWriter, table, table_idx: int) -> None: - tbl_el = table._tbl - widths = _grid_widths(tbl_el, writer.content_w) - pad = 4.0 - font_px = 10.0 * 96 / 72 +@dataclass +class _CellBox: + """앵커 셀 하나 — 병합(gridSpan/vMerge)을 흡수한 격자상의 사각형.""" + + row: int + col: int + rowspan: int = 1 + colspan: int = 1 + #: (줄, 그 줄의 문단 정렬) — 셀 문단별 jc 를 줄 단위로 내린 것. + lines: list = field(default_factory=list) + fill: Optional[str] = None + valign: str = "top" # w:tcPr/w:vAlign — top | center | bottom + + def content_h(self) -> float: + return sum(ln.height for ln, _a in self.lines) + + +def _cell_valign(tc_el) -> str: + va = tc_el.find(f"{_w('tcPr')}/{_w('vAlign')}") + val = (va.get(_w("val")) or "").lower() if va is not None else "" + if val in ("center",): + return "center" + if val in ("bottom",): + return "bottom" + return "top" + +def _cell_lines(tc_el, document, avail_w: float) -> list: + """셀 내용 → [(줄, 정렬)] — 문단별 런 스타일과 jc 를 보존해 감싼다. + + 중첩 표는 이 엔진의 격자 밖이라 행 단위 텍스트(' | ' 연결)로 평탄화 + 한다 — 배치는 잃지만 내용은 잃지 않는다. + """ + from docx.text.paragraph import Paragraph + + out: list = [] + for child in tc_el: + if child.tag == _w("p"): + paragraph = Paragraph(child, document) + align = _para_align(child) + for segs in _paragraph_segments(paragraph, 0): + if not any(s.text for s in segs): + continue + for ln in _wrap_segments(segs, avail_w): + if ln.segs: + out.append((ln, align)) + elif child.tag == _w("tbl"): + for tr in child.findall(_w("tr")): + texts = [] + for tc2 in tr.findall(_w("tc")): + t = "".join(t.text or "" for t in tc2.iter(_w("t"))).strip() + if t: + texts.append(t) + if texts: + seg = _Seg(text=" | ".join(texts), size_px=10.0 * 96 / 72) + for ln in _wrap_segments([seg], avail_w): + if ln.segs: + out.append((ln, "left")) + return out + + +def _table_model(tbl_el, document, widths: list[float], pad: float): + """w:tbl → (앵커 셀 목록, 행 높이 목록). vMerge continue 는 앵커의 + rowspan 으로 흡수되고, 행 높이는 내용/trHeight/병합 부족분 순으로 채운다.""" + font_px = 10.0 * 96 / 72 rows = tbl_el.findall(_w("tr")) - grid_cols = len(widths) - # vMerge tracking: (col) -> remaining anchor rect to extend + boxes: list[_CellBox] = [] + anchor_at: dict[int, _CellBox] = {} # col → 위 행에서 내려오는 vMerge 앵커 + min_row_h: list[float] = [] + for r_i, tr in enumerate(rows): - cells = tr.findall(_w("tc")) - # measure row height + trh = tr.find(f"{_w('trPr')}/{_w('trHeight')}") + try: + min_h = float(trh.get(_w("val"))) / _TWIPS_PER_PX if trh is not None else 0.0 + except (TypeError, ValueError): + min_h = 0.0 + min_row_h.append(max(min_h, font_px * _LINE_SPACING + pad * 2)) + col_cursor = 0 - cell_layouts = [] - row_h = font_px * _LINE_SPACING + pad * 2 - for tc in cells: + for tc in tr.findall(_w("tc")): tc_pr = tc.find(_w("tcPr")) span = 1 - v_merge_cont = False + vmerge = None # None | 'restart' | 'continue' if tc_pr is not None: gs = tc_pr.find(_w("gridSpan")) if gs is not None: - span = int(gs.get(_w("val")) or 1) + try: + span = max(1, int(gs.get(_w("val")) or 1)) + except ValueError: + span = 1 vm = tc_pr.find(_w("vMerge")) - if vm is not None and (vm.get(_w("val")) or "continue") != "restart": - v_merge_cont = True + if vm is not None: + vmerge = (vm.get(_w("val")) or "continue").lower() + if vmerge == "continue": + anchor = anchor_at.get(col_cursor) + if anchor is not None: + anchor.rowspan = r_i - anchor.row + 1 + col_cursor += span + continue width = sum(widths[col_cursor:col_cursor + span]) or widths[-1] - text = " ".join( - "".join(t.text or "" for t in p.iter(_w("t"))) - for p in tc.findall(_w("p")) - ).strip() - seg = _Seg(text=text, size_px=font_px) - wrapped = _wrap_segments([seg], max(width - pad * 2, 10.0)) if text else [] - cell_h = max( - sum(ln.height for ln in wrapped) + pad * 2, - font_px * _LINE_SPACING + pad * 2, - ) - if not v_merge_cont: - row_h = max(row_h, cell_h) - cell_layouts.append( - (col_cursor, span, width, wrapped, v_merge_cont, _cell_fill(tc)) + box = _CellBox( + row=r_i, col=col_cursor, colspan=span, + lines=_cell_lines(tc, document, max(width - pad * 2, 10.0)), + fill=_cell_fill(tc), valign=_cell_valign(tc), ) + boxes.append(box) + if vmerge == "restart": + anchor_at[col_cursor] = box + else: + anchor_at.pop(col_cursor, None) col_cursor += span - writer.ensure(row_h) - x0 = writer.m["left"] - y0 = writer.y - for col_i, span, width, wrapped, v_cont, fill in cell_layouts: - cx = x0 + sum(widths[:col_i]) - if v_cont: + + # 행 높이: ① 단일행 셀 내용 → ② trHeight 최소 → ③ 병합 셀 부족분은 + # 마지막 스팬 행에 몰아준다 (한/워드의 자동 늘림과 같은 근사). + row_h = list(min_row_h) + for box in boxes: + if box.rowspan == 1: + r = box.row + if r < len(row_h): + row_h[r] = max(row_h[r], box.content_h() + pad * 2) + for box in boxes: + if box.rowspan > 1: + end = min(box.row + box.rowspan, len(row_h)) + have = sum(row_h[box.row:end]) + need = box.content_h() + pad * 2 + if need > have and end - 1 >= box.row: + row_h[end - 1] += need - have + return boxes, row_h + + +def _draw_cell_box(writer: _PageWriter, table_idx: int, box: _CellBox, + x: float, y: float, w: float, h: float, pad: float) -> None: + attrs = f' fill="{box.fill}"' if box.fill else ' fill="none"' + writer.raw( + f'' + f'' + ) + content_h = box.content_h() + if box.valign == "center": + ty = y + max(pad, (h - content_h) / 2) + elif box.valign == "bottom": + ty = y + max(pad, h - content_h - pad) + else: + ty = y + pad + inner_w = max(w - pad * 2, 1.0) + for ln, align in box.lines: + if ty + ln.height > y + h + 0.5: # 넘치는 줄은 셀 경계에서 끊는다 + break + baseline = ty + ln.ascent + line_w = sum(s.width() for s in ln.segs) + tx = x + pad + if align == "center": + tx += max(0.0, (inner_w - line_w) / 2) + elif align == "right": + tx += max(0.0, inner_w - line_w) + for seg2 in ln.segs: + if seg2.text: + style = [] + if seg2.italic: + style.append('font-style="italic"') + if seg2.bold: + style.append('font-weight="bold"') + deco = seg2.deco() + if deco: + style.append(f'text-decoration="{deco}"') + fam = f"'{seg2.family}', {_FONT_STACK}" if seg2.family else _FONT_STACK + writer.raw( + f'' + f"{_esc(seg2.text)}" + ) + tx += seg2.width() + ty += ln.height + writer.raw("") + + +def _layout_table(writer: _PageWriter, table, table_idx: int) -> None: + """표 전체를 격자 모델로 그린다 — 병합(gridSpan/vMerge)은 앵커 셀 + 사각형 하나가 스팬 전체를 덮는다. 페이지에 다 안 들어가면 행 경계에서 + 쪼개고, 경계를 걸치는 병합 셀은 그 페이지 조각까지만 그린다.""" + tbl_el = table._tbl + widths = _grid_widths(tbl_el, writer.content_w) + pad = 4.0 + document = getattr(table, "_parent", None) + + boxes, row_h = _table_model(tbl_el, document, widths, pad) + if not row_h: + return + n_rows = len(row_h) + col_x = [0.0] + for w in widths: + col_x.append(col_x[-1] + w) + + r = 0 + while r < n_rows: + avail = writer.bottom - writer.y + total_rest = sum(row_h[r:]) + if total_rest > avail and writer.y > writer.m["top"] + 1: + writer.page_break() + avail = writer.bottom - writer.y + # 이 페이지에 들어가는 행 조각 [r, chunk_end) + chunk_end, acc = r, 0.0 + while chunk_end < n_rows and (acc + row_h[chunk_end] <= avail or chunk_end == r): + acc += row_h[chunk_end] + chunk_end += 1 + y_of = {r: writer.y} + for ri in range(r, chunk_end): + y_of[ri + 1] = y_of[ri] + row_h[ri] + for box in boxes: + b_end = box.row + box.rowspan + if b_end <= r or box.row >= chunk_end: continue - attrs = f' fill="{fill}"' if fill else ' fill="none"' - writer.raw( - f'' - f'' - ) - ty = y0 + pad - for ln in wrapped: - baseline = ty + ln.ascent - tx = cx + pad - for seg2 in ln.segs: - writer.raw( - f'' - f"{_esc(seg2.text)}" - ) - tx += seg2.width() - ty += ln.height - writer.raw("") - writer.y += row_h + # 페이지 조각으로 클립 — 경계를 걸치면 이 조각 몫만 그린다. + top_r = max(box.row, r) + bot_r = min(b_end, chunk_end) + x = writer.m["left"] + col_x[min(box.col, len(widths))] + w = sum(widths[box.col:box.col + box.colspan]) or widths[-1] + h = sum(row_h[top_r:bot_r]) + draw = box if top_r == box.row else _CellBox( + row=box.row, col=box.col, fill=box.fill) # 이월 조각은 빈 칸 + _draw_cell_box(writer, table_idx, draw, x, y_of[top_r], w, h, pad) + writer.y += acc + r = chunk_end writer.y += _PARA_GAP_PX @@ -646,6 +834,7 @@ def _hf_segments(container) -> list[_Seg]: if text_present: if heading: writer.y += _PARA_GAP_PX # breathing room above headings + align = _para_align(child) first = True for segs in logical_lines: if bullet and first and segs: @@ -653,7 +842,7 @@ def _hf_segments(container) -> list[_Seg]: bold=segs[0].bold, color="#222222")] + segs for ln in _wrap_segments(segs, writer.content_w - indent): if ln.segs: - writer.emit_line(ln, indent=indent) + writer.emit_line(ln, indent=indent, align=align) first = False writer.y += _PARA_GAP_PX elif not drew_image: diff --git a/src/xgen_edit2docs/documents/legacy/hwp_convert.py b/src/xgen_edit2docs/documents/legacy/hwp_convert.py index fdfb819f..e58209e3 100644 --- a/src/xgen_edit2docs/documents/legacy/hwp_convert.py +++ b/src/xgen_edit2docs/documents/legacy/hwp_convert.py @@ -1,22 +1,37 @@ """HWP 5.0 (한글 바이너리) → DOCX 구조 보존 변환. -HWP 5.0 은 OLE 복합문서다 (레퍼런스: 한컴 공개 스펙 + reference_data/pyhwp): +HWP 5.0 은 OLE 복합문서다 (레퍼런스: 한컴 공개 스펙 白書 — +reference_data/hwpml_3.0_spec.pdf 의 구조 의미 + pyhwp binmodel 의 바이트 +레이아웃, 표 번호는 한컴 스펙 기준): FileHeader 32B 시그니처 + version + flags(bit0 압축, bit1 암호) - DocInfo 레코드 스트림 — CHAR_SHAPE(글자 모양) 등 + DocInfo 레코드 스트림 — ID_MAPPINGS/FACE_NAME/BORDER_FILL/ + CHAR_SHAPE/PARA_SHAPE/BIN_DATA … BodyText/Section0… 레코드 스트림 — PARA_HEADER/PARA_TEXT/ - PARA_CHAR_SHAPE/CTRL_HEADER/TABLE/LIST_HEADER/PAGE_DEF + PARA_CHAR_SHAPE/CTRL_HEADER/LIST_HEADER/TABLE/ + SHAPE_COMPONENT(_PICTURE)/PAGE_DEF + BinData/BIN%04X.ext 삽입 이미지 원본 (압축 플래그를 따라간다) 레코드 헤더는 UINT32 하나: tagid(10b) | level(10b) | size(12b, 0xFFF 이면 다음 UINT32 가 실제 크기). 압축 플래그가 켜진 스트림은 raw zlib(-15). +레코드의 부모-자식 관계는 level 로 표현된다 — 여기서는 트리로 복원한 뒤 +의미 단위(문단/표/그림/글상자/머리말)로 해석한다. 본문 텍스트는 UTF-16LE 이되 0x00~0x1F 코드가 컨트롤 문자다 — 문자형(1워드)/ 인라인·확장형(8워드) 크기 표에 따라 건너뛴다 (pyhwp ControlChar 표와 동일). -확장형 0x0B(표/그리기 개체)가 표의 앵커다. -충실도 범위: 문단·런 스타일(크기/굵게/기울임/밑줄/색)·표(rows×cols, 셀 -텍스트)·페이지 크기/여백. 그림·수식·글상자 위치는 범위 밖(글상자 텍스트는 -본문으로 이어붙는다 — 내용 유실은 없다). +충실도 범위: +- 문단: 정렬(PARA_SHAPE align), 런 스타일(글꼴/크기/굵게/기울임/밑줄/ + 취소선/색 — CHAR_SHAPE 표 28/30) +- 표(표 70~75): rows×cols 격자, **병합(colspan/rowspan → gridSpan/vMerge)**, + 열 너비/행 높이, 셀 배경(BORDER_FILL 표 18/23), 셀 안 문단 전체 스타일, + 중첩 표(셀 안 표 — 재귀), 캡션 +- 그림(표 102): BinData 임베딩 → docx 인라인 이미지 (개체 요소 크기 반영) +- 글상자: 텍스트를 본문 문단으로 (위치는 범위 밖 — 내용 유실 없음) +- 머리말/꼬리말: 첫 정의를 docx 섹션 header/footer 텍스트로 +- 페이지: PAGE_DEF 크기/여백 + +수식·도형 좌표·각주는 범위 밖. 암호/배포용 문서는 명시 거부. """ from __future__ import annotations @@ -26,20 +41,27 @@ import struct import zlib from dataclasses import dataclass, field -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Tuple from . import LegacyConvertError # ── 레코드 태그 (HWPTAG_BEGIN = 0x10) ─────────────────────────── _TAG_BEGIN = 0x10 +TAG_ID_MAPPINGS = _TAG_BEGIN + 1 # 0x11 DocInfo +TAG_BIN_DATA = _TAG_BEGIN + 2 # 0x12 DocInfo +TAG_FACE_NAME = _TAG_BEGIN + 3 # 0x13 DocInfo +TAG_BORDER_FILL = _TAG_BEGIN + 4 # 0x14 DocInfo TAG_CHAR_SHAPE = _TAG_BEGIN + 5 # 0x15 DocInfo +TAG_PARA_SHAPE = _TAG_BEGIN + 9 # 0x19 DocInfo TAG_PARA_HEADER = _TAG_BEGIN + 50 # 0x42 TAG_PARA_TEXT = _TAG_BEGIN + 51 # 0x43 TAG_PARA_CHAR_SHAPE = _TAG_BEGIN + 52 # 0x44 TAG_CTRL_HEADER = _TAG_BEGIN + 55 # 0x47 TAG_LIST_HEADER = _TAG_BEGIN + 56 # 0x48 TAG_PAGE_DEF = _TAG_BEGIN + 57 # 0x49 +TAG_SHAPE_COMPONENT = _TAG_BEGIN + 60 # 0x4C TAG_TABLE = _TAG_BEGIN + 61 # 0x4D +TAG_SHAPE_PICTURE = _TAG_BEGIN + 69 # 0x55 #: 컨트롤 문자 크기 표 (pyhwp ControlChar 와 동일) — 코드 → 워드 수. _CTRL_SIZES = { @@ -53,36 +75,197 @@ _HWPUNIT_PER_INCH = 7200.0 _EMU_PER_INCH = 914400.0 +#: python-docx 가 여는 이미지 컨테이너 — 그 외 확장자는 조용히 건너뛴다. +_DOCX_IMAGE_EXTS = {"png", "jpg", "jpeg", "gif", "bmp", "tif", "tiff"} + def _hu_to_emu(v: int) -> int: return int(round(v * _EMU_PER_INCH / _HWPUNIT_PER_INCH)) +# ── DocInfo 카탈로그 ─────────────────────────────────────────── + + @dataclass class _CharShape: + """표 28 글자 모양 — 렌더에 실리는 부분집합.""" size_pt: float = 10.0 bold: bool = False italic: bool = False underline: bool = False + strike: bool = False color: Optional[str] = None # RRGGBB + face_id: Optional[int] = None # 한글(ko) FaceName 참조 + + +@dataclass +class _DocInfo: + char_shapes: List[_CharShape] = field(default_factory=list) + #: PARA_SHAPE align — 'left' | 'center' | 'right' | 'justify' + para_aligns: List[str] = field(default_factory=list) + #: BORDER_FILL → 배경색 RRGGBB (채우기 없음/흰색은 None) + border_fill_bg: List[Optional[str]] = field(default_factory=list) + #: 한글(ko) 글꼴 이름 목록 — CHAR_SHAPE face_id 가 가리킨다. + ko_faces: List[str] = field(default_factory=list) + #: BIN_DATA 레코드 순서(1-based id) → (storage_id, ext). 링크형은 None. + bin_data: List[Optional[Tuple[int, str]]] = field(default_factory=list) + + +def _read_bstr(payload: bytes, off: int) -> Tuple[str, int]: + """BSTR: UINT16 글자 수 + UTF-16LE. (문자열, 다음 오프셋).""" + if off + 2 > len(payload): + return "", off + (n,) = struct.unpack_from(" len(payload): + return "", off + return payload[off + 2:end].decode("utf-16le", errors="replace"), end + + +def _parse_char_shape(payload: bytes) -> _CharShape: + """표 28/30 레이아웃: FontFace 7×WORD(14) + 폭/자간/상대크기/위치 + 7×BYTE ×4(28) = 42, INT32 basesize(pt×100), UINT32 flags(bit0 italic, + bit1 bold, bit2-3 밑줄 종류 — 1 밑줄/2 취소선/3 윗줄), COLORREF @52.""" + st = _CharShape() + if len(payload) >= 2: + (st.face_id,) = struct.unpack_from("= 46: + (base,) = struct.unpack_from("= 50: + (flags,) = struct.unpack_from("> 2) & 0x3 + st.underline = kind == 1 + st.strike = kind == 2 + if len(payload) >= 56: + (colorref,) = struct.unpack_from("> 8) & 0xFF, (colorref >> 16) & 0xFF + if (r, g, b) != (255, 255, 255): + st.color = f"{r:02X}{g:02X}{b:02X}" + return st + + +#: 표 39 문단 모양 속성1 align (bits 2-4) → docx 정렬. +_ALIGN_MAP = {0: "justify", 1: "left", 2: "right", 3: "center", + 4: "justify", 5: "justify"} + + +def _parse_para_shape(payload: bytes) -> str: + if len(payload) >= 4: + (flags,) = struct.unpack_from("> 2) & 0x7, "left") + return "left" + + +def _parse_border_fill(payload: bytes) -> Optional[str]: + """표 18: borderflags(2) + Border(6)×5 = 32, fillflags UINT32 @32, + colorpattern 이면 background COLORREF @36.""" + if len(payload) >= 40: + (fillflags,) = struct.unpack_from("> 8) & 0xFF, (colorref >> 16) & 0xFF + if (r, g, b) != (255, 255, 255): + return f"{r:02X}{g:02X}{b:02X}" + return None + + +def _parse_bin_data(payload: bytes) -> Optional[Tuple[int, str]]: + """표 12: flags UINT16 — EMBEDDING(1)/STORAGE(2)면 storage_id UINT16 + + (EMBEDDING 은) ext BSTR. 링크형(0)은 외부 파일이라 None.""" + if len(payload) < 4: + return None + (flags,) = struct.unpack_from(" _DocInfo: + info = _DocInfo() + face_names: List[str] = [] + ko_face_count: Optional[int] = None + for tagid, _level, payload in _iter_records(data): + if tagid == TAG_ID_MAPPINGS: + # 표 8: INT32 배열 — [0] binData, [1..7] 언어별 글꼴 수(ko 부터). + n = len(payload) // 4 + if n >= 2: + (ko_face_count,) = struct.unpack_from(" List[_Node]: + """레코드 평면열 → level 기반 트리 (부모 = 직전의 더 얕은 레코드).""" + roots: List[_Node] = [] + stack: List[_Node] = [] + for tagid, level, payload in _iter_records(data): + node = _Node(tagid, level, payload) + while stack and stack[-1].level >= level: + stack.pop() + (stack[-1].children if stack else roots).append(node) + stack.append(node) + return roots + + def _decompress(raw: bytes) -> bytes: """HWP 압축 스트림 = raw deflate. 뒤에 패딩이 붙어 있어도 관용한다.""" d = zlib.decompressobj(-15) @@ -128,10 +332,9 @@ def _decompress(raw: bytes) -> bytes: return out -def _parse_text_chunks(payload: bytes) -> tuple[str, bool]: - """PARA_TEXT → (본문 텍스트, 표 앵커 여부). 탭/줄바꿈은 보존.""" +def _parse_text_chunks(payload: bytes) -> str: + """PARA_TEXT → 본문 텍스트. 탭/줄바꿈은 보존, 컨트롤 워드는 건너뛴다.""" parts: List[str] = [] - has_table = False idx, n = 0, len(payload) while idx < n: m = _CTRL_RE.search(payload, idx) @@ -149,35 +352,8 @@ def _parse_text_chunks(payload: bytes) -> tuple[str, bool]: parts.append("\t") elif code == 0x0A: parts.append("\n") - elif code == 0x0B: - has_table = True idx = ctrl + words * 2 - return "".join(parts), has_table - - -def _parse_char_shape(payload: bytes) -> _CharShape: - """DocInfo CHAR_SHAPE — 표 28/30 레이아웃 (pyhwp binmodel 과 동일 오프셋). - - FontFace 7×WORD(14) + 폭/자간/상대크기/위치 7×BYTE ×4(28) = 42, - INT32 basesize(pt×100), UINT32 flags(bit0 italic, bit1 bold, - bit2-3 underline), INT8×2 shadow, COLORREF text_color(0x00BBGGRR). - """ - st = _CharShape() - if len(payload) >= 46: - (base,) = struct.unpack_from("= 50: - (flags,) = struct.unpack_from("> 2) & 0x3) == 1 - if len(payload) >= 56: - (colorref,) = struct.unpack_from("> 8) & 0xFF, (colorref >> 16) & 0xFF - if (r, g, b) != (255, 255, 255): - st.color = f"{r:02X}{g:02X}{b:02X}" - return st + return "".join(parts) def _parse_page_def(payload: bytes) -> _PageDef: @@ -191,86 +367,173 @@ def _parse_page_def(payload: bytes) -> _PageDef: return pd -# ── 섹션 → 문단/표 시퀀스 ────────────────────────────────────── +def _chid_of(payload: bytes) -> str: + """CTRL_HEADER 의 chid — 리틀엔디언 4바이트 ('tbl ' 은 b' lbt').""" + if len(payload) < 4: + return "" + return payload[:4][::-1].decode("ascii", errors="replace") + + +# ── 트리 해석 ────────────────────────────────────────────────── -def _parse_section(data: bytes) -> tuple[List[object], Optional[_PageDef]]: - """BodyText/Section 레코드 → [_Para | _Table] 블록 목록 (+PAGE_DEF). +def _parse_cell_props(payload: bytes) -> _Cell: + """표 60 리스트 헤더(8B) 뒤에 표 75 셀 속성이 붙는다. - 레벨 규칙: 본문 문단은 level 0. 표는 CTRL_HEADER('tbl ') 다음의 TABLE - 레코드로 열리고, LIST_HEADER 마다 다음 셀(행 우선)로 넘어가며, 그 아래 - 깊은 level 의 문단들이 셀 내용이다. 표보다 얕은 레벨의 레코드가 오면 - 표가 닫힌다. + 셀 속성이 잘려 있으면(이형 파일) 좌표를 -1 로 표시한다 — 호출자가 + 행 우선 순서로 재배정한다. """ - blocks: List[object] = [] - page: Optional[_PageDef] = None + c = _Cell() + if len(payload) >= 16: + c.col, c.row, c.colspan, c.rowspan = struct.unpack_from("<4H", payload, 8) + c.colspan = max(1, c.colspan) + c.rowspan = max(1, c.rowspan) + else: + c.col = c.row = -1 + if len(payload) >= 24: + c.width_hu, c.height_hu = struct.unpack_from("<2i", payload, 16) + if len(payload) >= 34: + (c.borderfill_id,) = struct.unpack_from(" Optional[_Table]: + """CTRL_HEADER('tbl ') 서브트리 → _Table. - def close_table(): - nonlocal open_table - open_table = None + TABLE(표 70) 레코드가 격자 크기를, 그 **뒤의** LIST_HEADER 들이 셀을 + 준다 (표 75 — col/row/colspan/rowspan 포함). TABLE **앞의** LIST_HEADER + 는 캡션이다 (한컴 스펙의 before/after tablebody 구분). + """ + table = _Table() + seen_body = False + for child in ctrl.children: + if child.tagid == TAG_TABLE: + if len(child.payload) >= 8: + rows, cols = struct.unpack_from(" List[object]: + """CTRL_HEADER('gso ') 서브트리 → [_Image | _TextBox] (등장 순서). + + SHAPE_COMPONENT(표 78)가 개체 크기를, SHAPE_COMPONENT_PICTURE(표 102)의 + PictureInfo.bindata_id(오프셋 71)가 이미지 원본을 가리킨다. 글상자는 + SHAPE_COMPONENT 아래 LIST_HEADER 의 문단들이다. 컨테이너는 재귀. + """ + out: List[object] = [] + + def walk(node: _Node, width_hu: int, height_hu: int) -> None: + for child in node.children: + if child.tagid == TAG_SHAPE_COMPONENT: + w, h = width_hu, height_hu + if len(child.payload) >= 36: + w2, h2 = struct.unpack_from("<2i", child.payload, 28) + if 0 < w2 <= 7200 * 100 and 0 < h2 <= 7200 * 100: + w, h = w2, h2 + walk(child, w, h) + elif child.tagid == TAG_SHAPE_PICTURE: + if len(child.payload) >= 73: + (bindata_id,) = struct.unpack_from(" List[object]: + """PARA_HEADER 노드 목록 → [_Para] — 표/그림/글상자는 앵커 문단의 + attachments 로 붙는다 (본문 흐름상 그 문단 위치에서 등장). - if tagid == TAG_CTRL_HEADER: - # chid 는 리틀엔디언 4바이트 — 'tbl ' 은 b' lbt' 로 저장된다. - chid = payload[:4][::-1].decode("ascii", errors="replace") if len(payload) >= 4 else "" + 규격상 컨트롤은 앵커 문단의 자식이지만, 최상위에 직접 놓인 CTRL_HEADER + (이형/편집기 산출물)도 빈 앵커 문단으로 감싸 받아들인다. + """ + out: List[object] = [] + for node in nodes: + if node.tagid == TAG_CTRL_HEADER: + chid = _chid_of(node.payload) + holder = _Para() if chid == "tbl ": - open_table = _Table() - table_level = level - blocks.append(open_table) - close_para() + table = _interpret_table(node) + if table is not None: + holder.attachments.append(table) + elif chid == "gso ": + holder.attachments.extend(_interpret_gso(node)) + if holder.attachments: + out.append(holder) continue - - if tagid == TAG_TABLE and open_table is not None: - if len(payload) >= 8: - rows, cols = struct.unpack_from("= 10: + (para.parashape_id,) = struct.unpack_from(" table_level: - open_table.cells.append([]) - close_para() - continue - if tagid == TAG_PARA_HEADER: - cur_para = _Para() - if open_table is not None and level > table_level and open_table.cells: - open_table.cells[-1].append(cur_para) - else: - if open_table is not None: - close_table() - blocks.append(cur_para) - continue +def _find_records(nodes: List[_Node], tagid: int): + """트리 전체에서 tagid 레코드를 깊이 우선으로 찾는다.""" + for node in nodes: + if node.tagid == tagid: + yield node + yield from _find_records(node.children, tagid) - if tagid == TAG_PARA_TEXT and cur_para is not None: - text, has_table = _parse_text_chunks(payload) - cur_para.text += text - cur_para.has_table_anchor = cur_para.has_table_anchor or has_table - continue - if tagid == TAG_PARA_CHAR_SHAPE and cur_para is not None: - for off in range(0, len(payload) - 7, 8): - pos, shape_id = struct.unpack_from(" Tuple[List[_Para], List[_Para]]: + """첫 머리말('head')/꼬리말('foot') 정의의 문단들.""" + header: List[_Para] = [] + footer: List[_Para] = [] + for ctrl in _find_records(roots, TAG_CTRL_HEADER): + chid = _chid_of(ctrl.payload) + if chid not in ("head", "foot"): continue - - return blocks, page + target = header if chid == "head" else footer + if target: + continue # 첫 정의만 + for child in ctrl.children: + if child.tagid == TAG_LIST_HEADER: + target.extend(p for p in _interpret_paras(child.children) + if isinstance(p, _Para)) + return header, footer # ── DOCX 조립 ────────────────────────────────────────────────── @@ -279,6 +542,9 @@ def close_table(): def hwp_to_docx(content: bytes) -> bytes: import olefile from docx import Document + from docx.enum.text import WD_ALIGN_PARAGRAPH + from docx.oxml import OxmlElement + from docx.oxml.ns import qn from docx.shared import Emu, Pt, RGBColor if not olefile.isOleFile(io.BytesIO(content)): @@ -301,12 +567,26 @@ def read_stream(name: str) -> bytes: raw = ole.openstream(name).read() return _decompress(raw) if compressed else raw - # DocInfo — 글자 모양 목록 (id = 등장 순서) - char_shapes: List[_CharShape] = [] - if ole.exists("DocInfo"): - for tagid, _level, payload in _iter_records(read_stream("DocInfo")): - if tagid == TAG_CHAR_SHAPE: - char_shapes.append(_parse_char_shape(payload)) + info = _parse_doc_info(read_stream("DocInfo")) if ole.exists("DocInfo") \ + else _DocInfo() + + def bin_blob(bindata_id: int) -> Optional[Tuple[bytes, str]]: + """PictureInfo.bindata_id(1-based) → (원본 바이트, 확장자).""" + if not (1 <= bindata_id <= len(info.bin_data)): + return None + entry = info.bin_data[bindata_id - 1] + if entry is None: + return None + storage_id, ext = entry + if ext not in _DOCX_IMAGE_EXTS: + return None + name = f"BinData/BIN{storage_id:04X}.{ext}" + try: + if not ole.exists(name): + return None + return read_stream(name), ext + except Exception: # noqa: BLE001 — 깨진 이미지는 건너뛴다 + return None # BodyText/Section* — 숫자 순 section_names = sorted( @@ -321,8 +601,29 @@ def read_stream(name: str) -> bytes: doc = Document() page_applied = False + _WD_ALIGN = { + "left": WD_ALIGN_PARAGRAPH.LEFT, + "center": WD_ALIGN_PARAGRAPH.CENTER, + "right": WD_ALIGN_PARAGRAPH.RIGHT, + "justify": WD_ALIGN_PARAGRAPH.JUSTIFY, + } + def shape_of(idx: int) -> Optional[_CharShape]: - return char_shapes[idx] if 0 <= idx < len(char_shapes) else None + return info.char_shapes[idx] if 0 <= idx < len(info.char_shapes) \ + else None + + def face_name(face_id: Optional[int]) -> Optional[str]: + if face_id is None or not (0 <= face_id < len(info.ko_faces)): + return None + return info.ko_faces[face_id] or None + + def apply_align(para_obj, para: _Para) -> None: + pid = para.parashape_id + if pid is None or not (0 <= pid < len(info.para_aligns)): + return + align = info.para_aligns[pid] + if align != "left": + para_obj.alignment = _WD_ALIGN[align] def emit_runs(para_obj, para: _Para) -> None: text = para.text @@ -351,37 +652,174 @@ def emit_runs(para_obj, para: _Para) -> None: run.italic = True if st.underline: run.underline = True + if st.strike: + run.font.strike = True if st.color: run.font.color.rgb = RGBColor.from_string(st.color) - + name = face_name(st.face_id) + if name: + run.font.name = name + # 한글 글리프는 eastAsia 슬롯을 본다. + rPr = run._element.get_or_add_rPr() + rFonts = rPr.get_or_add_rFonts() + rFonts.set(qn("w:eastAsia"), name) + + def set_cell_bg(cell_obj, rgb: str) -> None: + tc_pr = cell_obj._tc.get_or_add_tcPr() + shd = tc_pr.find(qn("w:shd")) + if shd is None: + shd = OxmlElement("w:shd") + tc_pr.append(shd) + shd.set(qn("w:val"), "clear") + shd.set(qn("w:fill"), rgb) + + def fill_cell_paras(cell_obj, paras: List[_Para]) -> None: + first = True + for para in paras: + if first: + cell_obj.paragraphs[0].text = "" + target = cell_obj.paragraphs[0] + first = False + else: + target = cell_obj.add_paragraph() + apply_align(target, para) + emit_runs(target, para) + emit_attachments(para, container_cell=cell_obj) + + def emit_table(block: _Table, container_cell=None) -> None: + r_n, c_n = block.rows, block.cols + if container_cell is not None: + # 중첩 표 — 셀 안에 실제 표로 넣는다 (python-docx add_table). + tbl = container_cell.add_table(rows=r_n, cols=c_n) + else: + tbl = doc.add_table(rows=r_n, cols=c_n) + try: + tbl.style = "Table Grid" + except Exception: # noqa: BLE001 — 스타일 없는 템플릿 관용 + pass + + # 열 너비: colspan 1 셀의 표 75 width 로 gridCol 을 채운다. + col_w: Dict[int, int] = {} + for cell in block.cells: + if cell.colspan == 1 and 0 <= cell.col < c_n and cell.width_hu > 0: + col_w.setdefault(cell.col, cell.width_hu) + for j, column in enumerate(tbl.columns): + if j in col_w: + try: + column.width = Emu(_hu_to_emu(col_w[j])) + except Exception: # noqa: BLE001 + pass + # 행 높이: rowspan 1 셀 height 의 최대값 (atLeast 의미). + row_h: Dict[int, int] = {} + for cell in block.cells: + if cell.rowspan == 1 and 0 <= cell.row < r_n and cell.height_hu > 0: + row_h[cell.row] = max(row_h.get(cell.row, 0), cell.height_hu) + for i, row in enumerate(tbl.rows): + if i in row_h: + try: + row.height = Emu(_hu_to_emu(row_h[i])) + except Exception: # noqa: BLE001 + pass + + # 병합 먼저 (표 75 의 col/row/colspan/rowspan 은 절대 격자 좌표). + for cell in block.cells: + if cell.rowspan > 1 or cell.colspan > 1: + r2 = min(cell.row + cell.rowspan - 1, r_n - 1) + c2 = min(cell.col + cell.colspan - 1, c_n - 1) + if (r2, c2) != (cell.row, cell.col): + try: + tbl.cell(cell.row, cell.col).merge(tbl.cell(r2, c2)) + except Exception: # noqa: BLE001 — 겹침/범위 이상 관용 + pass + # 내용/배경. + for cell in block.cells: + if not (0 <= cell.row < r_n and 0 <= cell.col < c_n): + continue + try: + cell_obj = tbl.cell(cell.row, cell.col) + except Exception: # noqa: BLE001 + continue + bg = None + if 0 <= cell.borderfill_id - 1 < len(info.border_fill_bg): + bg = info.border_fill_bg[cell.borderfill_id - 1] + if bg: + set_cell_bg(cell_obj, bg) + fill_cell_paras(cell_obj, cell.paras) + for cap in block.caption: + p = doc.add_paragraph() if container_cell is None \ + else container_cell.add_paragraph() + apply_align(p, cap) + emit_runs(p, cap) + + def emit_image(img: _Image, container_cell=None) -> None: + got = bin_blob(img.bindata_id) + if got is None: + return + blob, _ext = got + target = doc.add_paragraph() if container_cell is None \ + else container_cell.add_paragraph() + run = target.add_run() + try: + kwargs = {} + if img.width_hu > 0: + kwargs["width"] = Emu(_hu_to_emu(img.width_hu)) + if img.height_hu > 0: + kwargs["height"] = Emu(_hu_to_emu(img.height_hu)) + run.add_picture(io.BytesIO(blob), **kwargs) + except Exception: # noqa: BLE001 — 못 여는 이미지는 건너뛴다 + pass + + def emit_attachments(para: _Para, container_cell=None) -> None: + for att in para.attachments: + if isinstance(att, _Table): + emit_table(att, container_cell=container_cell) + elif isinstance(att, _Image): + emit_image(att, container_cell=container_cell) + elif isinstance(att, _TextBox): + for tb_para in att.paras: + p = doc.add_paragraph() if container_cell is None \ + else container_cell.add_paragraph() + apply_align(p, tb_para) + emit_runs(p, tb_para) + + header_done = False for sec_name in section_names: - blocks, page = _parse_section(read_stream(sec_name)) - if page is not None and not page_applied: - sec = doc.sections[0] - sec.page_width = Emu(_hu_to_emu(page.width)) - sec.page_height = Emu(_hu_to_emu(page.height)) - sec.left_margin = Emu(_hu_to_emu(page.left)) - sec.right_margin = Emu(_hu_to_emu(page.right)) - sec.top_margin = Emu(_hu_to_emu(page.top)) - sec.bottom_margin = Emu(_hu_to_emu(page.bottom)) - page_applied = True - for block in blocks: - if isinstance(block, _Para): - emit_runs(doc.add_paragraph(), block) - elif isinstance(block, _Table) and block.rows and block.cols: - table = doc.add_table(rows=block.rows, cols=block.cols) - table.style = "Table Grid" - for ci, cell_paras in enumerate(block.cells[: block.rows * block.cols]): - cell = table.cell(ci // block.cols, ci % block.cols) - first = True - for para in cell_paras: - if first: - cell.paragraphs[0].text = "" - target = cell.paragraphs[0] - first = False - else: - target = cell.add_paragraph() - emit_runs(target, para) + roots = _build_tree(read_stream(sec_name)) + + if not page_applied: + for pd_node in _find_records(roots, TAG_PAGE_DEF): + page = _parse_page_def(pd_node.payload) + sec = doc.sections[0] + sec.page_width = Emu(_hu_to_emu(page.width)) + sec.page_height = Emu(_hu_to_emu(page.height)) + sec.left_margin = Emu(_hu_to_emu(page.left)) + sec.right_margin = Emu(_hu_to_emu(page.right)) + sec.top_margin = Emu(_hu_to_emu(page.top)) + sec.bottom_margin = Emu(_hu_to_emu(page.bottom)) + page_applied = True + break + + if not header_done: + h_paras, f_paras = _header_footer_paras(roots) + h_text = " ".join(p.text.strip() for p in h_paras if p.text.strip()) + f_text = " ".join(p.text.strip() for p in f_paras if p.text.strip()) + try: + if h_text: + doc.sections[0].header.paragraphs[0].text = h_text + if f_text: + doc.sections[0].footer.paragraphs[0].text = f_text + except Exception: # noqa: BLE001 + pass + header_done = bool(h_text or f_text) + + for block in _interpret_paras(roots): + if not isinstance(block, _Para): + continue + if block.text or not block.attachments: + p = doc.add_paragraph() + apply_align(p, block) + emit_runs(p, block) + emit_attachments(block) buf = io.BytesIO() doc.save(buf) diff --git a/src/xgen_edit2docs/simple.py b/src/xgen_edit2docs/simple.py index f3df063c..61c197d5 100644 --- a/src/xgen_edit2docs/simple.py +++ b/src/xgen_edit2docs/simple.py @@ -462,9 +462,28 @@ def _walk(el, shape_id=None, table_id=None, cell=None): _LEGACY_FORMATS = ("hwp", "hwpx", "doc", "xls", "ppt") -def _fmt_of(path: str | Path) -> str: +def _fmt_of(path: str | Path, *, legacy: str = "reject") -> str: + """확장자 → 포맷. *legacy* 정책이 레거시(hwp/hwpx/doc/xls/ppt) 취급을 + 가른다: + + - ``"allow"`` 렌더/미리보기/분석 같은 **읽기 계열** — 호출자가 + :func:`_normalize_legacy` 로 OOXML 사본을 만들어 진행한다. + - ``"reject"`` (기본) **편집/생성 계열** — 레거시를 받으면 변환 사본을 + 고치게 되어 원본에는 반영되지 않으므로 명시적으로 거부한다. + (기본을 reject 로 둔 이유: 새 진입점이 정책 지정을 잊으면 조용히 + 엉뚱한 분기로 흐르는 대신 즉시 명확한 에러가 난다 — v0.19.0 에서 + analyze_doc 이 hwp 를 xlsx 분기로 흘린 실사고의 재발 방지.) + """ suffix = Path(path).suffix.lower().lstrip(".") - if suffix not in _DOC_FORMATS and suffix not in _LEGACY_FORMATS: + if suffix in _LEGACY_FORMATS: + if legacy == "allow": + return suffix + raise ValueError( + f"Legacy format .{suffix} is read-only here: render/preview/" + f"analyze 는 지원하지만 편집·생성은 .docx/.xlsx/.pptx 만 가능합니다 " + f"({Path(path).name})" + ) + if suffix not in _DOC_FORMATS: raise ValueError( f"Unsupported document format: {Path(path).name} " f"(supported: {', '.join('.' + f for f in _DOC_FORMATS + _LEGACY_FORMATS)})" @@ -509,7 +528,7 @@ def render_doc( Returns: :class:`RenderResult` with the written file paths. """ - _fmt_of(doc) # 확장자 검증 (레거시 포함) + _fmt_of(doc, legacy="allow") # 확장자 검증 (레거시 포함) doc = _normalize_legacy(doc) fmt = _fmt_of(doc) to = (to or "png").strip().lower() @@ -691,7 +710,7 @@ def preview_doc( ``preview.md`` file with ``out_dir``). 레거시 포맷(hwp/hwpx/doc/xls/ppt) 은 OOXML 로 정규화된 뒤 같은 경로를 탄다. """ - _fmt_of(doc) + _fmt_of(doc, legacy="allow") doc = _normalize_legacy(doc) fmt = _fmt_of(doc) if fmt == "pptx": @@ -724,6 +743,8 @@ def analyze_doc(doc: str | Path) -> dict: """ from .documents.chart_edit import list_charts + _fmt_of(doc, legacy="allow") + doc = _normalize_legacy(doc) fmt = _fmt_of(doc) content = _read_pptx(doc) if fmt == "pptx": @@ -749,6 +770,8 @@ def list_charts(doc: str | Path) -> list[dict]: :func:`edit_chart` takes.""" from .documents.chart_edit import list_charts as _list + _fmt_of(doc, legacy="allow") + doc = _normalize_legacy(doc) return _list(_read_pptx(doc), _fmt_of(doc)) @@ -849,7 +872,8 @@ def list_doc_parts(doc: str | Path) -> list[dict]: """ from .documents.xml_edit import list_parts - _fmt_of(doc) # gate to the supported formats + _fmt_of(doc, legacy="allow") # gate to the supported formats + doc = _normalize_legacy(doc) return list_parts(_read_pptx(doc)) @@ -862,7 +886,8 @@ def get_doc_xml(doc: str | Path, part: str) -> str: """ from .documents.xml_edit import get_xml - _fmt_of(doc) + _fmt_of(doc, legacy="allow") + doc = _normalize_legacy(doc) return get_xml(_read_pptx(doc), part) diff --git a/tests/integration/test_doc_generation_editing.py b/tests/integration/test_doc_generation_editing.py index 63eecf2d..2fd6d5e8 100644 --- a/tests/integration/test_doc_generation_editing.py +++ b/tests/integration/test_doc_generation_editing.py @@ -217,11 +217,24 @@ def test_extension_dispatch_and_deterministic_verbs(self, tmp_path: Path): def test_unsupported_extension_raises(self, tmp_path: Path): from xgen_edit2docs import analyze_doc - bad = tmp_path / "file.hwp" + bad = tmp_path / "file.xyz" bad.write_bytes(b"x") with pytest.raises(ValueError, match="Unsupported document format"): analyze_doc(bad) + def test_legacy_extension_contract(self, tmp_path: Path): + """레거시(.hwp)는 읽기 계열에서 정규화 대상 — 깨진 파일이면 + LegacyConvertError, 편집 계열이면 정규화 전에 read-only 거부.""" + from xgen_edit2docs import analyze_doc, set_doc_text + from xgen_edit2docs.documents.legacy import LegacyConvertError + + junk = tmp_path / "file.hwp" + junk.write_bytes(b"x") + with pytest.raises(LegacyConvertError): + analyze_doc(junk) # v0.19.0 회귀: xlsx 분기로 흘러 엉뚱한 에러가 났다 + with pytest.raises(ValueError, match="read-only"): + set_doc_text(junk, []) + def test_agent_tools_dispatch(self, tmp_path: Path): from xgen_edit2docs.agent_tools import TOOL_NAMES, run_tool diff --git a/tests/unit/test_docx_pages.py b/tests/unit/test_docx_pages.py index 48035b01..79c9e87f 100644 --- a/tests/unit/test_docx_pages.py +++ b/tests/unit/test_docx_pages.py @@ -55,3 +55,97 @@ def test_text_is_escaped(self): joined = "".join(docx_to_page_svgs(_doc("a < b & c > d"))) assert "<" in joined and "&" in joined assert "a < b" not in joined + + +def _docx_bytes(doc) -> bytes: + import io + + buf = io.BytesIO() + doc.save(buf) + return buf.getvalue() + + +class TestTableFidelity: + """격자 모델 — 병합 기하/셀 스타일/정렬/행높이 (python-docx 로 직접 조립).""" + + def test_vmerge_draws_one_spanning_rect(self): + import re + + from docx import Document + + doc = Document() + t = doc.add_table(rows=2, cols=2) + t.cell(0, 0).merge(t.cell(1, 0)) + t.cell(0, 0).text = "세로병합" + t.cell(0, 1).text = "위" + t.cell(1, 1).text = "아래" + joined = "".join(docx_to_page_svgs(_docx_bytes(doc))) + # 병합 앵커 텍스트는 한 번만, 그 셀의 rect 는 오른쪽 셀 두 개 높이의 합 + assert joined.count("세로병합") == 1 + heights = [float(h) for h in re.findall(r']*height="([\d.]+)"', joined)] + assert len(heights) >= 4 # 페이지 배경 + 셀 3개 + cell_hs = sorted(heights[1:]) + assert cell_hs[-1] >= cell_hs[0] * 1.9 # 앵커 rect ≈ 2행 높이 + + def test_cell_runs_keep_styles_and_alignment(self): + from docx import Document + from docx.enum.text import WD_ALIGN_PARAGRAPH + from docx.shared import RGBColor + + doc = Document() + t = doc.add_table(rows=1, cols=1) + p = t.cell(0, 0).paragraphs[0] + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + run = p.add_run("빨강 굵게") + run.bold = True + run.font.color.rgb = RGBColor.from_string("FF0000") + run2 = p.add_run("취소선") + run2.font.strike = True + joined = "".join(docx_to_page_svgs(_docx_bytes(doc))) + assert 'font-weight="bold"' in joined + assert "#FF0000" in joined + assert "line-through" in joined + + def test_body_paragraph_alignment(self): + from docx import Document + from docx.enum.text import WD_ALIGN_PARAGRAPH + + doc = Document() + left = doc.add_paragraph("왼쪽정렬문장") + center = doc.add_paragraph("가운데정렬문장") + center.alignment = WD_ALIGN_PARAGRAPH.CENTER + joined = "".join(docx_to_page_svgs(_docx_bytes(doc))) + + def x_of(text: str) -> float: + import re + + m = re.search(rf']*>{text}', joined) + assert m, text + return float(m.group(1)) + + assert x_of("가운데정렬문장") > x_of("왼쪽정렬문장") + 50 + + def test_trheight_minimum_is_honored(self): + import re + + from docx import Document + from docx.shared import Twips + + doc = Document() + t = doc.add_table(rows=1, cols=1) + t.cell(0, 0).text = "x" + t.rows[0].height = Twips(3000) # 3000/15 = 200px + joined = "".join(docx_to_page_svgs(_docx_bytes(doc))) + heights = [float(h) for h in re.findall(r']*height="([\d.]+)"', joined)] + assert any(abs(h - 200.0) < 1.0 for h in heights) + + def test_nested_table_content_not_lost(self): + from docx import Document + + doc = Document() + t = doc.add_table(rows=1, cols=1) + inner = t.cell(0, 0).add_table(rows=1, cols=2) + inner.cell(0, 0).text = "안쪽A" + inner.cell(0, 1).text = "안쪽B" + joined = "".join(docx_to_page_svgs(_docx_bytes(doc))) + assert "안쪽A" in joined and "안쪽B" in joined diff --git a/tests/unit/test_legacy_formats.py b/tests/unit/test_legacy_formats.py index b91b91b5..f9c2a45f 100644 --- a/tests/unit/test_legacy_formats.py +++ b/tests/unit/test_legacy_formats.py @@ -242,6 +242,138 @@ def comp(b: bytes) -> bytes: }) +def _tiny_png() -> bytes: + """1×1 RGB PNG — CRC 까지 규격대로 조립한다.""" + def chunk(kind: bytes, body: bytes) -> bytes: + return (struct.pack(">I", len(body)) + kind + body + + struct.pack(">I", zlib.crc32(kind + body) & 0xFFFFFFFF)) + + ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0) + idat = zlib.compress(b"\x00\xff\x00\x00") # filter 0 + RGB(255,0,0) + return (b"\x89PNG\r\n\x1a\x0a" + chunk(b"IHDR", ihdr) + + chunk(b"IDAT", idat) + chunk(b"IEND", b"")) + + +def make_hwp_rich() -> bytes: + """심화 픽스처 — 병합/배경/정렬/취소선/글꼴/이미지/머리말을 전부 싣는다. + + 실파일 규격 그대로: 컨트롤(CTRL_HEADER)은 앵커 문단의 자식 레벨, + 셀 LIST_HEADER 는 표 75 셀 속성(col/row/colspan/rowspan/크기/borderfill) + 을 온전히 갖는다. + """ + header = bytearray(256) + header[0:32] = b"HWP Document File".ljust(32, b"\x00") + struct.pack_into(" bytes: + return struct.pack(" bytes: + p = bytearray(44) + if bg_colorref is not None: + struct.pack_into(" bytes: + p = bytearray(72) + struct.pack_into(" bytes: + return struct.pack(" bytes: + p = bytearray(16) + struct.pack_into(" bytes: + p = bytearray(40) + struct.pack_into("<4H", p, 8, col, row, colspan, rowspan) + struct.pack_into("<2i", p, 16, w, h) + struct.pack_into(" bytes: + return b"".join([ + _rec(0x48, level, props), + _rec(0x42, level + 1, para_header(0)), + _rec(0x43, level + 2, _utf16(text)), + _rec(0x44, level + 2, struct.pack(" bytes: + co = zlib.compressobj(6, zlib.DEFLATED, -15) + return co.compress(b) + co.flush() + + return build_cfb({ + "FileHeader": bytes(header), + "DocInfo": comp(docinfo), + "BodyText/Section0": comp(section), + "BinData/BIN0001.png": comp(_tiny_png()), + }) + + def make_hwpx(text: str = "HWPX 본문", cell: str = "표셀") -> bytes: header_xml = """ @@ -406,6 +538,76 @@ def test_non_hwp_bytes_are_refused(self): convert_to_ooxml(b"not an ole file at all", "hwp") +@pytest.fixture(scope="module") +def rich_docx(): + from docx import Document + + docx_bytes, fmt = convert_to_ooxml(make_hwp_rich(), "hwp") + assert fmt == "docx" + return Document(io.BytesIO(docx_bytes)) + + +class TestHwpFidelity: + """심화 충실도 — 표 75 병합/배경, 표 38 정렬, 표 28 취소선·글꼴, + 표 102 그림, 머리말. 픽스처는 make_hwp_rich (실파일 레벨 배치).""" + + def test_colspan_merge(self, rich_docx): + tbl = rich_docx.tables[0] + assert tbl.cell(0, 0).text.strip() == "병합 머리" + xml = tbl._tbl.xml + assert 'gridSpan' in xml and 'w:val="2"' in xml + # 병합 셀과 (0,1) 이 같은 tc 를 공유한다 + assert tbl.cell(0, 0)._tc is tbl.cell(0, 1)._tc + + def test_rowspan_merge(self, rich_docx): + tbl = rich_docx.tables[0] + assert tbl.cell(0, 2).text.strip() == "세로 병합" + assert 'vMerge' in tbl._tbl.xml + assert tbl.cell(0, 2)._tc is tbl.cell(1, 2)._tc + + def test_cell_background_from_borderfill(self, rich_docx): + # borderfill id 2 = COLORREF 0x00CCFF → #FFCC00 + assert 'FFCC00' in rich_docx.tables[0]._tbl.xml + + def test_paragraph_alignment(self, rich_docx): + from docx.enum.text import WD_ALIGN_PARAGRAPH + + first = rich_docx.paragraphs[0] + assert first.text == "가운데 취소선" + assert first.alignment == WD_ALIGN_PARAGRAPH.CENTER + + def test_strike_color_and_face(self, rich_docx): + run = rich_docx.paragraphs[0].runs[0] + assert run.font.strike + assert str(run.font.color.rgb) == "FF0000" + assert run.font.name == "함초롬돋움" + last = [p for p in rich_docx.paragraphs if p.text == "본문 끝"][0] + assert last.runs[0].bold + + def test_image_embedded_with_size(self, rich_docx): + shapes = rich_docx.inline_shapes + assert len(shapes) == 1 + # 14400×7200 HWPUNIT = 2in × 1in + assert abs(shapes[0].width - 914400 * 2) < 2000 + assert abs(shapes[0].height - 914400) < 2000 + + def test_header_text(self, rich_docx): + assert "머리말 텍스트" in rich_docx.sections[0].header.paragraphs[0].text + + def test_column_widths_reach_grid(self, rich_docx): + xml = rich_docx.tables[0]._tbl.xml + assert "gridCol" in xml + + def test_e2e_svg_render(self, tmp_path): + svg = _render_svg_pages(tmp_path, "rich.hwp", make_hwp_rich()) + assert "병합 머리" in svg and svg.count("병합 머리") == 1 + assert "세로 병합" in svg + assert "#FFCC00" in svg # 셀 배경 + assert "line-through" in svg # 취소선 + assert "