"""Phase O + Phase Q: 컨테이너 기반 공간 할당 + 글자수 예산 + 글루 모델. Kei 비중 → 컨테이너 px 확정 → 블록 제약 계산 → 편집자 스펙 생성. LLM 추정이 아닌 결정론적 계산. 주요 함수: - calculate_container_specs(): Kei 비중 → 역할별 ContainerSpec (Phase O) - finalize_block_specs(): 컨테이너 크기 → 블록별 내부 스펙 (Phase O) - calculate_char_budget(): 블록+컨테이너 → 글자수 예산 사전 계산 (Phase Q-3) - apply_glue_compression(): overflow 시 수학적 간격 축소 (Phase Q-7) - calculate_trim_chars(): 초과 px → 삭제 글자 수 """ from __future__ import annotations import math import logging from dataclasses import dataclass, field from typing import Any logger = logging.getLogger(__name__) # ────────────────────────────────────── # height_cost → px 범위 매핑: catalog.yaml의 블록들에서 동적 구축 # ────────────────────────────────────── _height_cost_cache: dict[str, tuple[int, int]] | None = None def _get_height_cost_px_range() -> dict[str, tuple[int, int]]: """catalog.yaml의 블록 min_height_px에서 height_cost별 범위를 동적 계산.""" global _height_cost_cache if _height_cost_cache is not None: return _height_cost_cache from src.block_reference import _load_catalog # height_cost별로 min_height_px 수집 cost_heights: dict[str, list[int]] = {} for b in _load_catalog(): cost = b.get("height_cost", "medium") h = b.get("min_height_px", 0) if cost not in cost_heights: cost_heights[cost] = [] cost_heights[cost].append(h) # 각 cost의 (min, max) 범위 계산 result = {} for cost, heights in cost_heights.items(): if heights: result[cost] = (min(heights), max(heights)) else: result[cost] = (0, 0) _height_cost_cache = result return result HEIGHT_COST_ORDER = {"compact": 0, "medium": 1, "large": 2, "xlarge": 3} # 역할별 zone 매핑 (기본) ROLE_ZONE_MAP = { "본심": "body", "배경": "body", "첨부": "sidebar", "결론": "footer", } # 폰트 설정 기본값 DEFAULT_FONT_SIZE_PX = 15.2 DEFAULT_LINE_HEIGHT = 1.7 DEFAULT_AVG_CHAR_WIDTH_PX = 14.4 # fonttools 실측 기반 (Pretendard 한글) CHAR_WIDTH_RATIO = 0.947 # Pretendard 한글 실측: char_width = font_size × 0.947 # ────────────────────────────────────── # Phase T-5: 폰트 위계 + 동적 비율 역산 # ────────────────────────────────────── # 역할별 폰트 위계 범위 (min, max) # 핵심 원칙: font_size(핵심) > font_size(본심) >= font_size(배경) > font_size(첨부) FONT_HIERARCHY_RANGE: dict[str, tuple[float, float]] = { "핵심": (14.0, 14.0), # 고정 14px bold "본심": (12.0, 12.0), # 고정 12px "배경": (10.0, 12.0), # 텍스트 양에 따라 조정 "첨부": (9.0, 11.0), # 텍스트 양에 따라 조정 "결론": (12.0, 14.0), # footer 배너용 } # 역할별 줄 높이 비율 ROLE_LINE_HEIGHT: dict[str, float] = { "핵심": 1.4, "본심": 1.5, "배경": 1.4, "첨부": 1.4, "결론": 1.3, } def _estimate_required_height( text_chars: int, font_size: float, available_width: int, line_height_ratio: float = 1.5, padding: int | None = None, ) -> int: """주어진 폰트 크기로 텍스트를 넣으려면 몇 px 필요한가.""" from src.fit_verifier import _load_design_tokens tokens = _load_design_tokens() if padding is None: padding = tokens["spacing_block"] if text_chars <= 0: return padding * 2 char_width = font_size * CHAR_WIDTH_RATIO inner_width = max(tokens["spacing_page"], available_width - padding * 2) chars_per_line = max(1, int(inner_width / char_width)) total_lines = max(1, -(-text_chars // chars_per_line)) # ceil division line_height_px = font_size * line_height_ratio return int(total_lines * line_height_px) + padding * 2 def calculate_font_hierarchy( role_text_lengths: dict[str, int], available_width: int | None = None, ) -> dict[str, float]: """역할별 폰트 크기를 위계 범위 내에서 텍스트 양 기반으로 확정. Phase T 핵심: 위계가 먼저, 컨테이너가 따라간다. Args: role_text_lengths: {"본심": 500, "배경": 200, "첨부": 300, "결론": 50} available_width: 예상 가용 너비 (px) Returns: {"핵심": 14.0, "본심": 12.0, "배경": 11.0, "첨부": 10.0, "결론": 13.0} """ if available_width is None: from src.config import settings from src.fit_verifier import _load_design_tokens tokens = _load_design_tokens() available_width = settings.slide_width - tokens["spacing_page"] * 2 result = {} for role, (font_min, font_max) in FONT_HIERARCHY_RANGE.items(): text_len = role_text_lengths.get(role, 0) if font_min == font_max: # 고정 폰트 (핵심, 본심) result[role] = font_max continue # 텍스트 양이 많으면 폰트 축소 (범위 내) # max 폰트로 시도 → 안 되면 1px씩 축소 chosen = font_max for fs in [font_max, font_max - 1, font_min]: fs = max(font_min, fs) required_h = _estimate_required_height( text_len, fs, available_width, ROLE_LINE_HEIGHT.get(role, 1.5) ) # 합리적 범위(xlarge 최대 높이 이내)면 이 폰트 사용 ranges = _get_height_cost_px_range() max_reasonable_h = ranges.get("xlarge", (0, 0))[1] if ranges.get("xlarge") else required_h if required_h <= max_reasonable_h: chosen = fs break chosen = fs # 최소 폰트라도 사용 result[role] = chosen # 위계 강제: 핵심 > 본심 >= 배경 > 첨부 if result.get("배경", 11) > result.get("본심", 12): result["배경"] = result["본심"] if result.get("첨부", 10) >= result.get("배경", 11): result["첨부"] = max(FONT_HIERARCHY_RANGE["첨부"][0], result["배경"] - 1) return result def calculate_dynamic_ratio( role_text_lengths: dict[str, int], font_hierarchy: dict[str, float], slide_width: int = 1280, slide_height: int = 720, preset: dict[str, Any] | None = None, ) -> tuple[int, int]: """sidebar 텍스트 양에서 body:sidebar 비율 역산. 고정 65:35가 아니라 텍스트 양 기반. Returns: (body_pct, sidebar_pct) 예: (70, 30) or (65, 35) """ # 프리셋에서 기본 비율 가져오기 preset_body_pct = 0 preset_sidebar_pct = 0 if preset: zones = preset.get("zones", {}) for zone_name, zone_info in zones.items(): if zone_name == "body": preset_body_pct = zone_info.get("width_pct", 0) elif zone_name == "sidebar": preset_sidebar_pct = zone_info.get("width_pct", 0) sidebar_text = role_text_lengths.get("첨부", 0) body_text = sum(v for k, v in role_text_lengths.items() if k != "첨부" and k != "결론") total_text = body_text + sidebar_text if total_text <= 0 or sidebar_text <= 0: # sidebar 텍스트 없으면 프리셋의 기본 비율 사용 if preset_body_pct > 0 and preset_sidebar_pct > 0: return (preset_body_pct, preset_sidebar_pct) return (100, 0) # 텍스트 비율에서 순수 계산 sidebar_ratio = sidebar_text / total_text sidebar_pct = max(1, int(sidebar_ratio * 100)) body_pct = 100 - sidebar_pct return (body_pct, sidebar_pct) def calculate_design_budget( container_height_px: int, container_width_px: int, block_schema: dict, font_size: float, padding: int | None = None, ) -> dict: """블록 schema 기반 디자인 요소 크기 역산. 텍스트 영역 확보 후 남은 공간 = 디자인 요소 예산. 텍스트를 줄이는 것이 아니라 도형/이미지/CSS 요소의 크기를 맞추는 방향. Args: container_height_px: 컨테이너 높이 container_width_px: 컨테이너 너비 block_schema: catalog.yaml의 해당 블록 schema font_size: 이 역할의 확정된 폰트 크기 (T-5에서 결정) padding: 컨테이너 내부 패딩 Returns: { "text_height_px": int, # 텍스트가 차지하는 높이 "available_height_px": int, # 디자인 요소 가용 높이 "available_width_px": int, # 디자인 요소 가용 너비 "max_circle_diameter": int, # 원형 요소 최대 지름 "max_img_width": int, # 이미지 최대 너비 "max_img_height": int, # 이미지 최대 높이 "fits": bool, # 디자인 요소가 들어가는지 } """ from src.fit_verifier import _load_design_tokens tokens = _load_design_tokens() if padding is None: padding = tokens["spacing_block"] # 블록 schema에서 텍스트 슬롯별 높이 합산 text_height = 0 for slot_name, spec in block_schema.items(): if slot_name.startswith("max_"): continue if not isinstance(spec, dict): continue slot_lines = spec.get("max_lines", 1) slot_font = spec.get("font_size", font_size) # line-height는 typography constant text_height += int(slot_lines * (slot_font * 1.6)) remaining_height = container_height_px - text_height - padding * 2 remaining_width = container_width_px - padding * 2 border_w = tokens.get("border_width", tokens.get("accent_border", 1)) return { "text_height_px": text_height, "available_height_px": max(0, remaining_height), "available_width_px": max(0, remaining_width), "max_circle_diameter": max(0, min(remaining_height, remaining_width) - border_w * 2), "max_img_width": max(0, remaining_width), "max_img_height": max(0, remaining_height), "fits": remaining_height >= 0, } def _estimate_capacity(width_px: int, font_size: float, height_px: int) -> int: """주어진 공간에서 수용 가능한 총 글자 수.""" from src.fit_verifier import _load_design_tokens tokens = _load_design_tokens() padding = tokens["spacing_block"] char_width = font_size * CHAR_WIDTH_RATIO inner_width = max(1, width_px - padding * 2) chars_per_line = max(1, int(inner_width / char_width)) inner_height = max(1, height_px - padding * 2) line_height_px = font_size * 1.4 # line-height (typography) max_lines = max(1, int(inner_height / line_height_px)) return chars_per_line * max_lines # ────────────────────────────────────── # ContainerSpec 데이터 클래스 # ────────────────────────────────────── @dataclass class ContainerSpec: """역할별 컨테이너 스펙.""" role: str # "본심", "배경", "첨부", "결론" zone: str # "body", "sidebar", "footer" topic_ids: list[int] # 이 컨테이너에 속하는 topic ID들 weight: float # Kei가 판단한 비중 (0.0~1.0) height_px: int # 컨테이너 높이 (px) width_px: int # 컨테이너 너비 (px) max_height_cost: str # 허용 최대 height_cost ("compact"/"medium"/"large"/"xlarge") block_constraints: dict = field(default_factory=dict) # 블록 내부 제약 # ────────────────────────────────────── # O-1: 컨테이너 스펙 계산 # ────────────────────────────────────── def calculate_container_specs( page_structure: dict[str, Any], topics: list[dict[str, Any]], preset: dict[str, Any], slide_width: int = 1280, slide_height: int = 720, gap_px: int = 20, ) -> dict[str, ContainerSpec]: """Kei 비중 → 역할별 ContainerSpec 계산. 결정론적. AI 호출 없음. Args: page_structure: Kei 판단 {"본심": {"topic_ids": [3], "weight": 0.6}, ...} topics: 각 topic의 purpose, role, layer preset: 프리셋 zone 정보 (budget_px, width_pct) slide_width: 슬라이드 너비 (px) slide_height: 슬라이드 높이 (px) gap_px: 컨테이너 간 간격 (px) Returns: {"본심": ContainerSpec(...), "배경": ContainerSpec(...), ...} """ zones = preset.get("zones", {}) specs: dict[str, ContainerSpec] = {} # zone별로 해당 역할들의 비중 합산 zone_roles: dict[str, list[tuple[str, dict]]] = {} # zone → [(role, info), ...] for role_name, role_info in page_structure.items(): if not isinstance(role_info, dict): continue zone = ROLE_ZONE_MAP.get(role_name, "body") if zone not in zone_roles: zone_roles[zone] = [] zone_roles[zone].append((role_name, role_info)) # tokens.css에서 spacing 읽기 (하드코딩 방지) from src.fit_verifier import _load_design_tokens tokens = _load_design_tokens() slide_padding = tokens["spacing_page"] # --spacing-page for zone_name, role_list in zone_roles.items(): zone_info = zones.get(zone_name, {}) # zone budget: weight 비율로 전체 가용 공간 배정 # weight는 초기 배정 비율. before→filled→after에서 조정됨. header_height = tokens.get("header_height", 66) total_available = slide_height - slide_padding * 2 - header_height - gap_px * 2 zone_weight_sum = sum(info.get("weight", 0) for _, info in role_list) all_weight_sum = sum( info.get("weight", 0) for roles in zone_roles.values() for _, info in roles ) if all_weight_sum > 0 and zone_weight_sum > 0: zone_budget = int(total_available * zone_weight_sum / all_weight_sum) else: # fallback: 프리셋 또는 동적 계산 zone_budget = zone_info.get("budget_px") or total_available zone_width_pct = zone_info.get("width_pct", 0) # 패딩 제외 폭: 슬라이드 폭 - 좌우 패딩 slide_inner_width = slide_width - slide_padding * 2 zone_width_px = int(slide_inner_width * zone_width_pct / 100) if zone_width_pct > 0 else slide_inner_width # 이 zone 안의 역할별 비중 비율 계산 # Kei가 weight를 반드시 제공해야 함 (없으면 균등 배분) total_weight = sum(info.get("weight", 0) for _, info in role_list) if total_weight <= 0: # weight가 없으면 균등 배분 total_weight = len(role_list) for _, info in role_list: info.setdefault("weight", 1) # 간격 제외 total_gap = gap_px * max(0, len(role_list) - 1) available = zone_budget - total_gap # 최소 높이: catalog에서 가장 작은 블록의 min_height_px from src.block_reference import _load_catalog min_block_h = min( (b.get("min_height_px", 0) for b in _load_catalog() if b.get("min_height_px", 0) > 0), default=1, ) for role_name, role_info in role_list: weight = role_info.get("weight", 1) topic_ids = role_info.get("topic_ids", []) # 비중 비율로 높이 할당 ratio = weight / total_weight height_px = max(min_block_h, int(available * ratio)) # footer는 최소 높이 보장 (font_size * line_height + padding) if zone_name == "footer": from src.fit_verifier import _load_design_tokens as _ldt_footer _ft = _ldt_footer() _footer_min = int(14 * _ft.get("line_height_ko", 1.7) + _ft["spacing_page"]) height_px = max(_footer_min, height_px) # 블록 내부 제약 계산 — topic당 높이로 판단 topic_count = max(1, len(topic_ids)) per_topic_px = height_px // topic_count # height_cost 허용 범위: topic당 높이 기준 (컨테이너 전체가 아님) max_cost = _max_allowed_height_cost(per_topic_px) font_size, padding, line_h = _determine_typography(height_px // topic_count) constraints = _calculate_block_constraints( height_px, zone_width_px, topic_count, font_size, line_h, padding ) constraints["font_size_px"] = font_size constraints["padding_px"] = padding constraints["line_height"] = line_h specs[role_name] = ContainerSpec( role=role_name, zone=zone_name, topic_ids=topic_ids, weight=weight, height_px=height_px, width_px=zone_width_px, max_height_cost=max_cost, block_constraints=constraints, ) logger.info( f"[O-1] 컨테이너 스펙: " + ", ".join(f"{r}={s.height_px}px({s.max_height_cost})" for r, s in specs.items()) ) return specs # ══════════════════════════════════════ # Phase X-B: 유형 B 컨테이너 생성 # ══════════════════════════════════════ def build_containers_type_b( page_structure: dict[str, Any], slide_width: int = 1280, slide_height: int = 720, image_sizes: list[dict] | None = None, ) -> dict[str, ContainerSpec]: """유형 B: 상단(top) + 하단 2분할(bottom_left/right) + 결론(footer). 기존 유형 A(calculate_container_specs)를 건드리지 않는 별도 함수. 모든 크기는 슬라이드 크기 + weight + zone에서 동적 계산. 하드코딩 없음. Args: page_structure: Kei 판단 {"핵심목표": {"zone": "top", "topic_ids": [1], "weight": 0.45}, ...} slide_width: 슬라이드 너비 slide_height: 슬라이드 높이 image_sizes: 이미지 정보 (비율 계산용) """ from src.fit_verifier import _load_design_tokens tokens = _load_design_tokens() pad = tokens["spacing_page"] header_h = tokens.get("header_height", 66) gap_block = tokens["spacing_block"] gap_small = tokens["spacing_small"] inner_w = slide_width - pad * 2 # 역할을 zone별로 분류 top_roles = [] # zone=top bottom_roles = [] # zone=bottom (전체폭) 또는 bottom_left/bottom_right (2분할) footer_role = None # zone=footer (Phase Y: 결론은 slide-base가 처리, 여기서 무시) for role_name, info in page_structure.items(): if not isinstance(info, dict): continue zone = info.get("zone", "") if zone == "top": top_roles.append((role_name, info)) elif zone in ("bottom", "bottom_left", "bottom_right"): bottom_roles.append((role_name, info)) elif zone == "footer": footer_role = (role_name, info) # Phase Y: slide-base.html 기준으로 가용 높이 계산 # slide-base: .slide-body = top:65px, height:590px # 하단 footer pill = 41px (slide-base가 관리, 여기서 빼지 않음) slide_body_top = 65 # slide-base .slide-body top slide_body_h = 590 # slide-base .slide-body height total_available = slide_body_h # footer zone이 있으면 기존 방식으로 공간 배분 (하위 호환) # footer zone이 없으면 (Phase Y) slide-base footer가 처리 → 전체를 zone에 사용 if footer_role: footer_weight = footer_role[1].get("weight", 0.1) footer_h_raw = int(total_available * footer_weight) _footer_min = int(14 * tokens.get("line_height_ko", 1.7) + pad) footer_h = max(_footer_min, footer_h_raw) middle_h = total_available - footer_h - gap_block else: footer_h = 0 middle_h = total_available # Phase Y: zone 제목 + gap 공간 확보 zone_count = len(top_roles) + len(bottom_roles) zone_title_h = 28 # zone 제목 높이 (assembler와 동일) zone_gap = 16 # zone 간 여백 (assembler와 동일) zone_overhead = zone_count * zone_title_h + max(0, zone_count - 1) * zone_gap usable_h = middle_h - zone_overhead # 상단/하단 높이: weight 비율로 (usable 영역에서) top_weight = sum(info.get("weight", 0) for _, info in top_roles) bottom_weight = sum(info.get("weight", 0) for _, info in bottom_roles) total_mid_weight = top_weight + bottom_weight if total_mid_weight <= 0: total_mid_weight = 1 top_h = int(usable_h * top_weight / total_mid_weight) bottom_h = usable_h - top_h # 상단: 이미지가 있으면 좌텍스트+우이미지 나란히 → 폭 분할 img_ratio = 0 if image_sizes: for img in image_sizes: r = img.get("ratio", 0) if r > 0: img_ratio = r break if img_ratio > 0: # 이미지 높이 = top_h, 이미지 폭 = top_h * ratio img_w = min(int(top_h * img_ratio), int(inner_w * 0.45)) # 최대 45% text_w = inner_w - img_w - gap_block else: text_w = inner_w img_w = 0 specs = {} # 상단 역할 for role_name, info in top_roles: specs[role_name] = ContainerSpec( role=role_name, zone="top", topic_ids=info.get("topic_ids", []), weight=info.get("weight", 0), height_px=top_h, width_px=text_w if img_w > 0 else inner_w, # 이미지 있으면 텍스트 폭만 max_height_cost=_max_allowed_height_cost(top_h), block_constraints={ "img_width_px": img_w, "img_height_px": top_h if img_w > 0 else 0, "has_image": img_w > 0, }, ) # 하단 역할: zone에 따라 전체폭 또는 2분할 has_bottom_full = any(info.get("zone") == "bottom" for _, info in bottom_roles) bottom_col_w = inner_w if has_bottom_full else (inner_w - gap_block) // 2 for role_name, info in bottom_roles: zone = info.get("zone", "bottom_left") w = inner_w if zone == "bottom" else bottom_col_w specs[role_name] = ContainerSpec( role=role_name, zone=zone, topic_ids=info.get("topic_ids", []), weight=info.get("weight", 0), height_px=bottom_h, width_px=w, max_height_cost=_max_allowed_height_cost(bottom_h), block_constraints={}, ) # 결론 if footer_role: rn, info = footer_role specs[rn] = ContainerSpec( role=rn, zone="footer", topic_ids=info.get("topic_ids", []), weight=info.get("weight", 0), height_px=footer_h, width_px=inner_w, max_height_cost="low", block_constraints={}, ) logger.info( f"[X-B-3] 유형 B 컨테이너: " + ", ".join(f"{r}={s.height_px}px(w={s.width_px})" for r, s in specs.items()) ) return specs def _max_allowed_height_cost(container_height_px: int) -> str: """컨테이너 높이에서 허용되는 최대 height_cost. catalog.yaml 블록들의 min_height_px 기반 동적 계산. """ ranges = _get_height_cost_px_range() # 높은 cost부터 확인: 컨테이너가 해당 cost의 최소 높이 이상이면 허용 for cost in ["xlarge", "large", "medium", "compact"]: if cost in ranges: min_h, _ = ranges[cost] if container_height_px >= min_h: return cost return "compact" def _determine_typography(per_block_height_px: int) -> tuple[float, int, float]: """컨테이너 높이에 따른 폰트/패딩/줄간격 결정. font-size와 line-height는 typography constant (허용). padding은 tokens.css의 spacing 값에서 가져옴. """ from src.fit_verifier import _load_design_tokens tokens = _load_design_tokens() # height_cost 범위에서 어떤 급인지 판단 ranges = _get_height_cost_px_range() xlarge_min = ranges.get("xlarge", (0, 0))[0] large_min = ranges.get("large", (0, 0))[0] medium_min = ranges.get("medium", (0, 0))[0] if per_block_height_px >= xlarge_min and xlarge_min > 0: return (15.2, tokens["spacing_block"], 1.7) # font 15.2, padding=--spacing-block, lh 1.7 elif per_block_height_px >= large_min and large_min > 0: return (14.0, tokens["spacing_inner"], 1.6) # font 14, padding=--spacing-inner, lh 1.6 elif per_block_height_px >= medium_min and medium_min > 0: return (13.0, tokens["spacing_small"], 1.5) # font 13, padding=--spacing-small, lh 1.5 else: return (12.0, tokens["spacing_small"], 1.4) # font 12, padding=--spacing-small, lh 1.4 def _calculate_block_constraints( height_px: int, width_px: int, topic_count: int, font_size_px: float, line_height: float, padding_px: int, ) -> dict: """컨테이너 크기에서 블록 내부 제약을 수학적으로 계산. 모든 수치는 입력 파라미터(이전 Stage 결과) + font metric에서 도출. """ per_topic_height = max(1, (height_px - padding_px * 2) // max(1, topic_count)) line_height_px = font_size_px * line_height max_lines = max(1, int(per_topic_height / max(1, line_height_px))) # chars_per_line: CHAR_WIDTH_RATIO(font metric)로 계산 char_width = font_size_px * CHAR_WIDTH_RATIO usable_width = max(1, width_px - padding_px * 2) chars_per_line = max(1, int(usable_width / max(1, char_width))) max_items = max(1, max_lines // 2) max_chars_total = max_lines * chars_per_line return { "max_lines": max_lines, "max_items": max_items, "chars_per_line": chars_per_line, "max_chars_total": max(1, max_chars_total), "max_chars_per_item": max(1, max_chars_total // max(1, max_items)), } # ────────────────────────────────────── # O-1 유틸: topic_id → ContainerSpec 매핑 # ────────────────────────────────────── def find_container_for_topic( topic_id: int | None, container_specs: dict[str, ContainerSpec], ) -> ContainerSpec | None: """topic_id로 해당 ContainerSpec을 찾는다.""" if topic_id is None: return None for spec in container_specs.values(): if topic_id in spec.topic_ids: return spec return None # ────────────────────────────────────── # O-3: 블록 스펙 확정 # ────────────────────────────────────── def finalize_block_specs( blocks: list[dict[str, Any]], container_specs: dict[str, ContainerSpec], catalog_map: dict[str, dict] | None = None, ) -> list[dict[str, Any]]: """각 블록의 내부 스펙을 컨테이너 크기에 맞게 확정한다. 결정론적. AI 호출 없음. 확정 필드: - _container_height_px, _container_width_px - _max_items, _max_chars_per_item, _max_chars_total - _font_size_px, _padding_px, _line_height """ for block in blocks: tid = block.get("topic_id") spec = find_container_for_topic(tid, container_specs) if not spec: continue # 같은 컨테이너 안의 블록 수 (높이 분배) siblings = [b for b in blocks if find_container_for_topic(b.get("topic_id"), container_specs) == spec and b.get("topic_id") is not None] sibling_count = max(1, len(siblings)) # 최소 높이: catalog에서 가장 작은 블록의 min_height_px _min_h = min((b.get("min_height_px", 1) for b in _load_catalog() if b.get("min_height_px", 0) > 0), default=1) per_block_height = max(_min_h, spec.height_px // sibling_count) # 폰트/패딩 결정 font_size, padding, line_h = _determine_typography(per_block_height) # 블록별 제약 계산 constraints = _calculate_block_constraints( per_block_height, spec.width_px, 1, font_size, line_h, padding ) # 블록 타입별 세부 조정 block_type = block.get("type", "") if block_type in ("dark-bullet-list",): block["_max_items"] = min(constraints["max_items"], 5) block["_max_chars_per_item"] = constraints["max_chars_per_item"] elif block_type in ("card-numbered", "card-icon-desc"): block["_max_items"] = constraints["max_items"] block["_max_chars_per_item"] = constraints["max_chars_per_item"] elif block_type in ("compare-2col-split", "compare-3col-badge", "table-simple-striped"): block["_max_items"] = constraints["max_items"] # 행 수 block["_max_chars_per_item"] = constraints["max_chars_per_item"] elif block_type in ("comparison-2col",): block["_max_chars_per_item"] = constraints["max_chars_total"] // 2 elif block_type in ("banner-gradient",): block["_max_chars_total"] = constraints["chars_per_line"] else: pass # 기본값 사용 # 공통 필드 block["_container_height_px"] = per_block_height block["_container_width_px"] = spec.width_px block["_max_chars_total"] = constraints["max_chars_total"] block["_font_size_px"] = font_size block["_padding_px"] = padding block["_line_height"] = line_h logger.info( f"[O-3] 블록 스펙 확정: " + ", ".join( f"t{b.get('topic_id')}={b.get('_container_height_px','?')}px" for b in blocks if b.get("topic_id") is not None ) ) return blocks # ────────────────────────────────────── # 기존 유틸 (Phase L 호환) # ────────────────────────────────────── def calculate_trim_chars( excess_px: int, container_width_px: int, font_size_px: float = DEFAULT_FONT_SIZE_PX, line_height: float = DEFAULT_LINE_HEIGHT, avg_char_width_px: float = DEFAULT_AVG_CHAR_WIDTH_PX, ) -> int: """초과 px에서 삭제할 글자 수를 계산한다.""" if excess_px <= 0: return 0 line_height_px = font_size_px * line_height lines_to_remove = math.ceil(excess_px / line_height_px) chars_per_line = int(container_width_px / avg_char_width_px) return max(lines_to_remove * chars_per_line, 10) # ────────────────────────────────────── # Phase Q-3: 글자수 예산 계산 # ────────────────────────────────────── # 블록 유형별 구조적 오버헤드 — catalog.yaml의 padding_overhead_px에서 읽음 def _get_block_overhead(block_type: str) -> int: """catalog.yaml에서 블록의 padding_overhead_px를 읽어옴.""" from src.block_reference import _load_catalog for b in _load_catalog(): if b["id"] == block_type: return b.get("padding_overhead_px", 0) return 0 # 같은 컨테이너 내 블록 간 gap — tokens.css에서 읽음 def _get_block_gap() -> int: from src.fit_verifier import _load_design_tokens return _load_design_tokens()["spacing_small"] def calculate_char_budget( block_type: str, container_spec: ContainerSpec, block_def: dict | None = None, ) -> dict: """블록이 컨테이너에서 수용 가능한 최대 글자수를 사전 계산한다. Phase Q 핵심: 이 예산이 AI 콘텐츠 생성의 하드 제약. Args: block_type: 블록 ID (예: "venn-diagram") container_spec: 이 topic이 속한 컨테이너 block_def: catalog.yaml의 블록 정의 (None이면 기본값 사용) Returns: { "total_chars": int, # 전체 글자수 예산 "max_items": int, # 최대 항목 수 "chars_per_item": int, # 항목당 최대 글자수 "font_size_px": float, # 적용 폰트 크기 "available_lines": int, # 가용 줄 수 } """ topic_count = max(1, len(container_spec.topic_ids)) # 같은 컨테이너 내 블록 간 gap 차감 total_gap = _get_block_gap() * max(0, topic_count - 1) _min_h2 = min((b.get("min_height_px", 1) for b in _load_catalog() if b.get("min_height_px", 0) > 0), default=1) available_container_height = max(_min_h2, container_spec.height_px - total_gap) per_topic_px = available_container_height // topic_count # 폰트 크기 결정 font_size, padding, line_h = _determine_typography(per_topic_px) # 구조적 오버헤드 structural = _get_block_overhead(block_type) content_height = max(10, per_topic_px - structural) # 줄 수 계산 line_height_px = font_size * line_h available_lines = max(1, int(content_height / line_height_px)) # 한국어 줄당 글자수 (폰트 크기 기반) # 패딩 제외: tokens.css의 spacing_page × 2가 아니라 블록 내부 padding # 블록 padding은 container_spec.block_constraints에 있을 수 있음 block_padding = container_spec.block_constraints.get("padding_px", 0) usable_width = container_spec.width_px - block_padding * 2 if block_padding else container_spec.width_px chars_per_line = max(5, int(usable_width / font_size)) # 항목 수 제한 (블록 정의 참조) max_items_by_space = max(1, available_lines // 2) # 항목당 최소 2줄 catalog_max = 10 catalog_min = 1 if block_def: catalog_max = block_def.get("max_items", 10) catalog_min = block_def.get("min_items", 1) max_items = min(max_items_by_space, catalog_max) max_items = max(max_items, catalog_min) total_chars = available_lines * chars_per_line chars_per_item = total_chars // max(1, max_items) budget = { "total_chars": max(20, total_chars), "max_items": max_items, "chars_per_item": max(10, chars_per_item), "font_size_px": font_size, "available_lines": available_lines, } logger.info( f"[Q-3] 예산: {block_type} → {budget['total_chars']}자, " f"{budget['max_items']}항목, {budget['font_size_px']}px" ) return budget def calculate_budgets_for_candidates( candidates: list[dict], container_spec: ContainerSpec, ) -> dict[str, dict]: """후보 블록 리스트의 각 블록에 대해 글자수 예산을 계산한다. Returns: {"block_id": {"total_chars": ..., "max_items": ..., ...}, ...} """ budgets = {} for block in candidates: block_id = block.get("id", "") budgets[block_id] = calculate_char_budget( block_id, container_spec, block_def=block ) return budgets # ────────────────────────────────────── # Phase Q-7: LaTeX 글루 모델 (overflow 수학적 조정) # ────────────────────────────────────── @dataclass class GlueSpec: """LaTeX 글루 모델: 유연한 간격 정의. natural: 기본 간격 (px) stretch: 늘어날 수 있는 양 (px) shrink: 줄어들 수 있는 양 (px) """ natural: float stretch: float shrink: float # 간격 유형별 글루 설정 SPACING_GLUE: dict[str, GlueSpec] = { "block_gap": GlueSpec(natural=20, stretch=4, shrink=12), "inner_gap": GlueSpec(natural=16, stretch=4, shrink=8), "title_gap": GlueSpec(natural=8, stretch=2, shrink=4), "container_padding": GlueSpec(natural=16, stretch=0, shrink=8), } # 폰트 크기 축소 단계 (이진 탐색용) FONT_SIZE_STEPS = [15.2, 14.0, 13.0, 12.0, 11.0, 10.0, 9.0, 8.0] def calculate_glue_absorption(block_count: int) -> float: """글루 모델로 흡수 가능한 최대 px를 계산한다. Args: block_count: 컨테이너 내 블록 수 Returns: 흡수 가능한 총 shrink px """ total_shrink = 0.0 # 블록 간 간격 total_shrink += SPACING_GLUE["block_gap"].shrink * max(0, block_count - 1) # 각 블록 내부 간격 total_shrink += SPACING_GLUE["inner_gap"].shrink * block_count # 제목 간격 total_shrink += SPACING_GLUE["title_gap"].shrink * block_count # 컨테이너 패딩 (상하) total_shrink += SPACING_GLUE["container_padding"].shrink * 2 return total_shrink def compute_glue_css_overrides( excess_px: float, block_count: int, ) -> dict[str, str]: """overflow excess를 흡수하기 위한 CSS 변수 오버라이드를 계산한다. Returns: {"--spacing-block": "8px", "--spacing-inner": "8px", ...} 또는 None (글루만으로 흡수 불가) """ max_absorption = calculate_glue_absorption(block_count) if excess_px <= 0: return {} if excess_px > max_absorption: # 글루만으로 부족 — 부분 축소 적용 후 나머지는 폰트 축소 필요 ratio = 1.0 else: ratio = excess_px / max_absorption overrides = {} # 비율에 따라 각 간격 축소 block_gap = SPACING_GLUE["block_gap"] new_block_gap = block_gap.natural - block_gap.shrink * ratio overrides["--spacing-block"] = f"{new_block_gap:.0f}px" inner_gap = SPACING_GLUE["inner_gap"] new_inner_gap = inner_gap.natural - inner_gap.shrink * ratio overrides["--spacing-inner"] = f"{new_inner_gap:.0f}px" padding = SPACING_GLUE["container_padding"] new_padding = padding.natural - padding.shrink * ratio overrides["--container-padding"] = f"{new_padding:.0f}px" logger.info( f"[Q-7] 글루 압축: excess={excess_px:.0f}px, " f"absorption={max_absorption:.0f}px, ratio={ratio:.2f}" ) return overrides def find_fitting_font_size( current_font_px: float, excess_after_glue_px: float, available_lines: int, chars_per_line: int, ) -> float | None: """글루 압축 후에도 남은 overflow를 폰트 축소로 해결할 수 있는지 확인. Returns: 적합한 폰트 크기 (px) 또는 None (불가능) """ for font_size in FONT_SIZE_STEPS: if font_size >= current_font_px: continue # 현재보다 같거나 큰 크기는 스킵 # 이 폰트에서의 줄 높이 line_height_px = font_size * 1.6 # 한국어 기본 height_saved = (current_font_px * 1.6 - line_height_px) * available_lines if height_saved >= excess_after_glue_px: logger.info( f"[Q-7] 폰트 축소: {current_font_px}px → {font_size}px " f"({height_saved:.0f}px 확보)" ) return font_size return None # 8px에서도 안 맞으면 AI 텍스트 압축 필요