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

185 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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