Phase W + V' 완료: before→filled→after 파이프라인 + 조립 로직 수정
Phase W: - weight 비율 초기 배정 (space_allocator header 높이 반영) - block_assembler 공통 조립 함수 (filled/assembled 통합) - filled → Selenium 측정 → context 저장 - sidebar overflow 확장 + body 재배분 - sub_layouts 사전 계산 (이미지 누락 해결) Phase V': - 팝업 링크 우측상단 배치 (인라인 → position:absolute) - 표 내용 Kei 판단 (공란 크기 계산 → 행/열 산출 → Kei 요약) - 출처 라벨 삭제 + 이미지 아래 캡션 배치 - after 공란 제거 (결론 바로 위까지 body/sidebar 채움) 추가: - V-10 bold 키워드: 기계적 추출 → Kei 문맥 판단 - ** 마크다운 → <strong> 변환 - [이미지:] 마커 제거 (bold 변환 전 처리) - grid-template-rows AFTER 크기 반영 (Sonnet final) - assemble_stage2 CSS font-size override, white-space fix - 하드코딩 전수 검토 완료 - 본심 여러 topic 텍스트 합침 Phase X 계획 문서 작성 (동적 역할 구조) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,14 +21,36 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
# height_cost → px 범위 매핑
|
||||
# height_cost → px 범위 매핑: catalog.yaml의 블록들에서 동적 구축
|
||||
# ──────────────────────────────────────
|
||||
HEIGHT_COST_PX_RANGE = {
|
||||
"compact": (30, 80),
|
||||
"medium": (80, 200),
|
||||
"large": (200, 350),
|
||||
"xlarge": (350, 500),
|
||||
}
|
||||
_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}
|
||||
|
||||
@@ -44,6 +66,229 @@ ROLE_ZONE_MAP = {
|
||||
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
|
||||
|
||||
|
||||
# ──────────────────────────────────────
|
||||
@@ -101,28 +346,60 @@ def calculate_container_specs(
|
||||
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 = zone_info.get("budget_px", 490)
|
||||
zone_width_pct = zone_info.get("width_pct", 100)
|
||||
zone_width_px = int(slide_width * zone_width_pct / 100 * 0.85) # 패딩 제외
|
||||
# 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 안의 역할별 비중 비율 계산
|
||||
total_weight = sum(info.get("weight", 0.25) for _, info in role_list)
|
||||
# Kei가 weight를 반드시 제공해야 함 (없으면 균등 배분)
|
||||
total_weight = sum(info.get("weight", 0) for _, info in role_list)
|
||||
if total_weight <= 0:
|
||||
total_weight = 1.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", 0.25)
|
||||
weight = role_info.get("weight", 1)
|
||||
topic_ids = role_info.get("topic_ids", [])
|
||||
|
||||
# 비중 비율로 높이 할당
|
||||
ratio = weight / total_weight
|
||||
height_px = max(50, int(available * ratio))
|
||||
height_px = max(min_block_h, int(available * ratio))
|
||||
|
||||
# 블록 내부 제약 계산 — topic당 높이로 판단
|
||||
topic_count = max(1, len(topic_ids))
|
||||
@@ -157,27 +434,43 @@ def calculate_container_specs(
|
||||
|
||||
|
||||
def _max_allowed_height_cost(container_height_px: int) -> str:
|
||||
"""컨테이너 높이에서 허용되는 최대 height_cost."""
|
||||
if container_height_px >= 350:
|
||||
return "xlarge"
|
||||
elif container_height_px >= 200:
|
||||
return "large"
|
||||
elif container_height_px >= 80:
|
||||
return "medium"
|
||||
else:
|
||||
return "compact"
|
||||
"""컨테이너 높이에서 허용되는 최대 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]:
|
||||
"""컨테이너 높이에 따른 폰트/패딩/줄간격 결정."""
|
||||
if per_block_height_px >= 300:
|
||||
return (15.2, 20, 1.7)
|
||||
elif per_block_height_px >= 150:
|
||||
return (14.0, 14, 1.6)
|
||||
elif per_block_height_px >= 80:
|
||||
return (13.0, 10, 1.5)
|
||||
"""컨테이너 높이에 따른 폰트/패딩/줄간격 결정.
|
||||
|
||||
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, 8, 1.4)
|
||||
return (12.0, tokens["spacing_small"], 1.4) # font 12, padding=--spacing-small, lh 1.4
|
||||
|
||||
|
||||
def _calculate_block_constraints(
|
||||
@@ -188,11 +481,17 @@ def _calculate_block_constraints(
|
||||
line_height: float,
|
||||
padding_px: int,
|
||||
) -> dict:
|
||||
"""컨테이너 크기에서 블록 내부 제약을 수학적으로 계산."""
|
||||
per_topic_height = max(30, (height_px - padding_px * 2) // topic_count)
|
||||
"""컨테이너 크기에서 블록 내부 제약을 수학적으로 계산.
|
||||
|
||||
모든 수치는 입력 파라미터(이전 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 / line_height_px))
|
||||
chars_per_line = max(5, int((width_px - padding_px * 2) / (font_size_px * 0.95)))
|
||||
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
|
||||
|
||||
@@ -200,8 +499,8 @@ def _calculate_block_constraints(
|
||||
"max_lines": max_lines,
|
||||
"max_items": max_items,
|
||||
"chars_per_line": chars_per_line,
|
||||
"max_chars_total": max(20, max_chars_total),
|
||||
"max_chars_per_item": max(20, max_chars_total // max(1, max_items)),
|
||||
"max_chars_total": max(1, max_chars_total),
|
||||
"max_chars_per_item": max(1, max_chars_total // max(1, max_items)),
|
||||
}
|
||||
|
||||
|
||||
@@ -249,7 +548,9 @@ def finalize_block_specs(
|
||||
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))
|
||||
per_block_height = max(40, spec.height_px // sibling_count)
|
||||
# 최소 높이: 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)
|
||||
@@ -318,31 +619,19 @@ def calculate_trim_chars(
|
||||
# Phase Q-3: 글자수 예산 계산
|
||||
# ──────────────────────────────────────
|
||||
|
||||
# 블록 유형별 구조적 오버헤드 (제목, 패딩, 간격 등 — px 단위)
|
||||
# Phase Q 2차 테스트 기반 실측 보정: 실제 CSS padding/margin 기반
|
||||
_BLOCK_STRUCTURAL_OVERHEAD: dict[str, int] = {
|
||||
"card-numbered": 40, # 패딩 12*2=24 + gap 10 + border 2 + 여유
|
||||
"card-icon-desc": 50, # 아이콘 40 + 패딩 + gap
|
||||
"card-step-vertical": 50, # 마커 30 + 패딩 + gap
|
||||
"dark-bullet-list": 52, # 패딩 20*2=40 + 제목 줄 12
|
||||
"comparison-2col": 60, # 헤더*2 + 구분선 + 패딩
|
||||
"compare-3col-badge": 60, # 헤더 행 40 + 배지 + 패딩
|
||||
"compare-2col-split": 60, # 헤더 행 40 + 패딩
|
||||
"table-simple-striped": 50, # 헤더 행 35 + 패딩
|
||||
"banner-gradient": 36, # 패딩 16*2=32 + 여유
|
||||
"callout-solution": 50, # 아이콘 + 제목 30 + 패딩 20
|
||||
"callout-warning": 50, # 아이콘 + 제목 30 + 패딩 20
|
||||
"quote-big-mark": 50, # 따옴표 장식 + 패딩 20*2
|
||||
"quote-question": 76, # 패딩 28*2=56 + desc margin 10 + 여유 10 (실측 기반)
|
||||
"compare-pill-pair": 52, # 외곽 패딩 6*2 + 내부 패딩 18*2 + 여유
|
||||
"venn-diagram": 60, # SVG 구조 + 패딩
|
||||
"process-horizontal": 50, # 화살표 + 번호 36 + 패딩
|
||||
"flow-arrow-horizontal": 30, # 캡슐 + 화살표 + 패딩
|
||||
"keyword-circle-row": 60, # 원형 + 라벨 + 패딩
|
||||
}
|
||||
# 블록 유형별 구조적 오버헤드 — 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 (px)
|
||||
_CONTAINER_BLOCK_GAP = 8
|
||||
# 같은 컨테이너 내 블록 간 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(
|
||||
@@ -371,15 +660,16 @@ def calculate_char_budget(
|
||||
topic_count = max(1, len(container_spec.topic_ids))
|
||||
|
||||
# 같은 컨테이너 내 블록 간 gap 차감
|
||||
total_gap = _CONTAINER_BLOCK_GAP * max(0, topic_count - 1)
|
||||
available_container_height = max(40, container_spec.height_px - total_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 = _BLOCK_STRUCTURAL_OVERHEAD.get(block_type, 20)
|
||||
structural = _get_block_overhead(block_type)
|
||||
content_height = max(10, per_topic_px - structural)
|
||||
|
||||
# 줄 수 계산
|
||||
@@ -387,7 +677,10 @@ def calculate_char_budget(
|
||||
available_lines = max(1, int(content_height / line_height_px))
|
||||
|
||||
# 한국어 줄당 글자수 (폰트 크기 기반)
|
||||
usable_width = container_spec.width_px * 0.85 # 패딩 제외
|
||||
# 패딩 제외: 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))
|
||||
|
||||
# 항목 수 제한 (블록 정의 참조)
|
||||
|
||||
Reference in New Issue
Block a user