# -*- 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에서 등으로 참조. """ 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에서

등으로 참조. """ 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