From 9d34e69969ba3f31cf881676bab7f6398fd618c2 Mon Sep 17 00:00:00 2001 From: CocoRoF Date: Mon, 31 Aug 2026 16:03:04 +0900 Subject: [PATCH] =?UTF-8?q?feat(legacy):=20doc/xls/ppt=20=EC=8B=AC?= =?UTF-8?q?=EC=B8=B5=20=EC=B6=A9=EC=8B=A4=EB=8F=84=20=E2=80=94=20=EC=84=9C?= =?UTF-8?q?=EC=8B=9D/=ED=91=9C/=EC=A0=95=EB=A0=AC/=EC=83=89=20(0.21.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hwp(0.20.0)와 같은 수준으로 나머지 레거시 3종을 고도화한다. 바이트 레이아웃은 reference_data 의 Apache POI(HWPF/HSLF sparse clone)와 xlrd 를 대조해 확정했다. doc (Word97) — 텍스트-만에서 서식·표 재조립으로 재설계: - CHPX/PAPX **FKP bin table** 해석 (PlcfBteChpx/Papx @FIB 0x00FA/0x0102, FKP 페이지 rgfc/rgb/BX, PAPX istd 스킵 규칙) - sprm 해석기 (spra 크기 규칙 + 0xD608/0xC615 2B 길이 예외): 굵게/기울임/ 취소선(토글 1·0x81)/밑줄(kul)/크기(hps)/색(ico 팔레트·cv RGB)/글꼴 (ftcAscii·ftcFE → SttbfFfn FFN 이름, eastAsia 슬롯 포함) - **표**: fInTable(0x16)/fTtp(0x17) 문단 속성 + 0x07 셀 마크로 행/셀 격자를 복원해 docx 표로 (기존: 탭으로 뭉갬) - 문단 정렬 sprmPJc(0x03/0x61), 필드 제거를 CP 매핑 보존 방식으로 전환 (서식 오프셋이 어긋나지 않게) xls (BIFF8) — FONT/XF/PALETTE 확장 (xlrd formatting.py 대조): - FONT: 색(icv)/밑줄(uls)/취소선(grbit 0x08) - XF: 수평·수직 정렬/줄바꿈(alc @6), solid 채우기(패턴 bits26-31 + 전경색 icv @18) - PALETTE(0x92) 재정의 + BIFF8 기본 팔레트(icv 0-7 EGA + 8-63) ppt (PPT97) — StyleTextPropAtom(4001) 해석 ([MS-PPT] 2.9.20/44): - 문단 런(정렬 0x800·들여쓰기 레벨)/문자 런(charFlags bold·italic· underline·strike, font.size, font.color — 스킴색 제외, 마스크 유효 비트 의미론) → pptx 런 스타일 (strike 는 rPr@strike 직접) - tabstops 가변 크기 등 props 순서/크기 표 전체 반영 테스트: 3종 심화 픽스처(FKP 페이지/BIFF 레코드/StyleTextPropAtom 을 스펙 그대로 손조립) 10건 추가 — 916 passed. PNG 래스터 스모크 3종 통과. --- pyproject.toml | 2 +- .../documents/legacy/doc_convert.py | 566 ++++++++++++++++-- .../documents/legacy/ppt_convert.py | 289 ++++++++- .../documents/legacy/xls_convert.py | 143 ++++- tests/unit/test_legacy_formats.py | 348 +++++++++++ 5 files changed, 1248 insertions(+), 100 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a4e25616..95eaca9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "xgen-edit2docs" -version = "0.20.0" +version = "0.21.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/legacy/doc_convert.py b/src/xgen_edit2docs/documents/legacy/doc_convert.py index ba5f5b38..887d746b 100644 --- a/src/xgen_edit2docs/documents/legacy/doc_convert.py +++ b/src/xgen_edit2docs/documents/legacy/doc_convert.py @@ -1,35 +1,115 @@ -"""Word 97 바이너리(.doc) → DOCX 변환 (MS-DOC 스펙의 piece table 복원). +"""Word 97 바이너리(.doc) → DOCX 변환 — piece table + CHPX/PAPX 서식. -.doc 의 본문은 연속 텍스트가 아니다 — FIB 가 가리키는 Clx(piece table)가 -"CP 구간 → 파일 오프셋(fc) + 압축 여부" 조각들을 나열하고, 유니코드(UTF-16LE) -조각과 8비트(레거시 코드페이지) 조각이 섞인다. 이 조각들을 CP 순서로 이어야 -정확한 본문이 된다 (fcMin..fcMac 만 읽는 옛 방식은 Word97+ 에서 어긋난다). +[MS-DOC] 스펙 (바이트 레이아웃은 reference_data/poi 의 HWPF 구현과 대조): WordDocument 스트림 FIB: wIdent(0xA5EC)@0, flags@0x0A(bit9 → 1Table), - fcClx@0x01A2, lcbClx@0x01A6 + fcPlcfBteChpx@0x00FA, fcPlcfBtePapx@0x0102, + fcSttbfFfn@0x0112, fcClx@0x01A2 (+lcb 각 4B 뒤) 0Table/1Table 스트림 Clx: (Prc)* 그리고 Pcdt(0x02 + lcb + PlcPcd) PlcPcd (n+1)×CP UINT32 + n×PCD(2B flags, 4B fc, 2B prm) fc bit30 = 8비트 압축 조각 (offset = fc/2) -특수 문자: 0x0D 문단 끝, 0x07 표 셀/행 마크(탭으로), 0x0B 강제 줄바꿈, -0x0C 페이지 나눔, 0x13/0x14/0x15 필드(명령부는 버리고 결과만), 0x01/0x08 -개체 앵커(버림). 충실도 범위: 문단 구조 + 본문 전체 — 글자 서식(CHPX FKP) -은 범위 밖(기본 서식으로 렌더, 내용 유실 없음). +서식은 **FC(파일 오프셋) 기준**으로 붙는다 — 텍스트는 CP 순서로 조각을 +이어 만들므로, 각 문자의 CP→FC 매핑을 유지한 채 bin table 을 해석한다: + + PlcfBteChpx/Papx (n+1)×FC + n×PN(4B) — PN×512 = FKP 페이지 오프셋 + CHP FKP [crun+1 FCs][crun×1B rgb]…[crun @511] + chpx = page[2*rgb]: cb(1B) + grpprl + PAP FKP rgb 대신 13B BX(1B offset + 12B PHE) + papx: cb(1B, 0 이면 다음 1B×2) + [istd 2B] + grpprl + +sprm: opcode 2B (ispmd 0-8, spra 13-15) + operand (spra: 0/1→1B, 2/4/5→2B, +3→4B, 7→3B, 6→가변). 적용 sprm: + 문자 0x35 bold / 0x36 italic / 0x37 strike (토글: 1·0x81 = on), + 0x3E kul(밑줄), 0x43 hps(pt×2), 0x42 ico(팔레트)/0x70 cv(RGB), + 0x4F ftcAscii/0x50 ftcFE (SttbfFfn 글꼴 표 참조) + 문단 0x03/0x61 jc(정렬), 0x16 fInTable, 0x17 fTtp(행 끝) + +표: 본문 텍스트에서 셀 끝 = 0x07 종결 문단, 행 끝 = fTtp 문단. +fInTable 연속 구간을 표로 묶어 docx 표(행/셀 격자)로 재조립한다. + +특수 문자: 0x0D 문단, 0x07 셀/행 마크, 0x0B 줄바꿈, 0x0C 페이지 나눔, +0x13/0x14/0x15 필드(명령부 제거·결과 유지 — CP 매핑 보존), 0x01/0x08 +개체 앵커(버림). 그림(Data 스트림 Escher)·각주는 범위 밖. """ from __future__ import annotations +import bisect import io import struct -from typing import List +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple from . import LegacyConvertError _FC_COMPRESSED = 0x40000000 _FC_MASK = 0x3FFFFFFF +#: Word ico 색 팔레트 (1-16; 0 = 자동) +_ICO_RGB = { + 1: "000000", 2: "0000FF", 3: "00FFFF", 4: "00FF00", 5: "FF00FF", + 6: "FF0000", 7: "FFFF00", 8: "FFFFFF", 9: "000080", 10: "008080", + 11: "008000", 12: "800080", 13: "800000", 14: "808000", 15: "808080", + 16: "C0C0C0", +} + + +@dataclass +class _Piece: + cp_start: int + cp_end: int + fc_raw: int + + @property + def compressed(self) -> bool: + return bool(self.fc_raw & _FC_COMPRESSED) + + @property + def unit(self) -> int: + return 1 if self.compressed else 2 + + def fc_at(self, cp: int) -> int: + base = self.fc_raw & _FC_MASK + if self.compressed: + return base // 2 + (cp - self.cp_start) + return base + 2 * (cp - self.cp_start) + -def _read_pieces(word_stream: bytes, table_stream: bytes) -> str: +@dataclass +class _Chp: + """해석된 문자 서식 (직접 서식만 — 스타일 시트 상속은 범위 밖).""" + bold: bool = False + italic: bool = False + strike: bool = False + underline: bool = False + size_pt: Optional[float] = None + color: Optional[str] = None + font: Optional[str] = None + font_fe: Optional[str] = None + + +@dataclass +class _Pap: + jc: int = 0 + in_table: bool = False + ttp: bool = False + + +@dataclass +class _DocPara: + """종결 문자까지 확정된 문단 — runs 는 [(텍스트, cp 시작)] 조각.""" + chars: List[Tuple[str, int]] = field(default_factory=list) # (ch, cp) + terminator: str = "\r" + end_cp: int = 0 + pap: _Pap = field(default_factory=_Pap) + + +# ── piece table ──────────────────────────────────────────────── + + +def _read_pieces(word_stream: bytes, table_stream: bytes + ) -> Tuple[str, List[_Piece]]: if len(word_stream) < 0x01AA: raise LegacyConvertError("doc FIB 가 너무 짧습니다") (w_ident,) = struct.unpack_from(" str: n = (len(plc) - 4) // 12 cps = struct.unpack_from(f"<{n + 1}I", plc, 0) out: List[str] = [] + pieces: List[_Piece] = [] for i in range(n): _flags, fc_raw, _prm = struct.unpack_from(" str: off = fc_raw & _FC_MASK raw = word_stream[off:off + count * 2] out.append(raw.decode("utf-16le", errors="replace")) - return "".join(out) + return "".join(out), pieces -def _strip_fields(text: str) -> str: - """필드 명령부(0x13..0x14)는 버리고 결과부(0x14..0x15)만 남긴다.""" - out: List[str] = [] +# ── FKP bin tables ───────────────────────────────────────────── + + +def _read_bin_table(word_stream: bytes, table_stream: bytes, + fc: int, lcb: int, kind: str + ) -> Tuple[List[int], List[bytes]]: + """PlcfBteChpx/Papx → 정렬된 (fc 경계 목록, 경계별 grpprl). + + 반환: (starts, grpprls) — starts[i] ≤ fc < starts[i+1] 구간의 서식이 + grpprls[i]. 깨진 페이지는 건너뛴다 (서식만 잃고 텍스트는 무사). + """ + starts: List[int] = [] + grpprls: List[bytes] = [] + if lcb < 8 or fc + lcb > len(table_stream): + return starts, grpprls + n = (lcb - 4) // 8 + try: + fcs = struct.unpack_from(f"<{n + 1}I", table_stream, fc) + pns = struct.unpack_from(f"<{n}I", table_stream, fc + 4 * (n + 1)) + except struct.error: + return starts, grpprls + del fcs # bin table 의 FC 경계는 FKP 안의 rgfc 가 더 정밀하다 + for pn in pns: + page_off = (pn & 0x3FFFFF) * 512 + page = word_stream[page_off:page_off + 512] + if len(page) < 512: + continue + crun = page[511] + if crun == 0 or (crun + 1) * 4 > 511: + continue + try: + rgfc = struct.unpack_from(f"<{crun + 1}I", page, 0) + except struct.error: + continue + for i in range(crun): + if kind == "chp": + rgb = page[(crun + 1) * 4 + i] + if rgb == 0: + grpprl = b"" + else: + off = 2 * rgb + if off >= 511: + continue + cb = page[off] + grpprl = page[off + 1:off + 1 + cb] + else: # pap — BX 13B, papx 는 istd 2B 로 시작 + bx_off = (crun + 1) * 4 + i * 13 + if bx_off >= 511: + continue + rgb = page[bx_off] + if rgb == 0: + grpprl = b"" + else: + off = 2 * rgb + if off >= 511: + continue + size = 2 * page[off] + if size == 0: + off += 1 + size = 2 * page[off] + else: + size -= 1 + grpprl = page[off + 1:off + 1 + size] + grpprl = grpprl[2:] # istd 건너뜀 + starts.append(rgfc[i]) + grpprls.append(grpprl) + # 구간 끝 경계도 넣어 두면 lookup 이 단순해진다 — 마지막에 정렬 + # fc 순 정렬 (FKP 페이지가 fc 순이 아닐 수 있다) + order = sorted(range(len(starts)), key=lambda k: starts[k]) + return [starts[k] for k in order], [grpprls[k] for k in order] + + +def _lookup(starts: List[int], grpprls: List[bytes], fc: int) -> bytes: + if not starts: + return b"" + idx = bisect.bisect_right(starts, fc) - 1 + return grpprls[idx] if idx >= 0 else b"" + + +# ── sprm 해석 ────────────────────────────────────────────────── + + +def _iter_sprms(grpprl: bytes): + """(opcode, operand bytes) — [MS-DOC] 2.6.1, POI SprmOperation 대조.""" + pos, n = 0, len(grpprl) + while pos + 2 <= n: + (opcode,) = struct.unpack_from("> 13 + if spra in (0, 1): + size = 1 + elif spra in (2, 4, 5): + size = 2 + elif spra == 3: + size = 4 + elif spra == 7: + size = 3 + else: # 6 — 가변: 길이 바이트 (표 sprm 0xD608/0xC615 는 2B 길이) + if opcode in (0xD608, 0xC615): + if pos + 2 > n: + return + (size,) = struct.unpack_from(" n: + return + size = grpprl[pos] + pos += 1 + if pos + size > n: + return + yield opcode, grpprl[pos:pos + size] + pos += size + + +def _toggle_on(operand: bytes) -> Optional[bool]: + """토글 sprm: 0 off / 1 on / 0x80 스타일따름 / 0x81 스타일반전. + + 직접 서식만 보므로 0x80 은 무시(None), 0x81 은 on 취급 (기본 스타일 + off 가정 — POI getCHPFlag 와 동일한 근사).""" + if not operand: + return None + v = operand[0] + if v == 0: + return False + if v == 1 or v == 0x81: + return True + return None + + +def _chp_of(grpprl: bytes, fonts: List[str]) -> _Chp: + chp = _Chp() + for opcode, operand in _iter_sprms(grpprl): + op = opcode & 0x1FF + if op == 0x35: + v = _toggle_on(operand) + if v is not None: + chp.bold = v + elif op == 0x36: + v = _toggle_on(operand) + if v is not None: + chp.italic = v + elif op == 0x37: + v = _toggle_on(operand) + if v is not None: + chp.strike = v + elif op == 0x3E and operand: + chp.underline = operand[0] != 0 + elif op == 0x43 and len(operand) >= 2: + (hps,) = struct.unpack_from("= 4: + r, g, b, auto = operand[0], operand[1], operand[2], operand[3] + if auto != 0xFF: + chp.color = f"{r:02X}{g:02X}{b:02X}" + elif op == 0x4F and len(operand) >= 2: + (ftc,) = struct.unpack_from("= 2: + (ftc,) = struct.unpack_from(" _Pap: + pap = _Pap() + for opcode, operand in _iter_sprms(grpprl): + op = opcode & 0x1FF + if op in (0x03, 0x61) and operand: # sprmPJc (97) / sprmPJc80 (2000+) + pap.jc = operand[0] + elif op == 0x16 and operand: + pap.in_table = operand[0] != 0 + elif op == 0x17 and operand: + pap.ttp = operand[0] != 0 + return pap + + +# ── 글꼴 표 (SttbfFfn) ──────────────────────────────────────── + + +def _read_font_names(table_stream: bytes, fc: int, lcb: int) -> List[str]: + """FFN 목록 — 이름은 엔트리 시작 +40 의 UTF-16 널종결 ([MS-DOC] 2.9.63).""" + names: List[str] = [] + if lcb < 6 or fc + lcb > len(table_stream): + return names + try: + marker, count = struct.unpack_from("= fc + lcb: + break + cb_m1 = table_stream[pos] + entry = table_stream[pos + 1:pos + 1 + cb_m1] + name = "" + if len(entry) > 39: + raw = entry[39:] + name = raw.decode("utf-16le", errors="replace").split("\x00")[0] + names.append(name) + pos += 1 + cb_m1 + except (struct.error, IndexError): + pass + return names + + +# ── 필드 제거 (CP 매핑 보존) ────────────────────────────────── + + +def _visible_chars(text: str) -> List[Tuple[str, int]]: + """필드 명령부(0x13..0x14)를 지운 (문자, 원본 CP) 목록.""" + out: List[Tuple[str, int]] = [] depth_cmd = 0 - for ch in text: + for cp, ch in enumerate(text): code = ord(ch) if code == 0x13: depth_cmd += 1 @@ -88,13 +385,36 @@ def _strip_fields(text: str) -> str: continue if depth_cmd > 0: continue - out.append(ch) - return "".join(out) + out.append((ch, cp)) + return out + + +# ── 본문 → 문단/표 ──────────────────────────────────────────── + + +def _split_paragraphs(chars: List[Tuple[str, int]]) -> List[_DocPara]: + paras: List[_DocPara] = [] + cur = _DocPara() + for ch, cp in chars: + if ch in ("\r", "\x07"): + cur.terminator = ch + cur.end_cp = cp + paras.append(cur) + cur = _DocPara() + else: + cur.chars.append((ch, cp)) + if cur.chars: + cur.end_cp = cur.chars[-1][1] + paras.append(cur) + return paras def doc_to_docx(content: bytes) -> bytes: import olefile from docx import Document + from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK + from docx.oxml.ns import qn + from docx.shared import Pt, RGBColor if not olefile.isOleFile(io.BytesIO(content)): raise LegacyConvertError("doc 가 아닙니다 (OLE 복합문서 아님)") @@ -111,36 +431,180 @@ def doc_to_docx(content: bytes) -> bytes: if not ole.exists(table_name): raise LegacyConvertError("doc Table 스트림이 없습니다") table_stream = ole.openstream(table_name).read() - - text = _strip_fields(_read_pieces(word_stream, table_stream)) - - doc = Document() - # 0x0D = 문단, 0x0C = 페이지 나눔(문단 경계 + 하드 브레이크), 0x07 = 셀 마크. - for page_part_idx, page_part in enumerate(text.split("\x0c")): - if page_part_idx > 0: - from docx.enum.text import WD_BREAK - - doc.add_paragraph().add_run().add_break(WD_BREAK.PAGE) - for raw_para in page_part.split("\r"): - cleaned = ( - raw_para - .replace("\x07", "\t") # 표 셀/행 마크 — 구조 대신 탭 구분 - .replace("\x0b", "\n") # 강제 줄바꿈 - .replace("\x01", "") # 그림 앵커 - .replace("\x08", "") # 그리기 개체 앵커 - .replace("\x1e", "-") # 줄바꿈 없는 하이픈 - .replace("\x1f", "") # 소프트 하이픈 - .replace("\x00", "") - ) - para = doc.add_paragraph() - for j, line in enumerate(cleaned.split("\n")): - if j > 0: - para.add_run().add_break() - if line: - para.add_run(line) - - buf = io.BytesIO() - doc.save(buf) - return buf.getvalue() finally: ole.close() + + text, pieces = _read_pieces(word_stream, table_stream) + + def fib_fclcb(off: int) -> Tuple[int, int]: + if off + 8 <= len(word_stream): + return struct.unpack_from(" Optional[_Piece]: + idx = bisect.bisect_right(piece_starts, cp) - 1 + if idx < 0: + return None + p = pieces[idx] + return p if p.cp_start <= cp < p.cp_end else None + + chp_cache: Dict[bytes, _Chp] = {} + + def chp_at(cp: int) -> _Chp: + p = piece_of(cp) + if p is None: + return _Chp() + grpprl = _lookup(chp_starts, chp_grpprls, p.fc_at(cp)) + chp = chp_cache.get(grpprl) + if chp is None: + chp = chp_cache[grpprl] = _chp_of(grpprl, fonts) + return chp + + def pap_at(cp: int) -> _Pap: + p = piece_of(cp) + if p is None: + return _Pap() + return _pap_of(_lookup(pap_starts, pap_grpprls, p.fc_at(cp))) + + paras = _split_paragraphs(_visible_chars(text)) + for para in paras: + para.pap = pap_at(para.end_cp) + + # ── DOCX 조립 ── + doc = Document() + _WD_JC = {0: WD_ALIGN_PARAGRAPH.LEFT, 1: WD_ALIGN_PARAGRAPH.CENTER, + 2: WD_ALIGN_PARAGRAPH.RIGHT, 3: WD_ALIGN_PARAGRAPH.JUSTIFY} + + def emit_para(target, para: _DocPara) -> None: + if para.pap.jc in _WD_JC and para.pap.jc != 0: + target.alignment = _WD_JC[para.pap.jc] + # 문자 서식 경계로 런을 쪼갠다 — 같은 _Chp 가 이어지면 한 런. + run_chars: List[str] = [] + run_chp: Optional[_Chp] = None + run_obj = None + + def flush() -> None: + nonlocal run_chars + run_chars = [] + + def current_run(): + nonlocal run_obj + if run_obj is None: + run_obj = target.add_run() + st = run_chp or _Chp() + if st.size_pt: + run_obj.font.size = Pt(st.size_pt) + if st.bold: + run_obj.bold = True + if st.italic: + run_obj.italic = True + if st.underline: + run_obj.underline = True + if st.strike: + run_obj.font.strike = True + if st.color: + run_obj.font.color.rgb = RGBColor.from_string(st.color) + name = st.font or st.font_fe + if name: + run_obj.font.name = name + rPr = run_obj._element.get_or_add_rPr() + rFonts = rPr.get_or_add_rFonts() + rFonts.set(qn("w:eastAsia"), st.font_fe or name) + return run_obj + + def emit_text() -> None: + nonlocal run_obj + if run_chars: + current_run().add_text("".join(run_chars)) + flush() + + for ch, cp in para.chars: + code = ord(ch) + chp = chp_at(cp) + if run_chp is not None and chp is not run_chp and run_chars: + emit_text() + run_obj = None + run_chp = chp + if code == 0x0B: + emit_text() + current_run().add_break() + elif code == 0x0C: + emit_text() + current_run().add_break(WD_BREAK.PAGE) + elif code in (0x01, 0x08, 0x00, 0x1F): + continue # 개체 앵커/소프트하이픈 — 버림 + elif code == 0x1E: + run_chars.append("-") + else: + run_chars.append(ch) + emit_text() + + i = 0 + n_paras = len(paras) + while i < n_paras: + para = paras[i] + if not para.pap.in_table: + p = doc.add_paragraph() + emit_para(p, para) + i += 1 + continue + # ── 표 구간: fInTable 연속 문단 → 행(fTtp 경계)/셀(0x07 종결) ── + rows: List[List[List[_DocPara]]] = [] + cur_row: List[List[_DocPara]] = [] + cur_cell: List[_DocPara] = [] + while i < n_paras and paras[i].pap.in_table: + tp = paras[i] + if tp.pap.ttp: + if cur_cell: + cur_row.append(cur_cell) + cur_cell = [] + if cur_row: + rows.append(cur_row) + cur_row = [] + elif tp.terminator == "\x07": + cur_cell.append(tp) + cur_row.append(cur_cell) + cur_cell = [] + else: + cur_cell.append(tp) + i += 1 + if cur_cell: + cur_row.append(cur_cell) + if cur_row: + rows.append(cur_row) + if not rows: + continue + n_cols = max(len(r) for r in rows) + tbl = doc.add_table(rows=len(rows), cols=n_cols) + try: + tbl.style = "Table Grid" + except Exception: # noqa: BLE001 + pass + for r_i, row in enumerate(rows): + for c_i, cell_paras in enumerate(row[:n_cols]): + cell = tbl.cell(r_i, c_i) + first = True + for cp_para in cell_paras: + if first: + cell.paragraphs[0].text = "" + target = cell.paragraphs[0] + first = False + else: + target = cell.add_paragraph() + emit_para(target, cp_para) + + buf = io.BytesIO() + doc.save(buf) + return buf.getvalue() diff --git a/src/xgen_edit2docs/documents/legacy/ppt_convert.py b/src/xgen_edit2docs/documents/legacy/ppt_convert.py index a201cc8b..2597da31 100644 --- a/src/xgen_edit2docs/documents/legacy/ppt_convert.py +++ b/src/xgen_edit2docs/documents/legacy/ppt_convert.py @@ -4,11 +4,32 @@ recLen 4B) 레코드 트리다. 슬라이드 텍스트의 정본은 DocumentContainer(1000) 안의 SlideListWithText(4080) — SlidePersistAtom(1011)이 슬라이드 경계를, TextHeaderAtom(3999)이 자리 종류(0/5=제목, 1/6=본문 …)를, TextCharsAtom -(4000, UTF-16LE)/TextBytesAtom(4008, 8비트=UTF-16 하위바이트)이 내용을 준다. +(4000, UTF-16LE)/TextBytesAtom(4008, 8비트=UTF-16 하위바이트)이 내용을, +**StyleTextPropAtom(4001)** 이 직전 텍스트 아톰의 문단/문자 서식 런을 준다. + +StyleTextPropAtom 레이아웃 ([MS-PPT] 2.9.20/2.9.44, Apache POI HSLF +TextPropCollection 대조 — reference_data/poi): + + 문단 런* { textLen(4) indentLevel(2) mask(4) props(mask 순서별) } + 문자 런* { textLen(4) mask(4) props } + 문자 props 순서: charFlags(mask&0xFFFF, 2B — bit0 bold/bit1 italic/ + bit2 underline/bit8 strike) → font.index(0x10000,2) → asian(0x200000,2) + → ansi(0x400000,2) → symbol(0x800000,2) → font.size(0x20000,2) + → font.color(0x40000,4 — RGB, 상위 바이트 0xFE/0xFF 는 스킴색) + → superscript(0x80000,2) + 문단 props 순서: paraFlags(0xF,2) → bullet.char(0x80,2) → + bullet.font(0x10,2) → bullet.size(0x40,2) → bullet.color(0x20,4) → + alignment(0x800,2 — 0 좌/1 중앙/2 우/3 양쪽) → linespacing(0x1000,2) + → spacebefore(0x2000,2) → spaceafter(0x4000,2) → text.offset(0x100,2) + → bullet.offset(0x400,2) → defaultTabSize(0x8000,2) → + tabstops(0x100000, 2+n×4 가변) → fontAlign(0x10000,2) → + wrapFlags(0xE0000,2) → textDirection(0x200000,2) + 슬라이드 크기는 DocumentAtom(1001)의 master unit(1/576 inch) 값. -충실도 범위: 슬라이드 수·순서, 자리별 텍스트(제목 큰 글씨/본문 문단), -슬라이드 크기. 도형 좌표(Escher)·이미지·표는 범위 밖 — 텍스트 유실은 없다. +충실도 범위: 슬라이드 수·순서, 자리별 텍스트(제목/본문), 문단 정렬· +들여쓰기 수준, 문자 런 스타일(굵게/기울임/밑줄/취소선/크기/색). +도형 좌표(Escher)·이미지·표는 범위 밖 — 텍스트 유실은 없다. """ from __future__ import annotations @@ -16,7 +37,7 @@ import io import struct from dataclasses import dataclass, field -from typing import List +from typing import List, Optional, Tuple from . import LegacyConvertError @@ -26,6 +47,7 @@ _RT_SLIDE_LIST_WITH_TEXT = 4080 _RT_TEXT_HEADER_ATOM = 3999 _RT_TEXT_CHARS_ATOM = 4000 +_RT_STYLE_TEXT_PROP_ATOM = 4001 _RT_TEXT_BYTES_ATOM = 4008 _TITLE_TYPES = {0, 5} # title / center title @@ -47,10 +69,149 @@ def _iter_records(data: bytes, start: int = 0, end: int | None = None): pos = payload_start + rec_len +# ── StyleTextPropAtom 해석 ───────────────────────────────────── + + +@dataclass +class _CharStyle: + bold: Optional[bool] = None + italic: Optional[bool] = None + underline: Optional[bool] = None + strike: Optional[bool] = None + size_pt: Optional[float] = None + color: Optional[str] = None # RRGGBB + + +@dataclass +class _ParaStyle: + indent: int = 0 + align: Optional[int] = None # 0 left / 1 center / 2 right / 3 justify + + +#: (mask, size) — 문자 props, POI characterTextPropTypes 순서. +_CHAR_PROPS: List[Tuple[int, int]] = [ + (0xFFFF, 2), # charFlags + (0x10000, 2), # font.index + (0x200000, 2), # asian.font.index + (0x400000, 2), # ansi.font.index + (0x800000, 2), # symbol.font.index + (0x20000, 2), # font.size + (0x40000, 4), # font.color + (0x80000, 2), # superscript +] + +#: (mask, size) — 문단 props (tabstops 는 가변이라 특별 취급). +_PARA_PROPS: List[Tuple[int, int]] = [ + (0xF, 2), # paraFlags + (0x80, 2), # bullet.char + (0x10, 2), # bullet.font + (0x40, 2), # bullet.size + (0x20, 4), # bullet.color + (0x800, 2), # alignment + (0x1000, 2), # linespacing + (0x2000, 2), # spacebefore + (0x4000, 2), # spaceafter + (0x100, 2), # text.offset + (0x400, 2), # bullet.offset + (0x8000, 2), # defaultTabSize + (0x100000, -1), # tabstops — 2 + n×4 + (0x10000, 2), # fontAlign + (0xE0000, 2), # wrapFlags + (0x200000, 2), # textDirection +] + + +def _parse_style_atom(payload: bytes, text_len: int + ) -> Tuple[List[Tuple[int, _ParaStyle]], + List[Tuple[int, _CharStyle]]]: + """→ ([(문단 런 길이, 스타일)], [(문자 런 길이, 스타일)]). + + 깨진/모르는 마스크를 만나면 그 지점까지 해석한 결과만 돌려준다 — + 스타일은 최선 노력, 텍스트는 이미 안전하다. + """ + pos, n = 0, len(payload) + para_runs: List[Tuple[int, _ParaStyle]] = [] + char_runs: List[Tuple[int, _CharStyle]] = [] + try: + handled = 0 + while pos + 10 <= n and handled <= text_len: + (run_len,) = struct.unpack_from(" n: + raise ValueError + (cnt,) = struct.unpack_from(" n: + raise ValueError + if m == 0x800: + (st.align,) = struct.unpack_from("= text_len + 1: + break + + handled = 0 + while pos + 8 <= n and handled <= text_len: + (run_len,) = struct.unpack_from(" n: + raise ValueError + if m == 0xFFFF: + (flags,) = struct.unpack_from("= text_len + 1: + break + except (ValueError, struct.error): + pass + return para_runs, char_runs + + +# ── 슬라이드 수집 ────────────────────────────────────────────── + + +@dataclass +class _TextBlock: + is_title: bool + text: str + para_runs: List[Tuple[int, _ParaStyle]] = field(default_factory=list) + char_runs: List[Tuple[int, _CharStyle]] = field(default_factory=list) + + @dataclass class _SlideText: - titles: List[str] = field(default_factory=list) - bodies: List[str] = field(default_factory=list) + blocks: List[_TextBlock] = field(default_factory=list) def _decode_bytes_atom(payload: bytes) -> str: @@ -88,17 +249,53 @@ def walk(start: int, end: int, in_sltwt: bool) -> None: text = (raw.decode("utf-16le", errors="replace") if rec_type == _RT_TEXT_CHARS_ATOM else _decode_bytes_atom(raw)) - target = (slides[-1].titles if cur_type in _TITLE_TYPES - else slides[-1].bodies) - target.append(text) + slides[-1].blocks.append( + _TextBlock(is_title=cur_type in _TITLE_TYPES, text=text)) + elif rec_type == _RT_STYLE_TEXT_PROP_ATOM: + # 직전 텍스트 아톰의 서식 런 + if slides and slides[-1].blocks: + block = slides[-1].blocks[-1] + block.para_runs, block.char_runs = _parse_style_atom( + data[p_start:p_start + p_len], len(block.text)) walk(0, len(data), False) return slides, slide_size_mu +# ── PPTX 조립 ────────────────────────────────────────────────── + + +def _char_style_at(block: _TextBlock, pos: int) -> _CharStyle: + acc = 0 + for run_len, st in block.char_runs: + if pos < acc + run_len: + return st + acc += run_len + return _CharStyle() + + +def _char_boundaries(block: _TextBlock) -> List[int]: + out, acc = [], 0 + for run_len, _st in block.char_runs: + acc += run_len + out.append(acc) + return out + + +def _para_style_at(block: _TextBlock, pos: int) -> _ParaStyle: + acc = 0 + for run_len, st in block.para_runs: + if pos < acc + run_len: + return st + acc += run_len + return _ParaStyle() + + def ppt_to_pptx(content: bytes) -> bytes: import olefile from pptx import Presentation + from pptx.dml.color import RGBColor + from pptx.enum.text import PP_ALIGN from pptx.util import Emu, Pt if not olefile.isOleFile(io.BytesIO(content)): @@ -124,34 +321,72 @@ def ppt_to_pptx(content: bytes) -> bytes: title_h = Emu(int(1.1 * _EMU_PER_INCH)) content_w = prs.slide_width - margin * 2 - for st in slides: + _ALIGN = {0: PP_ALIGN.LEFT, 1: PP_ALIGN.CENTER, + 2: PP_ALIGN.RIGHT, 3: PP_ALIGN.JUSTIFY} + + def emit_block(tf, block: _TextBlock, default_pt: float, + default_bold: bool, first_para_used: bool) -> bool: + """텍스트 블록 → 문단들(\\r 경계), 런 스타일/정렬/레벨 반영.""" + boundaries = _char_boundaries(block) + pos = 0 + for para_text in block.text.split("\r"): + para = tf.paragraphs[0] if not first_para_used else tf.add_paragraph() + first_para_used = True + pst = _para_style_at(block, pos) + if pst.align in _ALIGN: + para.alignment = _ALIGN[pst.align] + if pst.indent: + para.level = min(pst.indent, 4) + # 문자 런 경계 + 줄바꿈(0x0B) 지점으로 조각 낸다 + cuts = sorted({pos, pos + len(para_text)} | { + b for b in boundaries if pos < b < pos + len(para_text)} | { + pos + i for i, ch in enumerate(para_text) if ch == "\x0b"} | { + pos + i + 1 for i, ch in enumerate(para_text) if ch == "\x0b"}) + for a, b in zip(cuts, cuts[1:]): + piece = block.text[a:b] + if piece == "\x0b": + para.add_line_break() + continue + if not piece: + continue + st = _char_style_at(block, a) + run = para.add_run() + run.text = piece.replace("\x0b", "\n") + run.font.size = Pt(st.size_pt if st.size_pt else default_pt) + run.font.bold = st.bold if st.bold is not None else default_bold + if st.italic is not None: + run.font.italic = st.italic + if st.underline is not None: + run.font.underline = st.underline + if st.strike: + # python-pptx 에 strike API 가 없다 — rPr 속성 직접 + run.font._rPr.set("strike", "sngStrike") + if st.color: + run.font.color.rgb = RGBColor.from_string(st.color) + pos += len(para_text) + 1 # + \r + return first_para_used + + for st_slide in slides: slide = prs.slides.add_slide(blank) y = margin - if st.titles: + titles = [b for b in st_slide.blocks if b.is_title] + bodies = [b for b in st_slide.blocks if not b.is_title] + if titles: box = slide.shapes.add_textbox(margin, y, content_w, title_h) tf = box.text_frame tf.word_wrap = True - for i, t in enumerate(st.titles): - para = tf.paragraphs[0] if i == 0 else tf.add_paragraph() - run = para.add_run() - run.text = t.replace("\r", "\n").strip("\n") - run.font.size = Pt(28) - run.font.bold = True + used = False + for block in titles: + used = emit_block(tf, block, 28.0, True, used) y = y + title_h - if st.bodies: + if bodies: body_h = prs.slide_height - y - margin box = slide.shapes.add_textbox(margin, y, content_w, body_h) tf = box.text_frame tf.word_wrap = True - first = True - for chunk in st.bodies: - # 0x0D = 문단 경계, 0x0B = 문단 내 줄바꿈. - for line in chunk.replace("\x0b", "\n").split("\r"): - para = tf.paragraphs[0] if first else tf.add_paragraph() - first = False - run = para.add_run() - run.text = line - run.font.size = Pt(16) + used = False + for block in bodies: + used = emit_block(tf, block, 16.0, False, used) buf = io.BytesIO() prs.save(buf) diff --git a/src/xgen_edit2docs/documents/legacy/xls_convert.py b/src/xgen_edit2docs/documents/legacy/xls_convert.py index 766ea941..52164e5b 100644 --- a/src/xgen_edit2docs/documents/legacy/xls_convert.py +++ b/src/xgen_edit2docs/documents/legacy/xls_convert.py @@ -7,8 +7,11 @@ (MERGEDCELLS)을 읽어 openpyxl 워크북으로 재조립한다. 충실도 범위: 값·수식 캐시값·문자열(서식 run 은 평문화)·열너비/행높이· -병합·숫자서식·굵게/기울임/크기. 차트·이미지·조건부서식은 범위 밖. -BIFF5(.xls 구버전)는 SST 가 없어 지원하지 않는다 — 정직하게 거절한다. +병합·숫자서식·글꼴(굵게/기울임/밑줄/취소선/크기/색 — FONT 표준+PALETTE +재정의)·셀 정렬(XF alc/alcV/fWrap)·단색 채우기(XF 패턴 solid). +차트·이미지·조건부서식은 범위 밖. BIFF5(.xls 구버전)는 SST 가 없어 +지원하지 않는다 — 정직하게 거절한다. (XF/FONT/PALETTE 레이아웃은 +reference_data/xlrd formatting.py 와 대조.) """ from __future__ import annotations @@ -40,6 +43,7 @@ _R_FONT = 0x0031 _R_XF = 0x00E0 _R_FORMAT = 0x041E +_R_PALETTE = 0x0092 def _iter_biff(data: bytes, start: int = 0): @@ -149,17 +153,59 @@ def read_string(self) -> str: return s +#: BIFF8 기본 팔레트 표 (xlrd excel_default_palette_b8) — icv 0-7 은 앞 8개 +#: 고정 EGA, icv 8-63 은 표 전체(56개) 순서 (xlrd initialise_colour_map: +#: colour_map[i+8] = dpal[i]). PALETTE 레코드가 icv 8+ 를 덮어쓴다. +_DEFAULT_PALETTE = ( + (0, 0, 0), (255, 255, 255), (255, 0, 0), (0, 255, 0), + (0, 0, 255), (255, 255, 0), (255, 0, 255), (0, 255, 255), + (128, 0, 0), (0, 128, 0), (0, 0, 128), (128, 128, 0), + (128, 0, 128), (0, 128, 128), (192, 192, 192), (128, 128, 128), + (153, 153, 255), (153, 51, 102), (255, 255, 204), (204, 255, 255), + (102, 0, 102), (255, 128, 128), (0, 102, 204), (204, 204, 255), + (0, 0, 128), (255, 0, 255), (255, 255, 0), (0, 255, 255), + (128, 0, 128), (128, 0, 0), (0, 128, 128), (0, 0, 255), + (0, 204, 255), (204, 255, 255), (204, 255, 204), (255, 255, 153), + (153, 204, 255), (255, 153, 204), (204, 153, 255), (255, 204, 153), + (51, 102, 255), (51, 204, 204), (153, 204, 0), (255, 204, 0), + (255, 153, 0), (255, 102, 0), (102, 102, 153), (150, 150, 150), + (0, 51, 102), (51, 153, 102), (0, 51, 0), (51, 51, 0), + (153, 51, 0), (153, 51, 102), (51, 51, 153), (51, 51, 51), +) + + @dataclass class _Font: size_pt: float = 10.0 bold: bool = False italic: bool = False + underline: bool = False + strike: bool = False + color_icv: Optional[int] = None + + +#: XF alc(수평 정렬) → openpyxl horizontal +_HALIGN = {1: "left", 2: "center", 3: "right", 5: "justify", 6: "centerContinuous"} +#: XF alcV(수직 정렬) → openpyxl vertical +_VALIGN = {0: "top", 1: "center", 2: "bottom", 3: "justify"} + + +@dataclass +class _XfStyle: + ifnt: int = 0 + ifmt: int = 0 + halign: Optional[str] = None + valign: Optional[str] = None + wrap: bool = False + fill_icv: Optional[int] = None # solid 패턴의 전경색 icv def xls_to_xlsx(content: bytes) -> bytes: import olefile from openpyxl import Workbook + from openpyxl.styles import Alignment as XlAlignment from openpyxl.styles import Font as XlFont + from openpyxl.styles import PatternFill as XlPatternFill from openpyxl.styles.numbers import BUILTIN_FORMATS from openpyxl.utils import get_column_letter @@ -177,10 +223,12 @@ def xls_to_xlsx(content: bytes) -> bytes: # ── 워크북 전역부 ─────────────────────────────────────────── sst: List[str] = [] fonts: List[_Font] = [] - xf_font: List[int] = [] - xf_fmt: List[int] = [] + xfs: List[_XfStyle] = [] fmt_codes: Dict[int, str] = {} sheets: List[Tuple[str, int]] = [] # (이름, BOF 절대 오프셋) + # icv 주소 공간 0..63: [0..7]=EGA, [8..63]=기본 표 56개 + palette: List[Tuple[int, int, int]] = ( + list(_DEFAULT_PALETTE[:8]) + list(_DEFAULT_PALETTE)) records = list(_iter_biff(data)) if not records or records[0][0] != _R_BOF: @@ -214,16 +262,37 @@ def xls_to_xlsx(content: bytes) -> bytes: elif rtype == _R_FONT: f = _Font() if rlen >= 8: - height, grbit, _icv, weight = struct.unpack_from( + height, grbit, icv, weight = struct.unpack_from( "= 600 + if icv not in (0x7FFF,): # 자동색 제외 + f.color_icv = icv + if rlen >= 11: + f.underline = data[bstart + 10] != 0 fonts.append(f) elif rtype == _R_XF and rlen >= 4: - ifnt, ifmt = struct.unpack_from("= 20: + alc = data[bstart + 6] + xf.halign = _HALIGN.get(alc & 0x07) + xf.wrap = bool(alc & 0x08) + valign = (alc >> 4) & 0x07 + if valign != 2: # bottom 이 기본값 — 소음 줄이기 + xf.valign = _VALIGN.get(valign) + (brdbkg2,) = struct.unpack_from("> 26) & 0x3F + if pattern == 1: # solid + (bkg3,) = struct.unpack_from("= 5: (ifmt,) = struct.unpack_from(" bytes: code = (data[p:p + cch * 2].decode("utf-16le", errors="replace") if flags & 0x01 else data[p:p + cch].decode("latin-1")) fmt_codes[ifmt] = code + elif rtype == _R_PALETTE and rlen >= 2: + (cnt,) = struct.unpack_from(" bstart + rlen: + break + r_, g_, b_ = data[off], data[off + 1], data[off + 2] + palette[8 + k] = (r_, g_, b_) elif rtype == _R_BOUNDSHEET and rlen >= 8: (bof_pos,) = struct.unpack_from(" bytes: if not sheets: raise LegacyConvertError("xls 에 시트가 없습니다") - def font_of_xf(ixfe: int) -> Optional[_Font]: - if not (0 <= ixfe < len(xf_font)): + def xf_of(ixfe: int) -> Optional[_XfStyle]: + return xfs[ixfe] if 0 <= ixfe < len(xfs) else None + + def font_of_xf(xf: Optional[_XfStyle]) -> Optional[_Font]: + if xf is None: return None - ifnt = xf_font[ixfe] + ifnt = xf.ifnt # BIFF 규약: 폰트 인덱스 4는 존재하지 않는다 — 5 이상은 1 당긴다. if ifnt >= 4: ifnt -= 1 return fonts[ifnt] if 0 <= ifnt < len(fonts) else None - def fmt_of_xf(ixfe: int) -> Optional[str]: - if not (0 <= ixfe < len(xf_fmt)): + def fmt_of_xf(xf: Optional[_XfStyle]) -> Optional[str]: + if xf is None: + return None + if xf.ifmt in fmt_codes: + return fmt_codes[xf.ifmt] + return BUILTIN_FORMATS.get(xf.ifmt) + + def hex_of_icv(icv: Optional[int]) -> Optional[str]: + if icv is None or not (0 <= icv < len(palette)): return None - ifmt = xf_fmt[ixfe] - if ifmt in fmt_codes: - return fmt_codes[ifmt] - return BUILTIN_FORMATS.get(ifmt) + r_, g_, b_ = palette[icv] + return f"{r_:02X}{g_:02X}{b_:02X}" # ── 시트 서브스트림 ───────────────────────────────────────── wb = Workbook() @@ -267,12 +352,28 @@ def fmt_of_xf(ixfe: int) -> Optional[str]: def put(r: int, c: int, ixfe: int, value) -> None: cell = ws.cell(row=r + 1, column=c + 1, value=value) - f = font_of_xf(ixfe) - if f and (f.bold or f.italic or abs(f.size_pt - 10.0) > 0.01): - cell.font = XlFont(bold=f.bold, italic=f.italic, size=f.size_pt) - code = fmt_of_xf(ixfe) + xf = xf_of(ixfe) + f = font_of_xf(xf) + if f and (f.bold or f.italic or f.underline or f.strike + or f.color_icv is not None or abs(f.size_pt - 10.0) > 0.01): + cell.font = XlFont( + bold=f.bold, italic=f.italic, size=f.size_pt, + underline="single" if f.underline else None, + strike=f.strike or None, + color=hex_of_icv(f.color_icv), + ) + code = fmt_of_xf(xf) if code and code.lower() != "general": cell.number_format = code + if xf is not None: + if xf.halign or xf.valign or xf.wrap: + cell.alignment = XlAlignment( + horizontal=xf.halign, vertical=xf.valign, + wrap_text=xf.wrap or None) + bg = hex_of_icv(xf.fill_icv) + if bg and bg != "FFFFFF": + cell.fill = XlPatternFill( + fill_type="solid", fgColor=bg) for rtype, bstart, rlen, _rpos in _iter_biff(data, bof_pos): if rtype == _R_EOF: diff --git a/tests/unit/test_legacy_formats.py b/tests/unit/test_legacy_formats.py index f9c2a45f..9edcdc75 100644 --- a/tests/unit/test_legacy_formats.py +++ b/tests/unit/test_legacy_formats.py @@ -501,6 +501,229 @@ def rec(rtype: int, payload: bytes, ver: int = 0) -> bytes: return build_cfb({"PowerPoint Document": document}) +# ── 심화 픽스처: doc (CHPX/PAPX FKP + 표) ───────────────────── + + +def _chp_fkp(runs: list[tuple[int, bytes]], fc_end: int) -> bytes: + """CHP FKP 페이지 — [(fc_start, grpprl)] + 마지막 경계.""" + crun = len(runs) + page = bytearray(512) + rgfc = [fc for fc, _ in runs] + [fc_end] + struct.pack_into(f"<{crun + 1}I", page, 0, *rgfc) + cursor = 510 + for i, (_fc, grpprl) in enumerate(runs): + if not grpprl: + continue + size = 1 + len(grpprl) + cursor -= size + if cursor % 2: + cursor -= 1 + page[cursor] = len(grpprl) + page[cursor + 1:cursor + 1 + len(grpprl)] = grpprl + page[(crun + 1) * 4 + i] = cursor // 2 + page[511] = crun + return bytes(page) + + +def _pap_fkp(runs: list[tuple[int, bytes]], fc_end: int) -> bytes: + """PAP FKP 페이지 — BX 13B, papx = cb(1B[+1B]) + istd(2B) + sprms.""" + crun = len(runs) + page = bytearray(512) + rgfc = [fc for fc, _ in runs] + [fc_end] + struct.pack_into(f"<{crun + 1}I", page, 0, *rgfc) + cursor = 510 + for i, (_fc, sprms) in enumerate(runs): + if not sprms: + continue + body = b"\x00\x00" + sprms # istd 0 + sprms + t = len(body) + if t % 2: # size = 2*cb - 1 + blob = bytes([(t + 1) // 2]) + body + else: # size = 2*cb2 (첫 바이트 0) + blob = bytes([0, t // 2]) + body + cursor -= len(blob) + if cursor % 2: + cursor -= 1 + page[cursor:cursor + len(blob)] = blob + page[(crun + 1) * 4 + i * 13] = cursor // 2 + page[511] = crun + return bytes(page) + + +def make_doc_rich() -> bytes: + """정렬/런 스타일/글꼴/표(fInTable·fTtp)를 전부 싣는 Word97 픽스처.""" + body = ( + "제목 가운데\r" # cp 0-5, mark @6 — 가운데 정렬 + "굵은빨강취소\r" # cp 7-12, mark @13 — 앞 3자 bold+red, 뒤 3자 strike + "A1\x07B1\x07\x07" # 표 1행: 셀 2 + 행마크 + "A2\x07B2\x07\x07" # 표 2행 + "끝문단\r" + ) + text_bytes = _utf16(body) + word = bytearray(0x1000) + struct.pack_into(" int: + return text_fc + 2 * cp + + # CHPX: [0,7) 기본 / [7,10) bold+red / [10,13) strike+underline+14pt+글꼴0 + sprm_bold = struct.pack(" bytes: + def rec(rtype: int, payload: bytes) -> bytes: + return struct.pack(" bytes: + return (struct.pack(" bytes: + p = bytearray(20) + struct.pack_into(" bytes: + return rec(0x0085, struct.pack(" bytes: + def rec(rtype: int, payload: bytes, ver: int = 0) -> bytes: + return struct.pack("