185 lines
10 KiB
Python
185 lines
10 KiB
Python
# 기획서 공용 슬라이드 도구 (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)
|