BurningTimesAi/공유/기획/FirstRising/리뉴얼/tools/deckkit_light.py

246 lines
14 KiB
Python
Raw Permalink Normal View History

# 라이트 템플릿 도구 — PD 제공 레퍼런스(포트폴리오 요약 라이트) 시각 언어
# 흰 배경 · 왼쪽 빨간 세로 띠 · 영문 섹션 라벨 + 24pt 제목 + 우측 브랜드 · 얇은 구분선 + 리드 문장
# 회색 패널(F7F7F7) 위 흰 카드 · 테두리 이미지 · 빨간 핵심 수치 · 가로줄 표 · 번호 원 · 어두운 결론 띠
import os
from pptx import Presentation
from pptx.util import Inches, Pt, Emu
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.enum.shapes import MSO_SHAPE, MSO_CONNECTOR
from pptx.chart.data import CategoryChartData
from pptx.enum.chart import XL_CHART_TYPE
from pptx.oxml.ns import qn
from lxml import etree
from PIL import Image
F_REG, F_BLACK, F_MED, F_LIGHT = "Noto Sans KR", "Noto Sans KR Black", "Noto Sans KR Medium", "Noto Sans KR Light"
RED = RGBColor(0xD9, 0x37, 0x42); INK = RGBColor(0x3A, 0x3A, 0x3A); BODY = RGBColor(0x52, 0x52, 0x52); SUB = RGBColor(0x7A, 0x7A, 0x7A)
PAGE = RGBColor(0xA6, 0xA6, 0xA6); PANEL = RGBColor(0xF7, 0xF7, 0xF7); HEAD = RGBColor(0xEF, 0xEF, 0xEF); RULE = RGBColor(0xE2, 0xE2, 0xE2)
RULE2 = RGBColor(0xEB, 0xEB, 0xEB); WHITE = RGBColor(0xFF, 0xFF, 0xFF); DARK = RGBColor(0x3C, 0x3B, 0x3C); PINK = RGBColor(0xFC, 0xEC, 0xED)
X0, XW = 0.69, 11.94 # 본문 좌측·폭
Y0, YB = 1.64, 6.72 # 본문 위·아래
class Deck:
def __init__(self, brand="FIRSTRISING", brand_sub="Renewal Proposal · BurningTimes"):
self.prs = Presentation()
self.prs.slide_width, self.prs.slide_height = Inches(13.333), Inches(7.5)
self.blank = self.prs.slide_layouts[6]
self.page = 0; self.brand = brand; self.brand_sub = brand_sub
# ---------- 글자 ----------
@staticmethod
def _font(run, size, bold=False, color=BODY, face=F_REG):
run.font.name = face; run.font.size = Pt(size); run.font.bold = bold; run.font.color.rgb = color
rpr = run._r.get_or_add_rPr()
for tag in ("a:ea", "a:cs"):
el = rpr.find(qn(tag))
if el is None:
el = etree.SubElement(rpr, qn(tag))
el.set("typeface", face)
def text(self, sl, x, y, w, h, content, size=10.5, bold=False, color=BODY, face=F_REG, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP, spacing=1.15):
"""content: str | [줄] — 줄 = str | [(글, {size,bold,color,face})]"""
tb = sl.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
tf = tb.text_frame; tf.word_wrap = True; tf.vertical_anchor = anchor
tf.margin_left = tf.margin_right = Inches(0.02); tf.margin_top = tf.margin_bottom = Inches(0.01)
for i, ln in enumerate(content if isinstance(content, list) else [content]):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
p.alignment = align; p.line_spacing = spacing
for rn in (ln if isinstance(ln, list) else [ln]):
t, st = rn if isinstance(rn, tuple) else (rn, {})
r = p.add_run(); r.text = t
self._font(r, st.get("size", size), st.get("bold", bold), st.get("color", color), st.get("face", face))
return tb
def bullets(self, sl, x, y, w, h, items, size=10.5, color=BODY, gap=4, dot="·"):
tb = sl.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
tf = tb.text_frame; tf.word_wrap = True
tf.margin_left = tf.margin_right = Inches(0.02); tf.margin_top = Inches(0.01)
for i, it in enumerate(items):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
p.space_after = Pt(gap); p.line_spacing = 1.15
r = p.add_run(); r.text = dot + " "; self._font(r, size, True, RED)
for t, st in (it if isinstance(it, list) else [(it, {})]):
r = p.add_run(); r.text = t; self._font(r, st.get("size", size), st.get("bold", False), st.get("color", color))
return tb
# ---------- 도형 ----------
def rect(self, sl, x, y, w, h, fill=PANEL, line=None, lw=0.75, shape=MSO_SHAPE.RECTANGLE, radius=None, shadow=False):
s = sl.shapes.add_shape(shape, Inches(x), Inches(y), Inches(w), Inches(h))
if fill is None:
s.fill.background()
else:
s.fill.solid(); s.fill.fore_color.rgb = fill
if line is None:
s.line.fill.background()
else:
s.line.color.rgb = line; s.line.width = Pt(lw)
s.shadow.inherit = False
if radius is not None:
s.adjustments[0] = radius
if shadow:
sp = s._element.spPr
eff = sp.find(qn("a:effectLst")) # shadow.inherit=False 가 만든 빈 effectLst 재사용(중복 금지)
if eff is None:
eff = etree.SubElement(sp, qn("a:effectLst"))
sh = etree.SubElement(eff, qn("a:outerShdw")); sh.set("blurRad", "63500"); sh.set("dist", "12700"); sh.set("dir", "5400000"); sh.set("algn", "t"); sh.set("rotWithShape", "0")
c = etree.SubElement(sh, qn("a:srgbClr")); c.set("val", "000000"); a = etree.SubElement(c, qn("a:alpha")); a.set("val", "14000")
return s
def panel(self, sl, x, y, w, h, fill=PANEL, radius=0.03):
return self.rect(sl, x, y, w, h, fill=fill, shape=MSO_SHAPE.ROUNDED_RECTANGLE, radius=radius)
def card(self, sl, x, y, w, h, shadow=False):
return self.rect(sl, x, y, w, h, fill=WHITE, shape=MSO_SHAPE.ROUNDED_RECTANGLE, radius=0.03, shadow=shadow)
def hline(self, sl, x, y, w, color=RULE, width=0.75):
c = sl.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, Inches(x), Inches(y), Inches(x + w), Inches(y))
c.line.color.rgb = color; c.line.width = Pt(width); return c
def vline(self, sl, x, y, h, color=RULE, width=0.75):
c = sl.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, Inches(x), Inches(y), Inches(x), Inches(y + h))
c.line.color.rgb = color; c.line.width = Pt(width); return c
def arrow(self, sl, x1, y1, x2, y2, color=RED, width=1.75):
c = sl.shapes.add_connector(MSO_CONNECTOR.STRAIGHT, Inches(x1), Inches(y1), Inches(x2), Inches(y2))
c.line.color.rgb = color; c.line.width = Pt(width)
ln = c.line._get_or_add_ln()
t = etree.SubElement(ln, qn("a:tailEnd")); t.set("type", "triangle"); t.set("w", "med"); t.set("len", "med")
return c
def pill(self, sl, x, y, text, fill=DARK, w=None, size=8.5):
w = w or (0.22 + 0.13 * len(text))
s = self.rect(sl, x, y, w, 0.25, fill=fill, shape=MSO_SHAPE.ROUNDED_RECTANGLE, radius=0.5)
tf = s.text_frame; tf.margin_left = tf.margin_right = Inches(0.04); tf.margin_top = tf.margin_bottom = Inches(0)
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
p = tf.paragraphs[0]; p.alignment = PP_ALIGN.CENTER
r = p.add_run(); r.text = text; self._font(r, size, True, WHITE)
return s
def num(self, sl, x, y, n, d=0.26, fill=RED, size=9):
s = self.rect(sl, x, y, d, d, fill=fill, shape=MSO_SHAPE.OVAL)
tf = s.text_frame; tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = Inches(0)
tf.vertical_anchor = MSO_ANCHOR.MIDDLE
p = tf.paragraphs[0]; p.alignment = PP_ALIGN.CENTER
r = p.add_run(); r.text = str(n); self._font(r, size, True, WHITE)
return s
# ---------- 이미지 ----------
def image(self, sl, path, x, y, w, h, border=True, fill=None):
"""상자 안 비율 유지·가운데. fill=색이면 상자 배경"""
if fill is not None:
self.rect(sl, x, y, w, h, fill=fill)
if not path or not os.path.exists(path):
self.rect(sl, x, y, w, h, fill=PANEL, line=RULE)
self.text(sl, x, y + h / 2 - 0.15, w, 0.3, "이미지 없음", size=9, color=SUB, align=PP_ALIGN.CENTER); return None
im = Image.open(path); ar = im.width / im.height
iw, ih = (w, w / ar) if w / h < ar else (h * ar, h)
pic = sl.shapes.add_picture(path, Inches(x + (w - iw) / 2), Inches(y + (h - ih) / 2), Inches(iw), Inches(ih))
if border:
pic.line.color.rgb = RULE; pic.line.width = Pt(0.75)
return pic
def caption(self, sl, x, y, w, title, sub=None, align=PP_ALIGN.LEFT):
runs = [(title, {"bold": True, "color": INK, "size": 8.5})]
if sub:
runs.append((" " + sub, {"color": SUB, "size": 8}))
return self.text(sl, x, y, w, 0.22, [runs], size=8.5, align=align)
# ---------- 표 ----------
def table(self, sl, x, y, w, h, rows, colw=None, size=9.5, hsize=9.5, first_bold=True, col_colors=None, col_bold=None, hl_rows=()):
nr, nc = len(rows), len(rows[0])
tbl = sl.shapes.add_table(nr, nc, Inches(x), Inches(y), Inches(w), Inches(h)).table
st = tbl._tbl.tblPr.find(qn("a:tableStyleId"))
if st is None:
st = etree.SubElement(tbl._tbl.tblPr, qn("a:tableStyleId"))
st.text = "{5940675A-B579-460E-94D1-54222C63F5DA}"
tbl._tbl.tblPr.set("firstRow", "0"); tbl._tbl.tblPr.set("bandRow", "0")
if colw:
tot = sum(colw)
for i, cw in enumerate(colw):
tbl.columns[i].width = Emu(int(Inches(w) * cw / tot))
for ri, row in enumerate(rows):
for ci, val in enumerate(row):
cell = tbl.cell(ri, ci)
cell.margin_left = cell.margin_right = Inches(0.1); cell.margin_top = cell.margin_bottom = Inches(0.02)
cell.vertical_anchor = MSO_ANCHOR.MIDDLE
tcPr = cell._tc.get_or_add_tcPr()
for tag, on in (("a:lnL", False), ("a:lnR", False), ("a:lnT", True), ("a:lnB", True)):
ln = tcPr.find(qn(tag))
if ln is not None:
tcPr.remove(ln)
ln = etree.SubElement(tcPr, qn(tag)); ln.set("w", "9525" if on else "0")
if on:
sf = etree.SubElement(ln, qn("a:solidFill")); c = etree.SubElement(sf, qn("a:srgbClr")); c.set("val", "E2E2E2")
else:
etree.SubElement(ln, qn("a:noFill"))
tf = cell.text_frame; tf.word_wrap = True
p = tf.paragraphs[0]; p.alignment = PP_ALIGN.CENTER if ri == 0 else PP_ALIGN.LEFT
color = None
if isinstance(val, tuple):
val, color = val
r = p.add_run(); r.text = str(val)
if ri == 0:
self._font(r, hsize, True, INK); cell.fill.solid(); cell.fill.fore_color.rgb = HEAD
else:
cc = color or (col_colors[ci] if col_colors and col_colors[ci] else BODY)
bb = (first_bold and ci == 0) or (col_bold and col_bold[ci])
self._font(r, size, bool(bb), INK if (first_bold and ci == 0 and not color) else cc)
cell.fill.solid(); cell.fill.fore_color.rgb = PINK if ri in hl_rows else WHITE
# 표 기본 글자 뒤로 밀려나지 않게 tcPr 순서 보정: 테두리가 fill 뒤에 오도록
fills = [e for e in tcPr if e.tag in (qn("a:solidFill"), qn("a:noFill"))]
for fe in fills:
tcPr.remove(fe); tcPr.append(fe)
return tbl
def bar_chart(self, sl, x, y, w, h, cats, vals, color=RED, numfmt='0"%"'):
cd = CategoryChartData(); cd.categories = cats; cd.add_series("v", vals)
ch = sl.shapes.add_chart(XL_CHART_TYPE.BAR_CLUSTERED, Inches(x), Inches(y), Inches(w), Inches(h), cd).chart
ch.font.name = F_REG; ch.font.size = Pt(9.5); ch.font.color.rgb = BODY
ch.has_legend = False; ch.has_title = False
va = ch.value_axis; va.visible = False; va.has_major_gridlines = False
ca = ch.category_axis; ca.reverse_order = True; ca.tick_labels.font.size = Pt(9.5); ca.format.line.color.rgb = RULE
pl = ch.plots[0]; pl.gap_width = 55
s = pl.series[0]; s.format.fill.solid(); s.format.fill.fore_color.rgb = color
pl.has_data_labels = True; dl = pl.data_labels
dl.number_format = numfmt; dl.number_format_is_linked = False; dl.font.size = Pt(9.5); dl.font.bold = True; dl.font.color.rgb = INK
return ch
# ---------- 슬라이드 틀 ----------
def slide(self, title, section="", lead="", source=""):
self.page += 1
sl = self.prs.slides.add_slide(self.blank)
self.rect(sl, 0, 0, 0.19, 7.5, fill=RED)
if section:
self.text(sl, 0.69, 0.39, 8.3, 0.2, section, size=8, color=SUB)
self.text(sl, 0.67, 0.68, 8.9, 0.55, title, size=24, color=INK, face=F_BLACK)
self.text(sl, 8.89, 0.56, 3.75, 0.31, self.brand, size=14, color=RED, face=F_BLACK, align=PP_ALIGN.RIGHT)
self.text(sl, 7.22, 0.87, 5.42, 0.16, self.brand_sub, size=6.5, color=SUB, align=PP_ALIGN.RIGHT)
self.hline(sl, 0.69, 1.21, 11.94, color=RULE2, width=1.25)
if lead:
self.text(sl, 0.69, 1.3, 11.94, 0.25, lead, size=10, color=SUB)
if source:
self.text(sl, 0.69, 6.83, 10.97, 0.2, source, size=7.5, color=RED)
self.text(sl, 11.81, 6.83, 0.83, 0.2, f"{self.page:02d}", size=8, color=PAGE, align=PP_ALIGN.RIGHT)
return sl
def banner(self, sl, label, msg, y=6.2, h=0.5):
"""어두운 결론 띠 — 굵은 라벨 + 한 줄"""
self.rect(sl, X0, y, XW, h, fill=DARK, shape=MSO_SHAPE.ROUNDED_RECTANGLE, radius=0.09)
self.text(sl, X0 + 0.25, y, 1.6, h, label, size=14, bold=True, color=WHITE, anchor=MSO_ANCHOR.MIDDLE)
self.text(sl, X0 + 1.9, y, XW - 2.1, h, msg, size=10.5, color=WHITE, anchor=MSO_ANCHOR.MIDDLE)
def kpis(self, sl, x, y, w, h, items, big=17, small=8.5, bg=PANEL):
"""회색 패널 안 KPI 줄 — items: [(큰 글, 작은 글, 빨강여부)]"""
self.panel(sl, x, y, w, h, fill=bg, radius=0.09)
cw = w / len(items)
for i, (b, s, red) in enumerate(items):
cx = x + i * cw
self.text(sl, cx + 0.05, y + 0.12, cw - 0.1, 0.42, b, size=big, bold=True, color=RED if red else INK, align=PP_ALIGN.CENTER)
self.text(sl, cx + 0.05, y + 0.54, cw - 0.1, h - 0.6, s, size=small, color=SUB, align=PP_ALIGN.CENTER)
if i:
self.vline(sl, cx, y + 0.14, h - 0.28)
def save(self, path):
self.prs.save(path); return len(self.prs.slides)