# 라이트 템플릿 도구 — 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)