Skip to content
Binary file not shown.
Binary file not shown.
4 changes: 4 additions & 0 deletions app/contest_cardnews/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from app.contest_cardnews.render import render_contest_cardnews_slides
from app.contest_cardnews.slides import parse_contest_cardnews_slides_json

__all__ = ["parse_contest_cardnews_slides_json", "render_contest_cardnews_slides"]
35 changes: 35 additions & 0 deletions app/contest_cardnews/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from __future__ import annotations

# 정책 카드뉴스와 동일 (app.policy_cardnews.constants)
CANVAS_WIDTH = 1080
CANVAS_HEIGHT = 1350

CARD_INSET = 28
CONTENT_PAD = 40
INNER_PAD_X = 20
INNER_PAD_Y = 12
CHROME_HEIGHT = 118

GAP_SM = 8
GAP_MD = 14
GAP_LG = 22
# 슬라이드 내 요소 간 여백
ELEMENT_GAP = 20
ROW_GAP = 22
TITLE_TOP_PAD = 28
CARD_INNER_PAD = 16
LABEL_STRIP_H = 58

INK = (28, 32, 40)
INK_SOFT = (72, 80, 96)
STAR_PEACH = (255, 190, 160)
NOTE_PINK = (255, 160, 190)

# 둥근미소는 동일 pt에서 작게 보여 전체 스케일 업
FONT_SCALE = 1.28

# 레이아웃 겹침 방지용 예약 높이
POINT_FOOTER_H = 56
MASCOT_ZONE_H = 300
MASCOT_GAP = 24
SPEECH_ICON_GAP = 22
146 changes: 146 additions & 0 deletions app/contest_cardnews/copy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
from __future__ import annotations

from typing import Any

from app.contest_cardnews.template.dispatch import (
LAYOUT_CHECKLIST,
LAYOUT_COVER,
LAYOUT_CTA,
LAYOUT_TABLE,
normalize_layout_type,
)
from app.policy_cardnews.copy import is_filler_text, polish_korean_text

_SPEECH_MAX = 14
_TARGET_SLIDES = 3
_MIDDLE_LAYOUTS = (LAYOUT_TABLE, LAYOUT_CHECKLIST)


def _content_score(slide: dict[str, Any]) -> int:
score = 0
for key in ("eyebrow", "headline", "highlight", "body", "cta", "point"):
score += len(str(slide.get(key) or "").strip())
for item in slide.get("items") or []:
if isinstance(item, dict):
score += len(str(item.get("label") or "")) + len(str(item.get("text") or ""))
return score


def _middle_layout_bonus(layout: str) -> int:
if layout == LAYOUT_TABLE:
return 40
if layout == LAYOUT_CHECKLIST:
return 30
return 0


