Files
C.E.L_Slide_test2/src/space_allocator.py

1013 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 텍스트 압축 필요