"""스타일링 ③ — 생성된 포스터 하단에 정보 밴드를 그린다 큰 제목은 생성 모델이 대체로 맞추지만 작은 글자에서 자모를 흘린다 (동대문구 → 몽대문구, 체험존 → 채헐존). 프롬프트로는 더 못 밀어낸다. 그래서 날짜·시간·장소는 모델에 맡기지 않고 실제 폰트로 직접 그린다. 밴드 색은 이미지 하단에서 뽑아 얹은 티를 줄인다. """ from PIL import Image, ImageDraw, ImageFilter, ImageFont from answers.poster_text_answer import PosterTextAnswer from utils.font import font PAD_RATIO = 0.055 BAND_RATIO_WITH_SLOGAN = 0.165 BAND_RATIO = 0.13 FEATHER_RATIO = 0.42 FEATHER_CURVE = 1.4 FEATHER_BLUR = 3 DARK_LUMINANCE = 118 # 이보다 어두우면 밝은 글자를 얹는다 DARK_BACKGROUND_GAIN = 0.42 LIGHT_BACKGROUND_GAIN, LIGHT_BACKGROUND_LIFT = 1.06, 26 LIGHT_INK = (245, 243, 238) DARK_INK = (26, 24, 22) MIN_FONT_SIZE = 12 FONT_STEP = 2 ROW_GAP_RATIO = 0.085 QUOTE_MARKS = '"“”' def band_palette(image: Image.Image, band_top: int) -> tuple[tuple, tuple]: """밴드 배경색과 글자색을 이미지 하단 평균색에서 뽑는다""" width, height = image.size strip = image.crop((0, band_top, width, height)).resize((1, 1), Image.LANCZOS) red, green, blue = strip.getpixel((0, 0))[:3] luminance = 0.299 * red + 0.587 * green + 0.114 * blue if luminance < DARK_LUMINANCE: background = tuple(int(channel * DARK_BACKGROUND_GAIN) for channel in (red, green, blue)) return background, LIGHT_INK background = tuple(min(255, int(channel * LIGHT_BACKGROUND_GAIN + LIGHT_BACKGROUND_LIFT)) for channel in (red, green, blue)) return background, DARK_INK def fit_font(draw: ImageDraw.ImageDraw, text: str, weight: str, max_width: int, start_size: int) -> ImageFont.FreeTypeFont: """max_width 안에 들어가는 가장 큰 크기를 찾는다""" size = start_size while size > MIN_FONT_SIZE: candidate = font(weight, size) if draw.textlength(text, font=candidate) <= max_width: return candidate size -= FONT_STEP return font(weight, MIN_FONT_SIZE) def band_rows(text: PosterTextAnswer, band_height: int) -> list[tuple[str, str, int, float]]: """슬로건 → 날짜+시간 → 장소 순. 날짜를 가장 크게 잡는다 슬로건은 원래 제목 근처에 놓이는 문구지만 생성 결과의 제목 위치가 매번 달라 상단에 얹으면 아트워크와 부딪힌다. 밴드 첫 줄로 내려 항상 읽히게 한다. """ slogan = text.slogan.strip().strip(QUOTE_MARKS) datetime_line = text.date.strip() + (f" {text.time.strip()}" if text.time.strip() else "") rows = [] if slogan: rows.append((f"“{slogan}”", "SemiBold", round(band_height * 0.20), 0.72)) if datetime_line: rows.append((datetime_line, "Bold", round(band_height * 0.33), 1.0)) if text.place.strip(): rows.append((text.place.strip(), "SemiBold", round(band_height * 0.21), 0.92)) return rows def paint_scrim(image: Image.Image, band_top: int, band_height: int, background: tuple) -> None: """위쪽 경계가 칼같이 잘리면 얹은 티가 난다. 그라데이션으로 이어 붙인다""" width = image.width scrim = Image.new("RGB", (width, band_height), background) mask = Image.new("L", (width, band_height), 255) mask_draw = ImageDraw.Draw(mask) feather = round(band_height * FEATHER_RATIO) for row in range(feather): mask_draw.line([(0, row), (width, row)], fill=int(255 * (row / feather) ** FEATHER_CURVE)) image.paste(scrim, (0, band_top), mask.filter(ImageFilter.GaussianBlur(FEATHER_BLUR))) def compose_info_band(poster: Image.Image, text: PosterTextAnswer) -> Image.Image | None: """밴드를 얹은 이미지를 돌려준다. 그릴 내용이 없으면 None""" if not (text.date.strip() or text.place.strip()): return None image = poster.convert("RGB") width, height = image.size band_height = round(height * (BAND_RATIO_WITH_SLOGAN if text.slogan.strip() else BAND_RATIO)) band_top = height - band_height background, ink = band_palette(image, band_top) paint_scrim(image, band_top, band_height, background) draw = ImageDraw.Draw(image) inner_width = width - round(width * PAD_RATIO) * 2 rows = band_rows(text, band_height) fitted = [(line, fit_font(draw, line, weight, inner_width, size), opacity) for line, weight, size, opacity in rows] gap = round(band_height * ROW_GAP_RATIO) heights = [chosen.getbbox(line)[3] - chosen.getbbox(line)[1] for line, chosen, _ in fitted] top = band_top + (band_height - (sum(heights) + gap * (len(fitted) - 1))) // 2 \ - round(band_height * 0.03) for (line, chosen, opacity), line_height in zip(fitted, heights, strict=True): colour = ink if opacity >= 1.0 else tuple( round(channel * opacity + background[index] * (1 - opacity)) for index, channel in enumerate(ink)) draw.text((width // 2, top), line, font=chosen, fill=colour, anchor="ma") top += line_height + gap return image