def compact_contest_deck(slides: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""표지 + 요약 1장 + CTA = 3장. 중간은 table/checklist 우선."""
rows = [s for s in slides if isinstance(s, dict)]
if len(rows) <= _TARGET_SLIDES:
return _renumber_slides(rows)

cover = rows[0]
cta = rows[-1]
middles = rows[1:-1]

def rank(slide: dict[str, Any]) -> tuple[int, int]:
layout = normalize_layout_type(str(slide.get("layout_type") or ""))
prefer = 0 if layout in _MIDDLE_LAYOUTS else 1
return (prefer, -(_content_score(slide) + _middle_layout_bonus(layout)))

best_middle = min(middles, key=rank)
merged_layout = normalize_layout_type(str(best_middle.get("layout_type") or ""))
if merged_layout not in _MIDDLE_LAYOUTS:
if len(best_middle.get("items") or []) >= 3:
best_middle = {**best_middle, "layout_type": LAYOUT_CHECKLIST}
elif best_middle.get("items"):
best_middle = {**best_middle, "layout_type": LAYOUT_TABLE}

return _renumber_slides([cover, best_middle, cta])


def _renumber_slides(slides: list[dict[str, Any]]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for index, slide in enumerate(slides, start=1):
row = dict(slide)
row["slide"] = index
out.append(row)
return out


def normalize_contest_slide_copy(slide: dict[str, Any]) -> dict[str, Any]:
row = dict(slide)
layout = str(row.get("layout_type") or "").strip()

for key in ("eyebrow", "headline", "highlight", "subtext", "body", "cta", "speech", "point"):
if key in row:
row[key] = polish_korean_text(str(row.get(key) or ""))

simplified_items: list[dict[str, str]] = []
for item in row.get("items") or []:
if isinstance(item, dict):
simplified_items.append(
{
"label": polish_korean_text(str(item.get("label") or item.get("title") or "")),
"text": polish_korean_text(
str(item.get("text") or item.get("value") or item.get("content") or ""),
),
},
)
elif str(item).strip():
simplified_items.append({"label": "", "text": polish_korean_text(str(item))})
if simplified_items:
row["items"] = simplified_items[:5]

headline = row.get("headline", "")
highlight = row.get("highlight", "")
if highlight and headline and highlight == headline and "cover" in layout:
row["headline"] = ""

body = row.get("body", "")
if body and is_filler_text(body) and "cover" not in layout:
row["body"] = ""

if not row.get("speech"):
row["speech"] = _derive_contest_speech(row, layout=layout)

row["use_image"] = False
return row


def _derive_contest_speech(slide: dict[str, Any], *, layout: str) -> str:
explicit = str(slide.get("speech") or "").strip()
if explicit and len(explicit) <= _SPEECH_MAX:
return explicit
if "cta" in layout:
cta = str(slide.get("cta") or "").strip()
if cta and len(cta) <= _SPEECH_MAX:
return cta.rstrip(".!") + "!"
if "cover" in layout:
return "놓치지 마!"
return ""


def is_contest_slide_empty(slide: dict[str, Any]) -> bool:
layout = str(slide.get("layout_type") or "")
if "cover" in layout:
return not bool(
str(slide.get("headline") or "").strip()
or str(slide.get("highlight") or "").strip()
or str(slide.get("body") or "").strip()
or str(slide.get("eyebrow") or "").strip()
)
if "cta" in layout:
return not (
bool(str(slide.get("cta") or "").strip())
or bool(str(slide.get("headline") or "").strip())
)
if slide.get("items"):
return len([i for i in slide["items"] if str(i.get("text") or "").strip()]) == 0
return not bool(str(slide.get("body") or slide.get("headline") or "").strip())


def prepare_contest_slides(slides: list[dict[str, Any]]) -> list[dict[str, Any]]:
rows = compact_contest_deck(slides)
return [normalize_contest_slide_copy(s) for s in rows if not is_contest_slide_empty(s)]
118 changes: 118 additions & 0 deletions app/contest_cardnews/render.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
from __future__ import annotations

import logging
import random
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from PIL import Image

from app.contest_cardnews.template import (
LAYOUT_COVER,
LAYOUT_CTA,
apply_deck_palette,
normalize_contest_slide,
render_contest_slide,
resolve_palette,
)
from app.contest_cardnews.copy import (
is_contest_slide_empty,
normalize_contest_slide_copy,
prepare_contest_slides,
)
from app.policy_cardnews.mascot import pick_mascot, pick_pin_mascot

logger = logging.getLogger(__name__)

_REPO_ROOT = Path(__file__).resolve().parents[2]


@dataclass
class ContestSlideRenderContext:
slide: dict[str, Any]
host_org: str
mascot: Image.Image | None
mascot_name: str
slide_total: int
source_url: str = ""


def _to_handoff_path(path: Path) -> str:
try:
return path.relative_to(_REPO_ROOT).as_posix()
except ValueError:
return path.as_posix()


def _render_slide_image(*, ctx: ContestSlideRenderContext) -> Image.Image:
slide_no = int(ctx.slide.get("slide") or 1)
slide = normalize_contest_slide(ctx.slide, index=slide_no, total=ctx.slide_total)
palette = resolve_palette(str(slide.get("template_palette") or "pastel_mint"))
return render_contest_slide(
slide,
palette=palette,
mascot=ctx.mascot,
source_url=ctx.source_url,
)


async def render_contest_cardnews_slides(
*,
contentid: str,
slides: list[dict[str, Any]],
output_dir: Path,
host_org: str = "",
source_url: str = "",
) -> list[str]:
"""공모전 전용 브라우저형 템플릿 + 캐릭터 PNG (정책 템플릿·크롤 이미지 미사용)."""
target_dir = output_dir / contentid
target_dir.mkdir(parents=True, exist_ok=True)

rng = random.Random(contentid)
prepared = prepare_contest_slides(slides)
for row in prepared:
row["use_image"] = False
prepared = apply_deck_palette(prepared, rng=rng, contentid=contentid)
prepared = [normalize_contest_slide_copy(s) for s in prepared]
slides_to_render = [s for s in prepared if not is_contest_slide_empty(s)]
if not slides_to_render:
raise ValueError("렌더링할 카드뉴스 슬라이드가 없습니다 (내용 부족)")

slide_total = len(slides_to_render)
saved_paths: list[str] = []

for index, slide in enumerate(slides_to_render, start=1):
slide = dict(slide)
slide["slide"] = index
slide["use_image"] = False
layout = str(slide.get("layout_type") or LAYOUT_COVER)
is_cover = layout == LAYOUT_COVER or index == 1
is_cta = index == slide_total or layout == LAYOUT_CTA

mascot_name = ""
mascot: Image.Image | None = None
if is_cover:
mascot_pick = pick_mascot(rng)
if mascot_pick:
mascot_name, mascot = mascot_pick[0], mascot_pick[1]
elif is_cta:
mascot_pick = pick_pin_mascot(rng)
if mascot_pick:
mascot_name, mascot = mascot_pick[0], mascot_pick[1]
ctx = ContestSlideRenderContext(
slide=slide,
host_org=host_org,
mascot=mascot,
mascot_name=mascot_name,
slide_total=slide_total,
source_url=source_url,
)
out_path = target_dir / f"slide_{index:02d}.png"
image = _render_slide_image(ctx=ctx)
image.save(out_path, format="PNG")
saved_paths.append(_to_handoff_path(out_path))
if mascot_name:
logger.info("공모전 카드뉴스 slide_%02d 캐릭터: %s", index, mascot_name)

return saved_paths
Loading
Loading