📦 Initialize Geulbeot structure and merge Prompts & test projects

This commit is contained in:
2026-03-05 11:32:29 +09:00
commit 555a954458
687 changed files with 205247 additions and 0 deletions

View File

@@ -0,0 +1,824 @@
# -*- coding: utf-8 -*-
"""
Style Generator v2.1 (Phase 4 — 하드코딩 제거)
template_info의 tools 추출값 → CSS 문자열 생성.
★ v2.1 변경사항:
- 하드코딩 간격 → 추출값 대체:
· .doc-header margin-bottom → page.margins.header에서 계산
· .doc-footer margin-top → page.margins.footer에서 계산
· .title-block margin/padding → title paraPr spacing에서 유도
- .img-wrap, .img-caption CSS 추가 (content_order 이미지 지원)
★ v2.0 변경사항 (v1.0 대비):
- charPr 28개 전체 → .cpr-{id} CSS 클래스 생성
- paraPr 23개 전체 → .ppr-{id} CSS 클래스 생성
- styles 12개 → .sty-{id} CSS 클래스 (charPr + paraPr 조합)
- fontRef → 실제 폰트명 해석 (font_map 빌드)
- 제목 블록: 하드코딩 제거 → 실제 추출 데이터 사용
- 줄간격: paraPr별 line-height 개별 적용
- 여백: @page는 인쇄용, .page는 화면용 (이중 적용 제거)
- bf CSS: NONE-only borderFill도 클래스 생성 (border: none 명시)
- 텍스트 색상: charPr별 color 반영
- 폰트: charPr별 fontRef → 실제 font-family 해석
★ 원칙: hwpx_domain_guide.md §1~§8 매핑 규칙 100% 준수
★ 원칙: 하드코딩 값 0개. 모든 CSS 값은 template_info에서 유래.
"""
HU_TO_MM = 25.4 / 7200 # 1 HWPUNIT = 1/7200 inch → mm
# ================================================================
# 메인 엔트리포인트
# ================================================================
def generate_css(template_info: dict, semantic_map: dict = None) -> str:
"""template_info + semantic_map → CSS 문자열 전체 생성."""
# font_map 빌드 (charPr CSS에서 재사용)
fm = _build_font_map(template_info)
parts = [
_page_css(template_info),
_body_css(template_info, fm),
_layout_css(template_info),
_header_footer_css(template_info),
_title_block_css(template_info, fm, semantic_map),
_section_css(template_info),
_table_base_css(template_info),
_border_fill_css(template_info),
_char_pr_css(template_info, fm),
_para_pr_css(template_info),
_named_style_css(template_info),
_table_detail_css(template_info, semantic_map),
]
return "\n\n".join(p for p in parts if p)
# ================================================================
# @page (인쇄 전용)
# ================================================================
def _page_css(ti: dict) -> str:
page = ti.get("page", {})
paper = page.get("paper", {})
margins = page.get("margins", {})
w = paper.get("width_mm", 210)
h = paper.get("height_mm", 297)
mt = margins.get("top", "20mm")
mb = margins.get("bottom", "20mm")
ml = margins.get("left", "20mm")
mr = margins.get("right", "20mm")
return (
"@page {\n"
f" size: {w}mm {h}mm;\n"
f" margin: {mt} {mr} {mb} {ml};\n"
"}\n"
"@media screen {\n"
" @page { margin: 0; }\n" # 화면에서는 .page padding만 사용
"}"
)
# ================================================================
# body
# ================================================================
def _body_css(ti: dict, fm: dict) -> str:
"""바탕글 스타일 기준 body CSS"""
# '바탕글' 스타일 → charPr → fontRef → 실제 폰트
base_charpr = _resolve_style_charpr(ti, "바탕글")
base_parapr = _resolve_style_parapr(ti, "바탕글")
# 폰트
font_family = _charpr_font_family(base_charpr, fm)
# 크기
size_pt = base_charpr.get("height_pt", 10.0)
# 색상
color = base_charpr.get("textColor", "#000000")
# 줄간격
line_height = _parapr_line_height(base_parapr)
# 정렬
# body에는 정렬 넣지 않음 (paraPr별로)
return (
"body {\n"
f" font-family: {font_family};\n"
f" font-size: {size_pt}pt;\n"
f" line-height: {line_height};\n"
f" color: {color};\n"
" margin: 0; padding: 0;\n"
"}"
)
# ================================================================
# .page 레이아웃 (화면 전용 — 여백은 여기서만)
# ================================================================
def _layout_css(ti: dict) -> str:
page = ti.get("page", {})
paper = page.get("paper", {})
margins = page.get("margins", {})
w = paper.get("width_mm", 210)
ml = _mm(margins.get("left", "20mm"))
mr = _mm(margins.get("right", "20mm"))
body_w = w - ml - mr
mt = margins.get("top", "20mm")
mb = margins.get("bottom", "20mm")
m_left = margins.get("left", "20mm")
m_right = margins.get("right", "20mm")
return (
".page {\n"
f" width: {body_w:.0f}mm;\n"
" margin: 0 auto;\n"
f" padding: {mt} {m_right} {mb} {m_left};\n"
"}"
)
# ================================================================
# 헤더 / 푸터
# ================================================================
def _header_footer_css(ti: dict) -> str:
page = ti.get("page", {})
margins = page.get("margins", {})
# 헤더 margin-bottom: page.margins.header에서 유도
# 푸터 margin-top: page.margins.footer에서 유도
hdr_margin = margins.get("header", "")
ftr_margin = margins.get("footer", "")
hdr_mb = f"{_mm(hdr_margin) * 0.3:.1f}mm" if hdr_margin else "4mm"
ftr_mt = f"{_mm(ftr_margin) * 0.4:.1f}mm" if ftr_margin else "6mm"
lines = [
"/* 헤더/푸터 */",
f".doc-header {{ margin-bottom: {hdr_mb}; }}",
f".doc-footer {{ margin-top: {ftr_mt}; }}",
".doc-header table, .doc-footer table {",
" width: 100%; border-collapse: collapse;",
"}",
]
hdr_padding = _hf_cell_padding(ti.get("header"))
ftr_padding = _hf_cell_padding(ti.get("footer"))
lines.append(
f".doc-header td {{ {hdr_padding} vertical-align: middle; }}"
)
lines.append(
f".doc-footer td {{ {ftr_padding} vertical-align: middle; }}"
)
return "\n".join(lines)
# ================================================================
# 제목 블록 — ★ 하드코딩 제거, 실제 데이터 사용
# ================================================================
def _title_block_css(ti: dict, fm: dict, sm: dict = None) -> str:
"""제목 블록 CSS — title_table의 실제 셀 데이터에서 추출"""
tables = ti.get("tables", [])
# semantic_map에서 title_table 인덱스 가져오기
title_idx = None
if sm:
title_idx = sm.get("title_table")
title_tbl = None
if title_idx is not None:
title_tbl = next((t for t in tables if t["index"] == title_idx), None)
# 못 찾으면 1행 표 중 텍스트 있는 것 검색
if not title_tbl:
for t in tables:
rows = t.get("rows", [])
if rows and len(rows) == 1:
for cell in rows[0]:
if cell.get("text", "").strip():
title_tbl = t
break
if title_tbl:
break
lines = ["/* 제목 블록 */"]
if title_tbl:
# 텍스트 있는 셀에서 charPr, paraPr, bf 추출
title_charpr = None
title_parapr = None
title_bf_id = None
for row in title_tbl.get("rows", []):
for cell in row:
if cell.get("text", "").strip():
# ★ primaryCharPrIDRef 사용 (table_v2 추출)
cpr_id = cell.get("primaryCharPrIDRef")
if cpr_id is not None:
title_charpr = next(
(c for c in ti.get("char_styles", [])
if c.get("id") == cpr_id), None
)
ppr_id = cell.get("primaryParaPrIDRef")
if ppr_id is not None:
title_parapr = next(
(p for p in ti.get("para_styles", [])
if p.get("id") == ppr_id), None
)
title_bf_id = cell.get("borderFillIDRef")
break
if title_charpr:
break
# charPr 못 찾으면 폴백 (charPrIDRef가 없는 구버전 table.py)
if not title_charpr:
title_charpr = _find_title_charpr(ti)
# CSS 생성
font_family = _charpr_font_family(title_charpr, fm) if title_charpr else "'맑은 고딕', sans-serif"
size_pt = title_charpr.get("height_pt", 15.0) if title_charpr else 15.0
bold = title_charpr.get("bold", False) if title_charpr else False
color = title_charpr.get("textColor", "#000000") if title_charpr else "#000000"
# 줄간격
line_height = _parapr_line_height(title_parapr) if title_parapr else "180%"
align = _parapr_align(title_parapr) if title_parapr else "center"
# ★ margin/padding — paraPr 또는 page.margins에서 유도
title_after_mm = "4mm" # 기본값
title_padding = "4mm 0" # 기본값
if title_parapr:
margin_info = title_parapr.get("margin", {})
after_hu = margin_info.get("after_hu", 0)
if after_hu:
title_after_mm = f"{after_hu * HU_TO_MM:.1f}mm"
before_hu = margin_info.get("before_hu", 0)
if before_hu or after_hu:
b_mm = before_hu * HU_TO_MM if before_hu else 4
a_mm = after_hu * HU_TO_MM if after_hu else 0
title_padding = f"{b_mm:.1f}mm 0 {a_mm:.1f}mm 0"
lines.append(f".title-block {{ margin-bottom: {title_after_mm}; }}")
lines.append(".title-table { width: 100%; border-collapse: collapse; }")
lines.append(
f".title-block h1 {{\n"
f" font-family: {font_family};\n"
f" font-size: {size_pt}pt;\n"
f" font-weight: {'bold' if bold else 'normal'};\n"
f" color: {color};\n"
f" text-align: {align};\n"
f" line-height: {line_height};\n"
f" margin: 0; padding: {title_padding};\n"
f"}}"
)
# bf 적용 (파란 하단선 등)
if title_bf_id:
bf_data = ti.get("border_fills", {}).get(str(title_bf_id), {})
css_dict = bf_data.get("css", {})
bf_rules = []
for prop, val in css_dict.items():
if val and val.lower() != "none":
bf_rules.append(f" {prop}: {val};")
if bf_rules:
lines.append(
f".title-block {{\n"
+ "\n".join(bf_rules)
+ "\n}"
)
else:
lines.append(".title-block { margin-bottom: 4mm; }")
lines.append(".title-table { width: 100%; border-collapse: collapse; }")
lines.append(
".title-block h1 {\n"
" font-size: 15pt; font-weight: normal;\n"
" text-align: center; margin: 0; padding: 4mm 0;\n"
"}"
)
return "\n".join(lines)
# ================================================================
# 섹션 — 하드코딩 제거
# ================================================================
def _section_css(ti: dict) -> str:
"""섹션 CSS — '#큰아이콘' 또는 '개요1' 스타일에서 추출"""
lines = ["/* 섹션 */"]
# 섹션 제목: '#큰아이콘' 또는 가장 큰 bold charPr
title_charpr = _resolve_style_charpr(ti, "#큰아이콘")
if not title_charpr or title_charpr.get("id") == 0:
title_charpr = _resolve_style_charpr(ti, "개요1")
if not title_charpr or title_charpr.get("id") == 0:
# 폴백: bold인 charPr 중 가장 큰 것
for cs in sorted(ti.get("char_styles", []),
key=lambda x: x.get("height_pt", 0), reverse=True):
if cs.get("bold"):
title_charpr = cs
break
if title_charpr:
size = title_charpr.get("height_pt", 11)
bold = title_charpr.get("bold", True)
color = title_charpr.get("textColor", "#000000")
lines.append(
f".section-title {{\n"
f" font-size: {size}pt;\n"
f" font-weight: {'bold' if bold else 'normal'};\n"
f" color: {color};\n"
f" margin-bottom: 3mm;\n"
f"}}"
)
else:
lines.append(
".section-title { font-weight: bold; margin-bottom: 3mm; }"
)
lines.append(".section { margin-bottom: 6mm; }")
lines.append(".section-content { text-align: justify; }")
# content_order 기반 본문용 스타일
lines.append("/* 이미지/문단 (content_order) */")
lines.append(
".img-wrap { text-align: center; margin: 3mm 0; }"
)
lines.append(
".img-wrap img { max-width: 100%; height: auto; }"
)
lines.append(
".img-caption { font-size: 9pt; color: #666; margin-top: 1mm; }"
)
return "\n".join(lines)
# ================================================================
# 데이터 표 기본 CSS
# ================================================================
def _table_base_css(ti: dict) -> str:
"""표 기본 — '표내용' 스타일 charPr에서 추출"""
tbl_charpr = _resolve_style_charpr(ti, "표내용")
tbl_parapr = _resolve_style_parapr(ti, "표내용")
size_pt = tbl_charpr.get("height_pt", 9.0) if tbl_charpr else 9.0
line_height = _parapr_line_height(tbl_parapr) if tbl_parapr else "160%"
align = _parapr_align(tbl_parapr) if tbl_parapr else "justify"
border_fills = ti.get("border_fills", {})
if border_fills:
# bf-{id} 클래스가 셀별 테두리를 담당 → 기본값은 none
# (하드코딩 border를 넣으면 bf 클래스보다 specificity가 높아 덮어씀)
border_rule = "border: none;"
else:
# border_fills 추출 실패 시에만 폴백
border_rule = "border: 1px solid #000;"
return (
"/* 데이터 표 */\n"
".data-table {\n"
" width: 100%; border-collapse: collapse; margin: 4mm 0;\n"
"}\n"
".data-table th, .data-table td {\n"
f" {border_rule}\n"
f" font-size: {size_pt}pt;\n"
f" line-height: {line_height};\n"
f" text-align: {align};\n"
" vertical-align: middle;\n"
"}\n"
".data-table th {\n"
" font-weight: bold; text-align: center;\n"
"}"
)
# ================================================================
# borderFill → .bf-{id} CSS 클래스
# ================================================================
def _border_fill_css(ti: dict) -> str:
"""★ v2.0: NONE-only bf도 클래스 생성 (border: none 명시)"""
border_fills = ti.get("border_fills", {})
if not border_fills:
return ""
parts = ["/* borderFill → CSS 클래스 */"]
for bf_id, bf in border_fills.items():
rules = []
css_dict = bf.get("css", {})
for prop, val in css_dict.items():
if val:
# NONE도 포함 (border: none 명시)
rules.append(f" {prop}: {val};")
# background
if "background-color" not in css_dict:
bg = bf.get("background", "")
if bg and bg.lower() not in ("", "none", "transparent",
"#ffffff", "#fff"):
rules.append(f" background-color: {bg};")
if rules:
parts.append(f".bf-{bf_id} {{\n" + "\n".join(rules) + "\n}")
return "\n".join(parts) if len(parts) > 1 else ""
# ================================================================
# ★ NEW: charPr → .cpr-{id} CSS 클래스
# ================================================================
def _char_pr_css(ti: dict, fm: dict) -> str:
"""charPr 전체 → 개별 CSS 클래스 생성.
각 .cpr-{id}에 font-family, font-size, font-weight, color 등 포함.
HTML에서 <span class="cpr-5"> 등으로 참조.
"""
char_styles = ti.get("char_styles", [])
if not char_styles:
return ""
parts = ["/* charPr → CSS 클래스 (글자 모양) */"]
for cs in char_styles:
cid = cs.get("id")
rules = []
# font-family
ff = _charpr_font_family(cs, fm)
if ff:
rules.append(f" font-family: {ff};")
# font-size
pt = cs.get("height_pt")
if pt:
rules.append(f" font-size: {pt}pt;")
# bold
if cs.get("bold"):
rules.append(" font-weight: bold;")
# italic
if cs.get("italic"):
rules.append(" font-style: italic;")
# color
color = cs.get("textColor", "#000000")
if color and color.lower() != "#000000":
rules.append(f" color: {color};")
# underline — type이 NONE이 아닌 실제 밑줄만
underline = cs.get("underline", "NONE")
ACTIVE_UNDERLINE = {"BOTTOM", "CENTER", "TOP", "SIDE"}
if underline in ACTIVE_UNDERLINE:
rules.append(" text-decoration: underline;")
# strikeout — shape="NONE" 또는 "3D"는 취소선 아님
# 실제 취소선: CONTINUOUS, DASH, DOT 등 선 스타일만
strikeout = cs.get("strikeout", "NONE")
ACTIVE_STRIKEOUT = {"CONTINUOUS", "DASH", "DOT", "DASH_DOT",
"DASH_DOT_DOT", "LONG_DASH", "DOUBLE"}
if strikeout in ACTIVE_STRIKEOUT:
rules.append(" text-decoration: line-through;")
# ── 자간 (letter-spacing) ──
# HWPX spacing은 % 단위: letter-spacing = height_pt × spacing / 100
spacing_pct = cs.get("spacing", {}).get("hangul", 0)
if spacing_pct != 0 and pt:
ls_val = round(pt * spacing_pct / 100, 2)
rules.append(f" letter-spacing: {ls_val}pt;")
# ── 장평 (scaleX) ──
# HWPX ratio는 글자 폭 비율 (100=기본). CSS transform으로 변환
ratio_pct = cs.get("ratio", {}).get("hangul", 100)
if ratio_pct != 100:
rules.append(f" transform: scaleX({ratio_pct / 100});")
rules.append(" display: inline-block;") # scaleX 적용 필수
if rules:
parts.append(f".cpr-{cid} {{\n" + "\n".join(rules) + "\n}")
return "\n".join(parts) if len(parts) > 1 else ""
# ================================================================
# ★ NEW: paraPr → .ppr-{id} CSS 클래스
# ================================================================
def _para_pr_css(ti: dict) -> str:
"""paraPr 전체 → 개별 CSS 클래스 생성.
각 .ppr-{id}에 text-align, line-height, text-indent, margin 등 포함.
HTML에서 <p class="ppr-3"> 등으로 참조.
"""
para_styles = ti.get("para_styles", [])
if not para_styles:
return ""
parts = ["/* paraPr → CSS 클래스 (문단 모양) */"]
for ps in para_styles:
pid = ps.get("id")
rules = []
# text-align
align = _parapr_align(ps)
if align:
rules.append(f" text-align: {align};")
# line-height
lh = _parapr_line_height(ps)
if lh:
rules.append(f" line-height: {lh};")
# text-indent
margin = ps.get("margin", {})
indent_hu = margin.get("indent_hu", 0)
if indent_hu:
indent_mm = indent_hu * HU_TO_MM
rules.append(f" text-indent: {indent_mm:.1f}mm;")
# margin-left
left_hu = margin.get("left_hu", 0)
if left_hu:
left_mm = left_hu * HU_TO_MM
rules.append(f" margin-left: {left_mm:.1f}mm;")
# margin-right
right_hu = margin.get("right_hu", 0)
if right_hu:
right_mm = right_hu * HU_TO_MM
rules.append(f" margin-right: {right_mm:.1f}mm;")
# spacing before/after
before = margin.get("before_hu", 0)
if before:
rules.append(f" margin-top: {before * HU_TO_MM:.1f}mm;")
after = margin.get("after_hu", 0)
if after:
rules.append(f" margin-bottom: {after * HU_TO_MM:.1f}mm;")
if rules:
parts.append(f".ppr-{pid} {{\n" + "\n".join(rules) + "\n}")
return "\n".join(parts) if len(parts) > 1 else ""
# ================================================================
# ★ NEW: named style → .sty-{id} CSS 클래스
# ================================================================
def _named_style_css(ti: dict) -> str:
"""styles 목록 → .sty-{id} CSS 클래스.
각 style은 charPrIDRef + paraPrIDRef 조합.
→ .sty-{id} = .cpr-{charPrIDRef} + .ppr-{paraPrIDRef} 의미.
HTML에서 class="sty-0" 또는 class="cpr-5 ppr-11" 로 참조.
"""
styles = ti.get("styles", [])
if not styles:
return ""
parts = ["/* named styles */"]
for s in styles:
sid = s.get("id")
name = s.get("name", "")
cpr_id = s.get("charPrIDRef")
ppr_id = s.get("paraPrIDRef")
# 주석으로 매핑 기록
parts.append(
f"/* .sty-{sid} '{name}' = cpr-{cpr_id} + ppr-{ppr_id} */"
)
return "\n".join(parts)
# ================================================================
# 표 상세 CSS (열 너비, 셀 패딩)
# ================================================================
def _table_detail_css(ti: dict, sm: dict = None) -> str:
if not sm:
return ""
body_indices = sm.get("body_tables", [])
tables = ti.get("tables", [])
if not body_indices or not tables:
return ""
parts = ["/* 표 상세 (tools 추출값) */"]
for tbl_num, tbl_idx in enumerate(body_indices, 1):
tbl = next((t for t in tables if t["index"] == tbl_idx), None)
if not tbl:
continue
cls = f"tbl-{tbl_num}"
# 열 너비
col_pcts = tbl.get("colWidths_pct", [])
if col_pcts:
for c_idx, pct in enumerate(col_pcts):
parts.append(
f".{cls} col:nth-child({c_idx + 1}) {{ width: {pct}%; }}"
)
# 셀 패딩
cm = _first_cell_margin(tbl)
if cm:
ct = cm.get("top", 0) * HU_TO_MM
cb = cm.get("bottom", 0) * HU_TO_MM
cl = cm.get("left", 0) * HU_TO_MM
cr = cm.get("right", 0) * HU_TO_MM
parts.append(
f".{cls} td, .{cls} th {{\n"
f" padding: {ct:.1f}mm {cr:.1f}mm {cb:.1f}mm {cl:.1f}mm;\n"
f"}}"
)
# 헤더행 높이
first_row = tbl.get("rows", [[]])[0]
if first_row:
h_hu = first_row[0].get("height_hu", 0)
if h_hu > 0:
h_mm = h_hu * HU_TO_MM
parts.append(
f".{cls} thead th {{ height: {h_mm:.1f}mm; }}"
)
return "\n".join(parts) if len(parts) > 1 else ""
# ================================================================
# 보조 함수
# ================================================================
def _build_font_map(ti: dict) -> dict:
"""fonts → {(lang, id): face_name} 딕셔너리"""
fm = {}
for lang, flist in ti.get("fonts", {}).items():
if isinstance(flist, list):
for f in flist:
fm[(lang, f.get("id", 0))] = f.get("face", "")
return fm
def _charpr_font_family(charpr: dict, fm: dict) -> str:
"""charPr의 fontRef → 실제 font-family CSS 값"""
if not charpr:
return "'맑은 고딕', sans-serif"
fr = charpr.get("fontRef", {})
hangul_id = fr.get("hangul", 0)
latin_id = fr.get("latin", 0)
hangul_face = fm.get(("HANGUL", hangul_id), "")
latin_face = fm.get(("LATIN", latin_id), "")
faces = []
if hangul_face:
faces.append(f"'{hangul_face}'")
if latin_face and latin_face != hangul_face:
faces.append(f"'{latin_face}'")
faces.append("sans-serif")
return ", ".join(faces)
def _resolve_style_charpr(ti: dict, style_name: str) -> dict:
"""스타일 이름 → charPr dict 해석"""
styles = ti.get("styles", [])
char_styles = ti.get("char_styles", [])
for s in styles:
if s.get("name") == style_name:
cpr_id = s.get("charPrIDRef")
for cs in char_styles:
if cs.get("id") == cpr_id:
return cs
# 못 찾으면 charPr[0] (바탕글 기본)
return char_styles[0] if char_styles else {}
def _resolve_style_parapr(ti: dict, style_name: str) -> dict:
"""스타일 이름 → paraPr dict 해석"""
styles = ti.get("styles", [])
para_styles = ti.get("para_styles", [])
for s in styles:
if s.get("name") == style_name:
ppr_id = s.get("paraPrIDRef")
for ps in para_styles:
if ps.get("id") == ppr_id:
return ps
return para_styles[0] if para_styles else {}
def _find_title_charpr(ti: dict) -> dict:
"""제목용 charPr 추론 (primaryCharPrIDRef 없을 때 폴백).
헤드라인 폰트 or 가장 큰 크기 기준.
"""
headline_keywords = ["헤드라인", "headline", "제목", "title"]
fm = _build_font_map(ti)
best = {}
best_pt = 0
for cs in ti.get("char_styles", []):
pt = cs.get("height_pt", 0)
fr = cs.get("fontRef", {})
hangul_id = fr.get("hangul", 0)
face = fm.get(("HANGUL", hangul_id), "").lower()
# 헤드라인 폰트면 우선
if any(kw in face for kw in headline_keywords):
if pt > best_pt:
best_pt = pt
best = cs
# 헤드라인 폰트 못 찾으면 가장 큰 것
if not best:
for cs in ti.get("char_styles", []):
pt = cs.get("height_pt", 0)
if pt > best_pt:
best_pt = pt
best = cs
return best
def _parapr_line_height(parapr: dict) -> str:
"""paraPr → CSS line-height"""
if not parapr:
return "160%"
ls = parapr.get("lineSpacing", {})
ls_type = ls.get("type", "PERCENT")
ls_val = ls.get("value", 160)
if ls_type == "PERCENT":
return f"{ls_val}%"
elif ls_type == "FIXED":
return f"{ls_val / 100:.1f}pt"
else:
return f"{ls_val}%"
def _parapr_align(parapr: dict) -> str:
"""paraPr → CSS text-align"""
if not parapr:
return "justify"
align = parapr.get("align", "JUSTIFY")
return {
"JUSTIFY": "justify", "LEFT": "left", "RIGHT": "right",
"CENTER": "center", "DISTRIBUTE": "justify",
"DISTRIBUTE_SPACE": "justify"
}.get(align, "justify")
def _hf_cell_padding(hf_info: dict | None) -> str:
if not hf_info or not hf_info.get("table"):
return "padding: 2px 4px;"
rows = hf_info["table"].get("rows", [])
if not rows or not rows[0]:
return "padding: 2px 4px;"
cm = rows[0][0].get("cellMargin", {})
if not cm:
return "padding: 2px 4px;"
ct = cm.get("top", 0) * HU_TO_MM
cb = cm.get("bottom", 0) * HU_TO_MM
cl = cm.get("left", 0) * HU_TO_MM
cr = cm.get("right", 0) * HU_TO_MM
return f"padding: {ct:.1f}mm {cr:.1f}mm {cb:.1f}mm {cl:.1f}mm;"
def _first_cell_margin(tbl: dict) -> dict | None:
for row in tbl.get("rows", []):
for cell in row:
cm = cell.get("cellMargin")
if cm:
return cm
return None
def _mm(val) -> float:
if isinstance(val, (int, float)):
return float(val)
try:
return float(str(val).replace("mm", "").strip())
except (ValueError, TypeError):
return 20.0