# 기획서 공용 슬라이드 도구 (python-pptx) — 16:9, 맑은 고딕, 네이비·앰버 톤 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, XL_LEGEND_POSITION from pptx.oxml.ns import qn from lxml import etree from PIL import Image FONT = "맑은 고딕" NAVY = RGBColor(0x1B, 0x22, 0x33); ACCENT = RGBColor(0xF2, 0xA3, 0x3A); TEAL = RGBColor(0x1F, 0x9E, 0x8F) RED = RGBColor(0xC8, 0x41, 0x3B); GRAY = RGBColor(0x6B, 0x72, 0x80); LIGHT = RGBColor(0xF3, 0xF4, 0xF6) TEXT = RGBColor(0x1F, 0x29, 0x37); WHITE = RGBColor(0xFF, 0xFF, 0xFF); LINE = RGBColor(0xD1, 0xD5, 0xDB) BLUE = RGBColor(0x3B, 0x6E, 0xD6); PURPLE = RGBColor(0x7C, 0x4D, 0xC9); DARK = RGBColor(0x11, 0x14, 0x1C) PALE_A = RGBColor(0xFD, 0xF1, 0xE0); PALE_T = RGBColor(0xE7, 0xF3, 0xF1); PALE_R = RGBColor(0xFB, 0xE9, 0xE6); PALE_B = RGBColor(0xE8, 0xEE, 0xFB) class Deck: def __init__(self, tag_title): 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.tag_title = tag_title # ---------- 기본 요소 ---------- @staticmethod def _font(run, size, bold=False, color=TEXT): run.font.name = FONT; 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", FONT) def text(self, sl, x, y, w, h, content, size=14, bold=False, color=TEXT, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP, spacing=1.1): 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.05); tf.margin_top = tf.margin_bottom = Inches(0.03) 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)) return tb def bullets(self, sl, x, y, w, h, items, size=13, color=TEXT, gap=5, mark="■"): 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.05) for i, it in enumerate(items): lvl, body = it if isinstance(it, tuple) else (0, it) p = tf.paragraphs[0] if i == 0 else tf.add_paragraph() p.space_after = Pt(gap); p.line_spacing = 1.08 r = p.add_run(); r.text = (" " * lvl) + (mark + " " if lvl == 0 else "– ") self._font(r, size - (1 if lvl else 0), True, ACCENT if lvl == 0 else GRAY) for t, st in (body if isinstance(body, list) else [(body, {})]): r = p.add_run(); r.text = t self._font(r, st.get("size", size - (1 if lvl else 0)), st.get("bold", False), st.get("color", color)) return tb def rect(self, sl, x, y, w, h, fill=LIGHT, line=None, shape=MSO_SHAPE.RECTANGLE, radius=None): s = sl.shapes.add_shape(shape, Inches(x), Inches(y), Inches(w), Inches(h)) 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(1) s.shadow.inherit = False if radius is not None: s.adjustments[0] = radius return s def box(self, sl, x, y, w, h, title, body=None, fill=LIGHT, tcolor=TEXT, size=13, bsize=11, line=None, align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE): s = self.rect(sl, x, y, w, h, fill=fill, line=line, shape=MSO_SHAPE.ROUNDED_RECTANGLE, radius=0.07) tf = s.text_frame; tf.word_wrap = True; tf.vertical_anchor = anchor tf.margin_left = tf.margin_right = Inches(0.1); tf.margin_top = Inches(0.06); tf.margin_bottom = Inches(0.05) p = tf.paragraphs[0]; p.alignment = align r = p.add_run(); r.text = title; self._font(r, size, True, tcolor) for ln in (body if isinstance(body, list) else ([body] if body else [])): p = tf.add_paragraph(); p.alignment = align; p.space_before = Pt(2) for t, st in (ln if isinstance(ln, list) else [(ln, {})]): r = p.add_run(); r.text = t; self._font(r, st.get("size", bsize), st.get("bold", False), st.get("color", tcolor)) return s def arrow(self, sl, x1, y1, x2, y2, color=ACCENT, width=2.5): 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() tail = etree.SubElement(ln, qn("a:tailEnd")); tail.set("type", "triangle"); tail.set("w", "med"); tail.set("len", "med") return c def image(self, sl, path, x, y, w, h, frame=None): """상자(w×h) 안 비율 유지·가운데 정렬. frame=색이면 뒤에 배경 상자""" if not path or not os.path.exists(path): self.box(sl, x, y, w, h, "이미지 없음", [os.path.basename(path or "")], fill=LIGHT, size=11, bsize=9) return None if frame is not None: self.rect(sl, x, y, w, h, fill=frame) im = Image.open(path); ar = im.width / im.height iw, ih = (w, w / ar) if w / h < ar else (h * ar, h) return sl.shapes.add_picture(path, Inches(x + (w - iw) / 2), Inches(y + (h - ih) / 2), Inches(iw), Inches(ih)) def caption(self, sl, x, y, w, s, color=GRAY, size=9.5, align=PP_ALIGN.LEFT): return self.text(sl, x, y, w, 0.3, s, size=size, color=color, align=align) def table(self, sl, x, y, w, h, rows, colw=None, size=10.5, hsize=11, hfill=NAVY, first_bold=True, zebra=True, col_colors=None): 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}" 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.06); cell.margin_top = cell.margin_bottom = Inches(0.03) cell.vertical_anchor = MSO_ANCHOR.MIDDLE 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, WHITE); cell.fill.solid(); cell.fill.fore_color.rgb = hfill else: cc = color or (col_colors[ci] if col_colors and col_colors[ci] else TEXT) self._font(r, size, first_bold and ci == 0, cc) cell.fill.solid(); cell.fill.fore_color.rgb = LIGHT if (zebra and ri % 2 == 0) else WHITE return tbl def bar_chart(self, sl, x, y, w, h, cats, vals, color=PURPLE, numfmt='#,##0', horizontal=True): cd = CategoryChartData(); cd.categories = cats; cd.add_series("v", vals) ch = sl.shapes.add_chart(XL_CHART_TYPE.BAR_CLUSTERED if horizontal else XL_CHART_TYPE.COLUMN_CLUSTERED, Inches(x), Inches(y), Inches(w), Inches(h), cd).chart ch.font.name = FONT; ch.font.size = Pt(10); ch.has_legend = False; ch.has_title = False va = ch.value_axis; va.has_major_gridlines = False; va.visible = False ch.category_axis.tick_labels.font.size = Pt(10.5) if horizontal: ch.category_axis.reverse_order = True pl = ch.plots[0]; pl.gap_width = 45 pl.series[0].format.fill.solid(); pl.series[0].format.fill.fore_color.rgb = color pl.has_data_labels = True; pl.data_labels.number_format = numfmt; pl.data_labels.number_format_is_linked = False pl.data_labels.font.size = Pt(10); pl.data_labels.font.bold = True return ch # ---------- 슬라이드 틀 ---------- def slide(self, title, section="", tag="", key=None): self.page += 1 sl = self.prs.slides.add_slide(self.blank) self.rect(sl, 0, 0, 13.333, 0.95, fill=NAVY) self.rect(sl, 0, 0.95, 13.333, 0.05, fill=ACCENT) if section: self.text(sl, 0.45, 0.08, 9, 0.3, section, size=10.5, bold=True, color=ACCENT) self.text(sl, 0.45, 0.3, 11.3, 0.6, title, size=24, bold=True, color=WHITE) self.text(sl, 10.8, 0.33, 2.2, 0.4, self.tag_title, size=10, color=RGBColor(0xB8, 0xC0, 0xCC), align=PP_ALIGN.RIGHT) self.rect(sl, 0, 7.12, 13.333, 0.01, fill=LINE) if tag: self.text(sl, 0.45, 7.14, 11.4, 0.3, tag, size=9, color=GRAY) self.text(sl, 12.3, 7.14, 0.7, 0.3, str(self.page), size=9, color=GRAY, align=PP_ALIGN.RIGHT) return sl def key_msg(self, sl, msg, y=6.45, fill=NAVY, color=WHITE, size=13.5): """슬라이드 하단 한 줄 결론 바""" s = self.rect(sl, 0.55, y, 12.2, 0.55, fill=fill, shape=MSO_SHAPE.ROUNDED_RECTANGLE, radius=0.15) tf = s.text_frame; tf.vertical_anchor = MSO_ANCHOR.MIDDLE; tf.margin_left = Inches(0.2) p = tf.paragraphs[0]; p.alignment = PP_ALIGN.LEFT r = p.add_run(); r.text = "→ " + msg; self._font(r, size, True, color) return s def save(self, path): self.prs.save(path) return len(self.prs.slides)