- V'-2: 표 공간 계산에 V'-4(결론 위까지 채움) 높이 반영 → Kei에게 정확한 행 수 전달 (1행 → 5행) - V'-2: 이미지 높이를 실제 비율로 계산 (sub_layout 고정값 대신) → 200/2.73 = 73px (기존 172px → 공간 100px 확보) - footer 최소 높이: design tokens 기반 동적 계산 → weight 0.05일 때 26px → 53px 보장 - assemble_stage2: 이미지 높이도 실제 비율 반영 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
858 lines
33 KiB
Python
858 lines
33 KiB
Python
"""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
|
||
|
||
|
||
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 텍스트 압축 필요
|