Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
150 changes: 150 additions & 0 deletions app/contest_cardnews/copy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
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() if isinstance(i, dict) else str(i).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)
normalized = [normalize_contest_slide_copy(s) for s in rows]
return [s for s in normalized 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)]
Comment on lines +73 to +78

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

prepare_contest_slides 함수 내부에서 이미 normalize_contest_slide_copy 호출 및 is_contest_slide_empty 필터링을 모두 수행하여 반환합니다. 따라서 render_contest_cardnews_slides에서 prepare_contest_slides를 호출한 이후에 다시 동일한 정규화와 필터링을 반복하는 것은 불필요한 중복 연산입니다. 이를 제거하여 코드를 단순화할 수 있습니다.

Suggested change
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)]
prepared = prepare_contest_slides(slides)
for row in prepared:
row["use_image"] = False
slides_to_render = apply_deck_palette(prepared, rng=rng, contentid=contentid)

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