v8:문서유형 분석등록 및 추출_20260206
This commit is contained in:
824
handlers/style_generator.py
Normal file
824
handlers/style_generator.py
Normal 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
|
||||
Reference in New Issue
Block a user