824 lines
27 KiB
Python
824 lines
27 KiB
Python
# -*- 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 |