diff --git a/pyproject.toml b/pyproject.toml index fc15204..b63decc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "xgen-edit2docs" -version = "0.22.0" +version = "0.23.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 887d746..acb866e 100644 --- a/src/xgen_edit2docs/documents/legacy/doc_convert.py +++ b/src/xgen_edit2docs/documents/legacy/doc_convert.py @@ -89,11 +89,36 @@ class _Chp: font_fe: Optional[str] = None +@dataclass +class _TapCell: + """TC80 (20B: rgf 2B + unused 2B + BRC80×4) + SHD80 셰이딩.""" + width_tw: int = 0 + first_merged: bool = False # rgf 0x0001 — 가로 병합 시작 + merged: bool = False # rgf 0x0002 — 가로 병합 연속 + vert_merge: bool = False # rgf 0x0020 — 세로 병합 연속 + vert_restart: bool = False # rgf 0x0040 — 세로 병합 시작 + valign: int = 0 # rgf bits7-8 — 0 top / 1 center / 2 bottom + #: side → (val, sz_eighth_pt, color) — BRC80 그대로. + borders: Optional[Dict[str, Tuple[str, int, str]]] = None + shd_color: Optional[str] = None + + +@dataclass +class _Tap: + """행 정의 (sprmTDefTable 0xD608 + sprmTDefTableShd 0xD609).""" + boundaries: List[int] = field(default_factory=list) # (itcMac+1) twips + cells: List[_TapCell] = field(default_factory=list) + + @dataclass class _Pap: jc: int = 0 in_table: bool = False ttp: bool = False + space_before_tw: int = 0 + space_after_tw: int = 0 + line_mult: Optional[float] = None + tap: Optional[_Tap] = None @dataclass @@ -323,8 +348,62 @@ def _chp_of(grpprl: bytes, fonts: List[str]) -> _Chp: return chp +#: BRC80 brcType → docx 테두리 val (0/255 = 없음) +_BRC_VAL = {0: "nil", 255: "nil", 1: "single", 2: "single", 3: "double", + 5: "single", 6: "dotted", 7: "dashed", 8: "dotDash", + 9: "dotDotDash", 10: "triple"} + + +def _parse_brc(operand: bytes, off: int) -> Tuple[str, int, str]: + """BRC80 4B: w1 = dpt(1/8pt, 0xFF) | brcType<<8, w2 = ico(0xFF).""" + w1, w2 = struct.unpack_from("<2H", operand, off) + dpt = w1 & 0xFF + brc_type = (w1 >> 8) & 0xFF + ico = w2 & 0xFF + val = _BRC_VAL.get(brc_type, "single") + color = _ICO_RGB.get(ico, "000000") + return val, max(2, min(dpt, 48)), color + + +def _parse_tdef(operand: bytes) -> Optional[_Tap]: + """sprmTDefTable: itcMac(1B) + (itcMac+1)×INT16 경계(twips) + + itcMac×TC80(20B — 있을 때만).""" + if not operand: + return None + itc = operand[0] + if itc == 0 or itc > 63: + return None + need = 1 + (itc + 1) * 2 + if len(operand) < need: + return None + tap = _Tap() + tap.boundaries = list(struct.unpack_from(f"<{itc + 1}h", operand, 1)) + tc_start = need + for i in range(itc): + cell = _TapCell() + if i + 1 <= itc and len(tap.boundaries) > i + 1: + cell.width_tw = max(0, tap.boundaries[i + 1] - tap.boundaries[i]) + off = tc_start + i * 20 + if off + 20 <= len(operand): + (rgf,) = struct.unpack_from("> 7) & 0x3 + cell.borders = { + "top": _parse_brc(operand, off + 4), + "left": _parse_brc(operand, off + 8), + "bottom": _parse_brc(operand, off + 12), + "right": _parse_brc(operand, off + 16), + } + tap.cells.append(cell) + return tap + + def _pap_of(grpprl: bytes) -> _Pap: pap = _Pap() + shd_raw: Optional[bytes] = None for opcode, operand in _iter_sprms(grpprl): op = opcode & 0x1FF if op in (0x03, 0x61) and operand: # sprmPJc (97) / sprmPJc80 (2000+) @@ -333,6 +412,34 @@ def _pap_of(grpprl: bytes) -> _Pap: pap.in_table = operand[0] != 0 elif op == 0x17 and operand: pap.ttp = operand[0] != 0 + elif op == 0x12 and len(operand) >= 4: # sprmPDyaLine — LSPD + dya, f_mult = struct.unpack_from("= 2: # sprmPDyaBefore (twips) + (pap.space_before_tw,) = struct.unpack_from("= 2: # sprmPDyaAfter + (pap.space_after_tw,) = struct.unpack_from(" len(shd_raw): + break + (shd,) = struct.unpack_from("> 5) & 0x1F + ipat = (shd >> 10) & 0x3F + if ipat == 1 and ico_fore: # solid 전경색 + cell.shd_color = _ICO_RGB.get(ico_fore) + elif ico_back: + cell.shd_color = _ICO_RGB.get(ico_back) return pap @@ -490,6 +597,13 @@ def pap_at(cp: int) -> _Pap: 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] + pf = target.paragraph_format + if para.pap.line_mult is not None and abs(para.pap.line_mult - 1.0) > 0.01: + pf.line_spacing = para.pap.line_mult + if para.pap.space_before_tw > 0: + pf.space_before = Pt(min(para.pap.space_before_tw, 2880) / 20.0) + if para.pap.space_after_tw > 0: + pf.space_after = Pt(min(para.pap.space_after_tw, 2880) / 20.0) # 문자 서식 경계로 런을 쪼갠다 — 같은 _Chp 가 이어지면 한 런. run_chars: List[str] = [] run_chp: Optional[_Chp] = None @@ -562,6 +676,7 @@ def emit_text() -> None: continue # ── 표 구간: fInTable 연속 문단 → 행(fTtp 경계)/셀(0x07 종결) ── rows: List[List[List[_DocPara]]] = [] + row_taps: List[Optional[_Tap]] = [] cur_row: List[List[_DocPara]] = [] cur_cell: List[_DocPara] = [] while i < n_paras and paras[i].pap.in_table: @@ -572,6 +687,7 @@ def emit_text() -> None: cur_cell = [] if cur_row: rows.append(cur_row) + row_taps.append(tp.pap.tap) # 행 정의는 행 끝 문단에 실린다 cur_row = [] elif tp.terminator == "\x07": cur_cell.append(tp) @@ -584,6 +700,7 @@ def emit_text() -> None: cur_row.append(cur_cell) if cur_row: rows.append(cur_row) + row_taps.append(None) if not rows: continue n_cols = max(len(r) for r in rows) @@ -592,16 +709,122 @@ def emit_text() -> None: tbl.style = "Table Grid" except Exception: # noqa: BLE001 pass + from docx.oxml import OxmlElement + from docx.oxml.ns import qn + from docx.shared import Emu + + # 열 너비 — 첫 행 정의의 경계(twips) 차분 + first_tap = next((t for t in row_taps if t is not None), None) + if first_tap is not None: + for j, column in enumerate(tbl.columns): + if j < len(first_tap.cells) and first_tap.cells[j].width_tw > 0: + try: + column.width = Emu(first_tap.cells[j].width_tw * 635) + except Exception: # noqa: BLE001 + pass + + def set_borders(cell_obj, borders) -> None: + tc_pr = cell_obj._tc.get_or_add_tcPr() + tcb = tc_pr.find(qn("w:tcBorders")) + if tcb is None: + tcb = OxmlElement("w:tcBorders") + tc_pr.append(tcb) + for name, (val, sz, color) in borders.items(): + el = tcb.find(qn(f"w:{name}")) + if el is None: + el = OxmlElement(f"w:{name}") + tcb.append(el) + el.set(qn("w:val"), val) + el.set(qn("w:sz"), str(sz)) + el.set(qn("w:color"), color) + + def set_shd(cell_obj, rgb: str) -> None: + tc_pr = cell_obj._tc.get_or_add_tcPr() + shd = OxmlElement("w:shd") + shd.set(qn("w:val"), "clear") + shd.set(qn("w:fill"), rgb) + tc_pr.append(shd) + + def set_valign(cell_obj, v: int) -> None: + if v not in (1, 2): + return + tc_pr = cell_obj._tc.get_or_add_tcPr() + va = OxmlElement("w:vAlign") + va.set(qn("w:val"), "center" if v == 1 else "bottom") + tc_pr.append(va) + + # TC80 rgf 기반 병합 — 가로(fFirstMerged+fMerged 연속), + # 세로(fVertRestart 앵커 + 아래 행 fVertMerge 연속, 같은 열 색인). + for r_i, tap in enumerate(row_taps[:len(rows)]): + if tap is None: + continue + c = 0 + while c < min(len(tap.cells), n_cols): + if tap.cells[c].first_merged: + end = c + while (end + 1 < min(len(tap.cells), n_cols) + and tap.cells[end + 1].merged): + end += 1 + if end > c: + try: + tbl.cell(r_i, c).merge(tbl.cell(r_i, end)) + except Exception: # noqa: BLE001 + pass + c = end + 1 + else: + c += 1 + for c in range(n_cols): + r_i = 0 + while r_i < len(rows): + tap = row_taps[r_i] if r_i < len(row_taps) else None + if (tap is not None and c < len(tap.cells) + and tap.cells[c].vert_restart): + end = r_i + while end + 1 < len(rows): + nt = row_taps[end + 1] if end + 1 < len(row_taps) else None + if (nt is not None and c < len(nt.cells) + and nt.cells[c].vert_merge + and not nt.cells[c].vert_restart): + end += 1 + else: + break + if end > r_i: + try: + tbl.cell(r_i, c).merge(tbl.cell(end, c)) + except Exception: # noqa: BLE001 + pass + r_i = end + 1 + else: + r_i += 1 + for r_i, row in enumerate(rows): + tap = row_taps[r_i] if r_i < len(row_taps) else None for c_i, cell_paras in enumerate(row[:n_cols]): - cell = tbl.cell(r_i, c_i) + try: + cell = tbl.cell(r_i, c_i) + except Exception: # noqa: BLE001 + continue + if tap is not None and c_i < len(tap.cells): + tc = tap.cells[c_i] + # 병합 연속 셀은 앵커와 같은 _tc 를 공유한다 — 여기서 + # 스타일을 다시 쓰면 앵커의 테두리(예: 상변 이중선)를 + # 연속 행 값으로 덮어써 버린다. + continuation = (tc.merged and not tc.first_merged) or ( + tc.vert_merge and not tc.vert_restart) + if not continuation: + if tc.borders: + set_borders(cell, tc.borders) + if tc.shd_color: + set_shd(cell, tc.shd_color) + set_valign(cell, tc.valign) first = True for cp_para in cell_paras: - if first: + if first and not cell.paragraphs[0].runs: cell.paragraphs[0].text = "" target = cell.paragraphs[0] first = False else: + first = False target = cell.add_paragraph() emit_para(target, cp_para) diff --git a/src/xgen_edit2docs/documents/legacy/ppt_convert.py b/src/xgen_edit2docs/documents/legacy/ppt_convert.py index 2597da3..bfd608c 100644 --- a/src/xgen_edit2docs/documents/legacy/ppt_convert.py +++ b/src/xgen_edit2docs/documents/legacy/ppt_convert.py @@ -49,6 +49,7 @@ _RT_TEXT_CHARS_ATOM = 4000 _RT_STYLE_TEXT_PROP_ATOM = 4001 _RT_TEXT_BYTES_ATOM = 4008 +_RT_FONT_ENTITY_ATOM = 4023 # FontCollection(2005) 안 — 이름 64B UTF-16 _TITLE_TYPES = {0, 5} # title / center title @@ -80,12 +81,15 @@ class _CharStyle: strike: Optional[bool] = None size_pt: Optional[float] = None color: Optional[str] = None # RRGGBB + font_idx: Optional[int] = None # FontCollection 색인 @dataclass class _ParaStyle: indent: int = 0 align: Optional[int] = None # 0 left / 1 center / 2 right / 3 justify + bullet: Optional[bool] = None # paraFlags fHasBullet (마스크 유효 시) + bullet_char: Optional[str] = None #: (mask, size) — 문자 props, POI characterTextPropTypes 순서. @@ -150,7 +154,15 @@ def _parse_style_atom(payload: bytes, text_len: int size = 2 + cnt * 4 if pos + size > n: raise ValueError - if m == 0x800: + if m == 0xF: # paraFlags — bit0 fHasBullet (마스크 유효) + (pf,) = struct.unpack_from(" n: raise ValueError - if m == 0xFFFF: + if m == 0x10000: + (fi,) = struct.unpack_from(" str: return payload.decode("latin-1") -def _collect_slides(data: bytes) -> tuple[List[_SlideText], tuple[int, int]]: +def _collect_slides(data: bytes + ) -> tuple[List[_SlideText], tuple[int, int], List[str]]: slide_size_mu = (9144, 6858) # 10in × 7.5in 기본 slides: List[_SlideText] = [] + fonts: List[str] = [] def walk(start: int, end: int, in_sltwt: bool) -> None: nonlocal slide_size_mu @@ -231,6 +248,11 @@ def walk(start: int, end: int, in_sltwt: bool) -> None: walk(p_start, p_start + p_len, in_sltwt or rec_type == _RT_SLIDE_LIST_WITH_TEXT) continue + if rec_type == _RT_FONT_ENTITY_ATOM and p_len >= 2: + raw = data[p_start:p_start + min(p_len, 64)] + name = raw.decode("utf-16le", errors="replace").split("\x00")[0] + if name: + fonts.append(name) if rec_type == _RT_DOCUMENT_ATOM and p_len >= 8: w, h = struct.unpack_from(" None: data[p_start:p_start + p_len], len(block.text)) walk(0, len(data), False) - return slides, slide_size_mu + return slides, slide_size_mu, fonts # ── PPTX 조립 ────────────────────────────────────────────────── @@ -308,7 +330,7 @@ def ppt_to_pptx(content: bytes) -> bytes: finally: ole.close() - slides, (w_mu, h_mu) = _collect_slides(data) + slides, (w_mu, h_mu), font_names = _collect_slides(data) if not slides: raise LegacyConvertError("ppt 에서 슬라이드 텍스트를 찾지 못했습니다") @@ -324,6 +346,20 @@ def ppt_to_pptx(content: bytes) -> bytes: _ALIGN = {0: PP_ALIGN.LEFT, 1: PP_ALIGN.CENTER, 2: PP_ALIGN.RIGHT, 3: PP_ALIGN.JUSTIFY} + from pptx.oxml.ns import qn as _qn + + def set_bullet(para, pst: _ParaStyle) -> None: + """paraFlags fHasBullet → a:buChar / a:buNone (렌더 엔진이 접두로 + 그려 준다 — txbody_to_svg _resolve_bullet_prefix).""" + if pst.bullet is None: + return + p_pr = para._p.get_or_add_pPr() + if pst.bullet: + el = p_pr.makeelement(_qn("a:buChar"), {"char": pst.bullet_char or "•"}) + else: + el = p_pr.makeelement(_qn("a:buNone"), {}) + p_pr.append(el) + def emit_block(tf, block: _TextBlock, default_pt: float, default_bold: bool, first_para_used: bool) -> bool: """텍스트 블록 → 문단들(\\r 경계), 런 스타일/정렬/레벨 반영.""" @@ -337,6 +373,7 @@ def emit_block(tf, block: _TextBlock, default_pt: float, para.alignment = _ALIGN[pst.align] if pst.indent: para.level = min(pst.indent, 4) + set_bullet(para, pst) # 문자 런 경계 + 줄바꿈(0x0B) 지점으로 조각 낸다 cuts = sorted({pos, pos + len(para_text)} | { b for b in boundaries if pos < b < pos + len(para_text)} | { @@ -363,6 +400,9 @@ def emit_block(tf, block: _TextBlock, default_pt: float, run.font._rPr.set("strike", "sngStrike") if st.color: run.font.color.rgb = RGBColor.from_string(st.color) + if (st.font_idx is not None + and 0 <= st.font_idx < len(font_names)): + run.font.name = font_names[st.font_idx] pos += len(para_text) + 1 # + \r return first_para_used diff --git a/src/xgen_edit2docs/documents/legacy/xls_convert.py b/src/xgen_edit2docs/documents/legacy/xls_convert.py index 52164e5..e9ab174 100644 --- a/src/xgen_edit2docs/documents/legacy/xls_convert.py +++ b/src/xgen_edit2docs/documents/legacy/xls_convert.py @@ -190,6 +190,13 @@ class _Font: _VALIGN = {0: "top", 1: "center", 2: "bottom", 3: "justify"} +#: BIFF 테두리 line style → openpyxl style +_BORDER_STYLE = {1: "thin", 2: "medium", 3: "dashed", 4: "dotted", + 5: "thick", 6: "double", 7: "hair", 8: "mediumDashed", + 9: "dashDot", 10: "mediumDashDot", 11: "dashDotDot", + 12: "mediumDashDotDot", 13: "slantDashDot"} + + @dataclass class _XfStyle: ifnt: int = 0 @@ -198,14 +205,18 @@ class _XfStyle: valign: Optional[str] = None wrap: bool = False fill_icv: Optional[int] = None # solid 패턴의 전경색 icv + #: side → (openpyxl style, icv) — 선 없는 변은 없음. + borders: Optional[Dict[str, Tuple[str, int]]] = None def xls_to_xlsx(content: bytes) -> bytes: import olefile from openpyxl import Workbook from openpyxl.styles import Alignment as XlAlignment + from openpyxl.styles import Border as XlBorder from openpyxl.styles import Font as XlFont from openpyxl.styles import PatternFill as XlPatternFill + from openpyxl.styles import Side as XlSide from openpyxl.styles.numbers import BUILTIN_FORMATS from openpyxl.utils import get_column_letter @@ -287,7 +298,27 @@ def xls_to_xlsx(content: bytes) -> bytes: valign = (alc >> 4) & 0x07 if valign != 2: # bottom 이 기본값 — 소음 줄이기 xf.valign = _VALIGN.get(valign) + (brdbkg1,) = struct.unpack_from("> 4) & 0xF, + "top": (brdbkg1 >> 8) & 0xF, + "bottom": (brdbkg1 >> 12) & 0xF, + } + colors = { + "left": (brdbkg1 >> 16) & 0x7F, + "right": (brdbkg1 >> 23) & 0x7F, + "top": brdbkg2 & 0x7F, + "bottom": (brdbkg2 >> 7) & 0x7F, + } + borders = { + side: (_BORDER_STYLE[st], colors[side]) + for side, st in styles.items() + if st in _BORDER_STYLE + } + if borders: + xf.borders = borders pattern = (brdbkg2 >> 26) & 0x3F if pattern == 1: # solid (bkg3,) = struct.unpack_from(" None: if bg and bg != "FFFFFF": cell.fill = XlPatternFill( fill_type="solid", fgColor=bg) + if xf.borders: + sides = {} + for name, (style, icv) in xf.borders.items(): + sides[name] = XlSide( + border_style=style, color=hex_of_icv(icv) or "000000") + cell.border = XlBorder(**sides) for rtype, bstart, rlen, _rpos in _iter_biff(data, bof_pos): if rtype == _R_EOF: diff --git a/src/xgen_edit2docs/documents/xlsx_pages.py b/src/xgen_edit2docs/documents/xlsx_pages.py index 05f981e..5905cb3 100644 --- a/src/xgen_edit2docs/documents/xlsx_pages.py +++ b/src/xgen_edit2docs/documents/xlsx_pages.py @@ -74,17 +74,72 @@ def _cell_fill_hex(cell) -> Optional[str]: return None -def _cell_font(cell) -> tuple[bool, Optional[str], float]: - bold, color, size = False, None, _FONT_PX +def _cell_font(cell) -> tuple[bool, Optional[str], float, bool, str]: + """(bold, color, size_px, italic, deco) — deco 는 text-decoration 값.""" + bold, color, size, italic, deco = False, None, _FONT_PX, False, "" try: font = cell.font bold = bool(font.b) + italic = bool(font.i) + parts = [] + if font.u: + parts.append("underline") + if font.strike: + parts.append("line-through") + deco = " ".join(parts) color = _argb_to_hex(getattr(font.color, "rgb", None) if font.color else None) if font.sz: size = float(font.sz) * 96 / 72 except Exception: # noqa: BLE001 pass - return bold, color, size + return bold, color, size, italic, deco + + +#: openpyxl border style → (선폭 px, dasharray | None). 없으면 기본 격자. +_XL_BORDER = { + "thin": (0.9, None), "hair": (0.6, None), "medium": (1.6, None), + "thick": (2.4, None), "double": (2.2, None), + "dashed": (0.9, "5 3"), "mediumDashed": (1.6, "5 3"), + "dotted": (0.9, "2 2"), "dashDot": (0.9, "5 3 2 3"), + "mediumDashDot": (1.6, "5 3 2 3"), "dashDotDot": (0.9, "5 3 2 3 2 3"), + "mediumDashDotDot": (1.6, "5 3 2 3 2 3"), "slantDashDot": (1.6, "5 3"), +} + + +def _cell_border_lines(cell, x: float, y: float, w: float, h: float) -> str: + """선언된 변만 스타일대로 — 나머지는 호출부의 기본 격자가 담당.""" + try: + border = cell.border + except Exception: # noqa: BLE001 + return "" + coords = { + "left": (x, y, x, y + h), "right": (x + w, y, x + w, y + h), + "top": (x, y, x + w, y), "bottom": (x, y + h, x + w, y + h), + } + out = [] + for side, (x1, y1, x2, y2) in coords.items(): + try: + sd = getattr(border, side) + style = sd.style if sd is not None else None + except Exception: # noqa: BLE001 + continue + if not style or style not in _XL_BORDER: + continue + width, dash = _XL_BORDER[style] + color = "#666666" + try: + rgb = getattr(sd.color, "rgb", None) if sd.color else None + hexed = _argb_to_hex(rgb) + if hexed: + color = hexed + except Exception: # noqa: BLE001 + pass + dash_attr = f' stroke-dasharray="{dash}"' if dash else "" + out.append( + f'' + ) + return "".join(out) def xlsx_to_page_svgs(content: bytes) -> list[str]: @@ -257,7 +312,7 @@ def _render_band( value = cached text = format_cell_value(value, cell.number_format) fill = _cell_fill_hex(cell) - bold, color, size = _cell_font(cell) + bold, color, size, italic, deco = _cell_font(cell) ref = f"{get_column_letter(col_i)}{row_i}" attrs = f' fill="{fill}"' if fill else ' fill="none"' @@ -266,18 +321,32 @@ def _render_band( f'' ] + cell_parts.append(_cell_border_lines(cell, x, y, cw, ch)) if text: is_num = isinstance(value, (int, float)) and not isinstance(value, bool) max_chars = max(int((cw - _CELL_PAD * 2) / (size * 0.55)), 1) shown = text if len(text) <= max_chars else text[: max(max_chars - 1, 1)] + "…" - tx = x + cw - _CELL_PAD if is_num else x + _CELL_PAD - anchor = "end" if is_num else "start" + # 셀 정렬 — 명시 정렬 > 타입 기본(숫자 우측/문자 좌측) + halign = None + try: + halign = cell.alignment.horizontal + except Exception: # noqa: BLE001 + pass + if halign in ("center", "centerContinuous"): + tx, anchor = x + cw / 2, "middle" + elif halign == "right" or (halign is None and is_num): + tx, anchor = x + cw - _CELL_PAD, "end" + else: + tx, anchor = x + _CELL_PAD, "start" weight = ' font-weight="bold"' if bold else "" + style_extra = ' font-style="italic"' if italic else "" + if deco: + style_extra += f' text-decoration="{deco}"' fill_attr = color or "#222222" cell_parts.append( f'{_esc(shown)}' ) cell_parts.append("") diff --git a/tests/unit/test_legacy_formats.py b/tests/unit/test_legacy_formats.py index 915f02d..23562e8 100644 --- a/tests/unit/test_legacy_formats.py +++ b/tests/unit/test_legacy_formats.py @@ -606,6 +606,40 @@ def fc(cp: int) -> int: sprm_jc = struct.pack(" bytes: + return struct.pack("<2H", dpt | (btype << 8), ico) + + def tc80(rgf: int, top, left, bottom, right) -> bytes: + return struct.pack("<2H", rgf, 0) + top + left + bottom + right + + solid = brc(4, 1, 1) + + def tdef(tcs: list[bytes]) -> bytes: + # itcMac + (itc+1)×경계(0/2880/5760 twips) + TC80×itc + body = bytes([len(tcs)]) + body += struct.pack(f"<{len(tcs) + 1}h", + *[i * 2880 for i in range(len(tcs) + 1)]) + body += b"".join(tcs) + return struct.pack(" int: (fc(7), b""), # 문단2 (fc(14), sprm_intbl), # A1 셀 (fc(17), sprm_intbl), # B1 셀 - (fc(20), sprm_intbl + sprm_ttp), # 행마크1 + (fc(20), sprm_intbl + sprm_ttp + tdef_row1 + shd_row1), # 행마크1 (fc(21), sprm_intbl), # A2 (fc(24), sprm_intbl), # B2 - (fc(27), sprm_intbl + sprm_ttp), # 행마크2 - (fc(28), b""), # 끝문단 + (fc(27), sprm_intbl + sprm_ttp + tdef_row2), # 행마크2 + (fc(28), sprm_before + sprm_line), # 끝문단 — 간격/줄간격 ], fc(len(body))) # FKP 페이지는 512 정렬 오프셋에 놓인다 — pn 6/7 사용 (word 0x1000 안) @@ -671,12 +705,19 @@ def font(height: int, grbit: int, icv: int, weight: int, uls: int) -> bytes: fonts = [font(200, 0, 0x7FFF, 400, 0)] * 4 fonts.append(font(240, 0x0008, 40, 700, 1)) # 12pt bold strike underline icv40 - def xf(ifnt: int, alc: int, pattern_icv: int | None) -> bytes: + def xf(ifnt: int, alc: int, pattern_icv: int | None, + with_borders: bool = False) -> bytes: p = bytearray(20) struct.pack_into(" bytes: globals_part += rec(0x0092, pal) for _ in range(15): globals_part += rec(0x00E0, xf(0, 0, None)) - globals_part += rec(0x00E0, xf(5, 0x02, 40)) # ixfe 15: 가운데+채움+스타일폰트 + globals_part += rec(0x00E0, xf(5, 0x02, 40, with_borders=True)) # ixfe 15 globals_part += rec(0x00FC, sst_payload) sheet_cells = rec(0x00FD, struct.pack(" bytes: return struct.pack(" bytes: slwt_children += rec(3999, struct.pack("