740 lines
33 KiB
Python
Raw 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.

# -*- coding: utf-8 -*-
"""
ubisam-ppt-make — Ubisam SW 설계서 deck builder.
Design system extracted from P-PRJ_BFHB_UBISAM_Ver1.4.pptx and the user's
edited layout_showcase (UbiSam logo on every page).
Font rule (fixed): 한글=굴림, 영문=Arial.
Usage:
python build_deck.py # build sample showcase
python build_deck.py --input spec.json --output deck.pptx
"""
import os
import json
import argparse
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.enum.shapes import MSO_SHAPE
from pptx.oxml.ns import qn
from pptx.parts.image import ImagePart
from pptx.opc.constants import RELATIONSHIP_TYPE as RT
from pptx.opc.packuri import PackURI
from lxml import etree
HERE = os.path.dirname(os.path.abspath(__file__))
LOGO = os.path.join(HERE, "assets", "Ubisam_logo_transparent.png")
# ---- design tokens ----
ACCENT = RGBColor(0x4A, 0x8D, 0xCB)
ACCENT_D = RGBColor(0x2F, 0x5E, 0x8C)
INK = RGBColor(0x26, 0x2A, 0x2E)
SUB = RGBColor(0x6B, 0x72, 0x78)
LINE = RGBColor(0xD9, 0xDD, 0xE1)
BG_SOFT = RGBColor(0xF4, 0xF7, 0xFA)
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
WARN = RGBColor(0xE5, 0x3E, 0x3E)
PALE = RGBColor(0xDD, 0xEA, 0xF6)
BLUE2 = RGBColor(0x00, 0x70, 0xC0) # divider/closing block blue
KFONT = "굴림"
EFONT = "Arial"
EMU_W = Inches(13.333)
EMU_H = Inches(7.5)
_NS_P = "http://schemas.openxmlformats.org/presentationml/2006/main"
_NS_A = "http://schemas.openxmlformats.org/drawingml/2006/main"
_NS_R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
# ---------- master background ----------
def _apply_master_background(prs):
"""슬라이드 마스터에 흰 배경 + Ubisam 로고 워터마크 + 하단 accent 바 적용.
모든 슬라이드가 상속. cover·divider·closing은 자체 rect로 덮음."""
master = prs.slide_master
spTree = master.shapes._spTree
# 1. 배경: WHITE
try:
master.background.fill.solid()
master.background.fill.fore_color.rgb = WHITE
except Exception:
pass
for layout in prs.slide_layouts:
try:
layout.background.fill.solid()
layout.background.fill.fore_color.rgb = WHITE
except Exception:
pass
# 2. Ubisam 로고 워터마크 (하단 전체 폭, 15% 투명도)
if os.path.exists(LOGO):
try:
with open(LOGO, "rb") as f:
blob = f.read()
pkg = master.part._package
img_part = ImagePart(
PackURI("/ppt/media/ubisam_master_wm.png"), "image/png", pkg, blob
)
rId = master.part.relate_to(img_part, RT.IMAGE)
W, H = int(EMU_W), int(Inches(2.5))
T = int(EMU_H) - H
wm_xml = (
f'<p:pic xmlns:p="{_NS_P}" xmlns:a="{_NS_A}" xmlns:r="{_NS_R}">'
f'<p:nvPicPr>'
f'<p:cNvPr id="9002" name="UbisamWatermark"/>'
f'<p:cNvPicPr><a:picLocks noChangeAspect="1"/></p:cNvPicPr>'
f'<p:nvPr/></p:nvPicPr>'
f'<p:blipFill>'
f'<a:blip r:embed="{rId}"><a:alphaModFix amt="15000"/></a:blip>'
f'<a:stretch><a:fillRect/></a:stretch>'
f'</p:blipFill>'
f'<p:spPr>'
f'<a:xfrm><a:off x="0" y="{T}"/>'
f'<a:ext cx="{W}" cy="{H}"/></a:xfrm>'
f'<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>'
f'<a:noFill/>'
f'</p:spPr>'
f'</p:pic>'
)
spTree.append(etree.fromstring(wm_xml.encode("utf-8")))
except Exception:
pass
# 3. 하단 accent 바
hex_c = f"{ACCENT[0]:02X}{ACCENT[1]:02X}{ACCENT[2]:02X}"
bar_xml = (
f'<p:sp xmlns:p="{_NS_P}" xmlns:a="{_NS_A}" xmlns:r="{_NS_R}">'
f'<p:nvSpPr><p:cNvPr id="9001" name="AccentBar"/>'
f'<p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr/></p:nvSpPr>'
f'<p:spPr>'
f'<a:xfrm><a:off x="0" y="{int(Inches(7.35))}"/>'
f'<a:ext cx="{int(EMU_W)}" cy="{int(Inches(0.15))}"/></a:xfrm>'
f'<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>'
f'<a:solidFill><a:srgbClr val="{hex_c}"/></a:solidFill>'
f'<a:ln><a:noFill/></a:ln>'
f'</p:spPr>'
f'<p:txBody><a:bodyPr/><a:lstStyle/><a:p/></p:txBody>'
f'</p:sp>'
)
try:
spTree.append(etree.fromstring(bar_xml.encode("utf-8")))
except Exception:
pass
# ---------- text / shape helpers ----------
def _set_font(run, size=None, bold=None, color=None, italic=None):
f = run.font
if size is not None:
f.size = Pt(size)
if bold is not None:
f.bold = bold
if italic is not None:
f.italic = italic
if color is not None:
f.color.rgb = color
f.name = EFONT
rPr = run._r.get_or_add_rPr()
for tag in ("a:ea", "a:cs"):
el = rPr.find(qn(tag))
if el is None:
el = rPr.makeelement(qn(tag), {})
rPr.append(el)
el.set("typeface", KFONT if tag == "a:ea" else EFONT)
def add_text(slide, l, t, w, h, lines, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP):
tb = slide.shapes.add_textbox(l, t, w, h)
tf = tb.text_frame
tf.word_wrap = True
tf.vertical_anchor = anchor
for m in ("margin_left", "margin_right", "margin_top", "margin_bottom"):
setattr(tf, m, 0)
for i, spec in enumerate(lines):
text, size, bold, color = spec[0], spec[1], spec[2], spec[3]
a = spec[4] if len(spec) > 4 else align
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
p.alignment = a
r = p.add_run()
r.text = text
_set_font(r, size=size, bold=bold, color=color)
return tb
def rect(slide, l, t, w, h, fill=None, line_color=None, line_w=None,
shape=MSO_SHAPE.RECTANGLE):
sp = slide.shapes.add_shape(shape, l, t, w, h)
sp.shadow.inherit = False
if fill is None:
sp.fill.background()
else:
sp.fill.solid()
sp.fill.fore_color.rgb = fill
if line_color is None:
sp.line.fill.background()
else:
sp.line.color.rgb = line_color
sp.line.width = Pt(line_w or 0.75)
if sp.has_text_frame:
sp.text_frame.word_wrap = True
return sp
def blank_slide(prs):
return prs.slides.add_slide(prs.slide_layouts[6])
def add_logo(slide, where="top", left=None, top=None, height=None):
"""UbiSam logo on every page. cover=bottom-right, others=top-right.
left/top/height override the top-right default (in inches)."""
if not os.path.exists(LOGO):
return
if where == "bottom":
slide.shapes.add_picture(LOGO, Inches(10.55), Inches(6.35), height=Inches(0.62))
else:
slide.shapes.add_picture(LOGO, Inches(10.55 if left is None else left),
Inches(0.5 if top is None else top),
height=Inches(0.55 if height is None else height))
def header(slide, no, title_en, title_ko=None):
if no:
rect(slide, Inches(0.55), Inches(0.42), Inches(0.62), Inches(0.46), fill=ACCENT)
add_text(slide, Inches(0.55), Inches(0.42), Inches(0.62), Inches(0.46),
[(no, 16, True, WHITE, PP_ALIGN.CENTER)], anchor=MSO_ANCHOR.MIDDLE)
tx = 1.32 if no else 0.55
add_text(slide, Inches(tx), Inches(0.40), Inches(8.6), Inches(0.5),
[(title_en, 22, True, INK)], anchor=MSO_ANCHOR.MIDDLE)
if title_ko:
add_text(slide, Inches(tx + 0.02), Inches(0.86), Inches(8.6), Inches(0.32),
[(title_ko, 12, False, SUB)])
rect(slide, Inches(0.55), Inches(1.22), Inches(11.4), Inches(0.035), fill=ACCENT)
rect(slide, Inches(0.55), Inches(1.22), Inches(2.2), Inches(0.035), fill=ACCENT_D)
def footer(slide, page, doc_title):
add_text(slide, Inches(0.55), Inches(7.05), Inches(7.0), Inches(0.3),
[("Ubisam | " + doc_title, 9, False, SUB)])
if page is not None:
add_text(slide, Inches(11.0), Inches(7.05), Inches(1.78), Inches(0.3),
[(str(page), 9, False, SUB, PP_ALIGN.RIGHT)])
def num_badge(slide, l, t, dia, n, fill=ACCENT, color=WHITE, size=14):
rect(slide, Inches(l), Inches(t), Inches(dia), Inches(dia), fill=fill, shape=MSO_SHAPE.OVAL)
add_text(slide, Inches(l), Inches(t), Inches(dia), Inches(dia),
[(str(n), size, True, color, PP_ALIGN.CENTER)], anchor=MSO_ANCHOR.MIDDLE)
# ---------- layouts ----------
def L_cover(prs, d):
s = blank_slide(prs)
rect(s, 0, 0, EMU_W, EMU_H, fill=WHITE)
rect(s, 0, 0, Inches(0.28), EMU_H, fill=ACCENT)
rect(s, Inches(0.28), 0, Inches(0.06), EMU_H, fill=ACCENT_D)
rect(s, Inches(0.9), Inches(1.7), Inches(2.4), Inches(0.42), fill=BG_SOFT)
add_text(s, Inches(0.9), Inches(1.7), Inches(2.4), Inches(0.42),
[(d.get("tag", "SW DESIGN DOCUMENT"), 11, True, ACCENT, PP_ALIGN.CENTER)],
anchor=MSO_ANCHOR.MIDDLE)
add_text(s, Inches(0.9), Inches(2.35), Inches(10.0), Inches(1.2),
[(d["title"], 40, True, INK)])
subs = d.get("subtitles", [])
add_text(s, Inches(0.92), Inches(3.55), Inches(10.5), Inches(0.9),
[(t, 15 if i == 0 else 12, False, SUB) for i, t in enumerate(subs)])
rect(s, Inches(0.92), Inches(4.7), Inches(3.2), Inches(0.03), fill=LINE)
contacts = d.get("contacts", [])
cl = [(d.get("team", "시스템 사업팀"), 13, True, INK)]
cl += [(c, 11, False, SUB) for c in contacts]
add_text(s, Inches(0.92), Inches(4.95), Inches(8.0), Inches(1.4), cl)
add_logo(s, "bottom")
return s
def L_history(prs, d):
s = blank_slide(prs)
header(s, d.get("no", "00."), d.get("title", "변경 이력"), d.get("subtitle", "Revision History"))
cols = [("No.", 0.55, 0.55), ("Version", 1.4, 1.1), ("변경일자", 2.5, 1.5),
("변경자", 4.0, 1.3), ("변경 내용", 5.3, 6.65)]
rows = d.get("rows", [])
top, rh = 1.7, 0.5
rect(s, Inches(0.55), Inches(top), Inches(11.4), Inches(rh), fill=ACCENT)
for label, x, w in cols:
add_text(s, Inches(x), Inches(top), Inches(w), Inches(rh),
[(label, 12, True, WHITE, PP_ALIGN.CENTER)], anchor=MSO_ANCHOR.MIDDLE)
for i, row in enumerate(rows):
y = top + rh + i * rh
if i % 2 == 1:
rect(s, Inches(0.55), Inches(y), Inches(11.4), Inches(rh), fill=BG_SOFT)
for (label, x, w), val in zip(cols, row):
al = PP_ALIGN.LEFT if label == "변경 내용" else PP_ALIGN.CENTER
xx = x + 0.1 if label == "변경 내용" else x
add_text(s, Inches(xx), Inches(y), Inches(w), Inches(rh),
[(str(val), 11, False, INK, al)], anchor=MSO_ANCHOR.MIDDLE)
rect(s, Inches(0.55), Inches(y + rh), Inches(11.4), Inches(0.012), fill=LINE)
add_logo(s)
footer(s, d.get("page"), d.get("doc_title", ""))
return s
def L_agenda(prs, d):
s = blank_slide(prs)
header(s, "", d.get("title", "Contents"), d.get("subtitle", "목차"))
items = d.get("items", [])
cw, ch = 5.55, 1.45
gx, gy = 0.55, 1.75
for i, it in enumerate(items[:6]):
r, c = divmod(i, 2)
x = gx + c * (cw + 0.3)
y = gy + r * (ch + 0.25)
rect(s, Inches(x), Inches(y), Inches(cw), Inches(ch), fill=BG_SOFT)
rect(s, Inches(x), Inches(y), Inches(0.08), Inches(ch), fill=ACCENT)
add_text(s, Inches(x + 0.25), Inches(y + 0.18), Inches(1.2), Inches(1.0),
[(it.get("no", ""), 34, True, ACCENT)], anchor=MSO_ANCHOR.MIDDLE)
add_text(s, Inches(x + 1.55), Inches(y + 0.3), Inches(cw - 1.7), Inches(0.9),
[(it.get("title", ""), 17, True, INK), (it.get("sub", ""), 11, False, SUB)],
anchor=MSO_ANCHOR.MIDDLE)
add_logo(s)
footer(s, d.get("page"), d.get("doc_title", ""))
return s
def L_divider(prs, d):
s = blank_slide(prs)
rect(s, 0, 0, EMU_W, EMU_H, fill=WHITE)
rect(s, 0, Inches(3.88), EMU_W, Inches(3.62), fill=BLUE2)
add_text(s, Inches(0.9), Inches(1.3), Inches(6.0), Inches(2.2),
[(d.get("no", "01"), 130, True, BLUE2)], anchor=MSO_ANCHOR.MIDDLE)
rect(s, Inches(0.95), Inches(4.05), Inches(2.4), Inches(0.06), fill=WHITE)
add_text(s, Inches(0.95), Inches(4.25), Inches(10.0), Inches(0.84),
[(d.get("title", ""), 30, True, WHITE), (d.get("subtitle", ""), 20, False, PALE)])
add_logo(s, left=11.3, top=0.55, height=0.5)
return s
def L_content(prs, d):
s = blank_slide(prs)
header(s, d.get("no", "02."), d.get("title", ""), d.get("subtitle"))
panels = d.get("panels", [])
pw, ph = 5.55, 2.35
gx, gy = 0.55, 1.65
for i, p in enumerate(panels[:4]):
r, c = divmod(i, 2)
x = gx + c * (pw + 0.3)
y = gy + r * (ph + 0.25)
rect(s, Inches(x), Inches(y), Inches(pw), Inches(ph), fill=WHITE, line_color=LINE, line_w=1.0)
rect(s, Inches(x), Inches(y), Inches(pw), Inches(0.42), fill=BG_SOFT)
add_text(s, Inches(x + 0.2), Inches(y), Inches(pw - 0.4), Inches(0.42),
[(p.get("title", ""), 13, True, ACCENT_D)], anchor=MSO_ANCHOR.MIDDLE)
add_text(s, Inches(x + 0.2), Inches(y + 0.42), Inches(pw - 0.4), Inches(ph - 0.42),
[("〔 " + p.get("body", "") + " 〕", 11, False, SUB, PP_ALIGN.CENTER)],
anchor=MSO_ANCHOR.MIDDLE)
add_logo(s)
footer(s, d.get("page"), d.get("doc_title", ""))
return s
def L_flow(prs, d):
s = blank_slide(prs)
header(s, d.get("no", "03."), d.get("title", "Sequence Flowchart"), d.get("subtitle"))
steps = d.get("steps", [])
branch = d.get("branch")
cx, bw = 4.55, 3.0
y, gap = 1.6, 0.46
prev_bottom, dec_y = None, None
for title, code, kind in [(s2.get("title", ""), s2.get("code", ""), s2.get("kind", "proc")) for s2 in steps]:
h = 0.58 if kind == "dec" else 0.52
if kind == "dec" and dec_y is None:
dec_y = y
lc = ACCENT if kind == "dec" else SUB
rect(s, Inches(cx), Inches(y), Inches(bw), Inches(h), fill=WHITE,
line_color=lc, line_w=1.5 if kind == "dec" else 1.0,
shape=MSO_SHAPE.ROUNDED_RECTANGLE)
add_text(s, Inches(cx), Inches(y + 0.04), Inches(bw), Inches(h - 0.04),
[(title, 12, True, INK, PP_ALIGN.CENTER), (code, 9, False, ACCENT_D, PP_ALIGN.CENTER)],
anchor=MSO_ANCHOR.MIDDLE)
if prev_bottom is not None:
ln = s.shapes.add_connector(2, Inches(cx + bw / 2), Inches(prev_bottom),
Inches(cx + bw / 2), Inches(y))
ln.line.color.rgb = ACCENT
ln.line.width = Pt(1.5)
prev_bottom = y + h
y += h + gap
if branch and dec_y is not None:
bh = 0.58
by = dec_y
rect(s, Inches(1.7), Inches(by), Inches(1.9), Inches(bh), fill=BG_SOFT,
line_color=SUB, line_w=1.0, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
add_text(s, Inches(1.7), Inches(by), Inches(1.9), Inches(bh),
[(branch.get("title", ""), 11, True, WARN, PP_ALIGN.CENTER),
(branch.get("sub", ""), 9, False, SUB, PP_ALIGN.CENTER)], anchor=MSO_ANCHOR.MIDDLE)
ln = s.shapes.add_connector(2, Inches(cx), Inches(by + bh / 2), Inches(3.6), Inches(by + bh / 2))
ln.line.color.rgb = WARN
ln.line.width = Pt(1.25)
add_logo(s)
footer(s, d.get("page"), d.get("doc_title", ""))
return s
def _fill_cell(cell, text, size, bold, color, center=False):
cell.vertical_anchor = MSO_ANCHOR.MIDDLE
cell.margin_left = Inches(0.08)
cell.margin_right = Inches(0.08)
cell.margin_top = Inches(0.02)
cell.margin_bottom = Inches(0.02)
tf = cell.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.CENTER if center else PP_ALIGN.LEFT
r = p.add_run()
r.text = str(text)
_set_font(r, size=size, bold=bold, color=color)
def L_table(prs, d):
s = blank_slide(prs)
header(s, d.get("no", "06."), d.get("title", ""), d.get("subtitle"))
headers = d.get("headers", [])
widths = d.get("widths") or [11.4 / len(headers)] * len(headers)
data = d.get("rows", [])
center_cols = set(d.get("center_cols", []))
rows = len(data) + 1
tbl = s.shapes.add_table(rows, len(headers), Inches(0.55), Inches(1.7),
Inches(11.4), Inches(0.5 * rows)).table
for c, w in enumerate(widths):
tbl.columns[c].width = Inches(w)
for c, hd in enumerate(headers):
cell = tbl.cell(0, c)
cell.fill.solid()
cell.fill.fore_color.rgb = ACCENT
_fill_cell(cell, hd, 12, True, WHITE, center=True)
for r, row in enumerate(data, start=1):
for c, val in enumerate(row):
cell = tbl.cell(r, c)
cell.fill.solid()
cell.fill.fore_color.rgb = BG_SOFT if r % 2 == 0 else WHITE
_fill_cell(cell, val, 11, False, INK, center=(c in center_cols))
add_logo(s)
footer(s, d.get("page"), d.get("doc_title", ""))
return s
def L_closing(prs, d):
s = blank_slide(prs)
rect(s, 0, 0, EMU_W, EMU_H, fill=WHITE)
rect(s, 0, Inches(5.0), EMU_W, Inches(2.5), fill=ACCENT_D)
add_text(s, Inches(0.83), Inches(1.8), Inches(11.0), Inches(1.62),
[(d.get("title", "Thank You"), 96, True, BLUE2)])
if d.get("message"):
add_text(s, Inches(0.95), Inches(3.5), Inches(11.0), Inches(0.5),
[(d["message"], 14, False, INK)])
cl = [(d.get("team", "시스템 사업팀"), 14, True, WHITE)]
cl += [(c, 12, False, PALE) for c in d.get("contacts", [])]
add_text(s, Inches(0.95), Inches(5.35), Inches(11.0), Inches(1.4), cl)
add_logo(s, left=10.55, top=0.55, height=0.6)
return s
# ---------- manual layouts ----------
REGION = RGBColor(0xFF, 0x00, 0x00) # red region-highlight border (matches manual edit)
def _img_px_size(path):
try:
from PIL import Image
with Image.open(path) as im:
return im.size
except Exception:
return None
def _fit_image(slide, path, bx, by, bw, bh):
"""Add picture fitted (aspect-preserved, centered) inside box (inches).
Returns the placed rect (left, top, w, h) in inches."""
pic = slide.shapes.add_picture(path, Inches(bx), Inches(by), width=Inches(bw))
if pic.height > Inches(bh):
sp = pic._element
sp.getparent().remove(sp)
pic = slide.shapes.add_picture(path, Inches(bx), Inches(by), height=Inches(bh))
pic.left = Inches(bx) + (Inches(bw) - pic.width) // 2
pic.top = Inches(by) + (Inches(bh) - pic.height) // 2
return (pic.left / 914400.0, pic.top / 914400.0,
pic.width / 914400.0, pic.height / 914400.0)
def _norm_boxes(it):
"""Return list of [x,y,w,h] pixel boxes from item's box/boxes field."""
b = it.get("boxes") or it.get("box")
if not b:
return []
return b if isinstance(b[0], (list, tuple)) else [b]
def L_screen_guide(prs, d):
"""화면 설명: 캡처 영역(좌) + 번호 콜아웃(우). 이미지 위 영역 박스+번호 마커 지원."""
s = blank_slide(prs)
header(s, d.get("no", "07."), d.get("title", "화면 설명"), d.get("subtitle"))
sx, sy, sw, sh_ = 0.55, 1.7, 7.3, 5.0
rect(s, Inches(sx), Inches(sy), Inches(sw), Inches(sh_), fill=BG_SOFT, line_color=LINE, line_w=1.0)
items = d.get("items", [])
img = d.get("image")
if img and os.path.exists(img):
il, it_, iw, ih = _fit_image(s, img, sx + 0.05, sy + 0.05, sw - 0.1, sh_ - 0.1)
px = _img_px_size(img)
if px:
scale = iw / px[0] # inches per pixel (aspect preserved)
mdia = 0.30
for i, item in enumerate(items):
for j, bx in enumerate(_norm_boxes(item)):
rl, rt = il + bx[0] * scale, it_ + bx[1] * scale
rw, rh = bx[2] * scale, bx[3] * scale
rect(s, Inches(rl), Inches(rt), Inches(rw), Inches(rh),
fill=None, line_color=REGION, line_w=1.5)
if j == 0: # number marker at first box's top-left corner
num_badge(s, rl, rt, mdia, i + 1, size=12)
else:
add_text(s, Inches(sx), Inches(sy), Inches(sw), Inches(sh_),
[("〔 " + d.get("caption", "화면 캡처 영역") + " 〕", 12, False, SUB, PP_ALIGN.CENTER)],
anchor=MSO_ANCHOR.MIDDLE)
rx, rw = 8.15, 4.65
n = max(len(items), 1)
rh = min(1.0, (sh_ + 0.0) / n)
for i, it in enumerate(items):
pos = it.get("pos")
if pos:
num_badge(s, sx + pos[0], sy + pos[1], 0.34, i + 1, size=12)
ly = sy + i * rh
num_badge(s, rx, ly + 0.03, 0.34, i + 1, size=12)
add_text(s, Inches(rx + 0.5), Inches(ly - 0.02), Inches(rw - 0.5), Inches(rh),
[(it.get("title", ""), 13, True, INK), (it.get("desc", ""), 11, False, SUB)])
add_logo(s)
footer(s, d.get("page"), d.get("doc_title", ""))
return s
def L_procedure(prs, d):
"""조작 절차: 세로 번호 단계."""
s = blank_slide(prs)
header(s, d.get("no", "07."), d.get("title", "조작 절차"), d.get("subtitle"))
steps = d.get("steps", [])
y0, y1 = 1.75, 6.8
n = max(len(steps), 1)
rh = min(1.1, (y1 - y0) / n)
for i, st in enumerate(steps):
y = y0 + i * rh
if i % 2 == 1:
rect(s, Inches(0.55), Inches(y), Inches(11.4), Inches(rh - 0.12), fill=BG_SOFT)
num_badge(s, 0.75, y + (rh - 0.12 - 0.5) / 2, 0.5, i + 1, size=18)
add_text(s, Inches(1.55), Inches(y), Inches(10.3), Inches(rh - 0.12),
[(st.get("title", ""), 15, True, INK), (st.get("desc", ""), 12, False, SUB)],
anchor=MSO_ANCHOR.MIDDLE)
add_logo(s)
footer(s, d.get("page"), d.get("doc_title", ""))
return s
def L_icon_ref(prs, d):
"""버튼/아이콘 설명: 카드 그리드(3열)."""
s = blank_slide(prs)
header(s, d.get("no", "07."), d.get("title", "버튼 설명"), d.get("subtitle"))
items = d.get("items", [])
cw, ch, gx, gy, gp = 3.6, 2.35, 0.55, 1.75, 0.3
for i, it in enumerate(items[:6]):
r, c = divmod(i, 3)
x = gx + c * (cw + gp)
y = gy + r * (ch + 0.3)
rect(s, Inches(x), Inches(y), Inches(cw), Inches(ch), fill=WHITE, line_color=LINE, line_w=1.0)
rect(s, Inches(x + (cw - 1.1) / 2), Inches(y + 0.3), Inches(1.1), Inches(1.1),
fill=BG_SOFT, line_color=ACCENT, line_w=1.0, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
add_text(s, Inches(x + (cw - 1.1) / 2), Inches(y + 0.3), Inches(1.1), Inches(1.1),
[(it.get("icon", "ICON"), 11, True, ACCENT, PP_ALIGN.CENTER)], anchor=MSO_ANCHOR.MIDDLE)
add_text(s, Inches(x + 0.15), Inches(y + 1.5), Inches(cw - 0.3), Inches(0.35),
[(it.get("name", ""), 13, True, INK, PP_ALIGN.CENTER)])
add_text(s, Inches(x + 0.15), Inches(y + 1.85), Inches(cw - 0.3), Inches(0.45),
[(it.get("func", ""), 10, False, SUB, PP_ALIGN.CENTER)])
add_logo(s)
footer(s, d.get("page"), d.get("doc_title", ""))
return s
def L_alarm(prs, d):
"""알람·조치: 주의 배너 + 표."""
s = blank_slide(prs)
header(s, d.get("no", "07."), d.get("title", "알람 및 조치 방법"), d.get("subtitle"))
rect(s, Inches(0.55), Inches(1.65), Inches(11.4), Inches(0.55),
fill=RGBColor(0xFF, 0xF4, 0xD6), line_color=YEL if False else RGBColor(0xF0, 0xB4, 0x29), line_w=1.0)
rect(s, Inches(0.7), Inches(1.78), Inches(0.95), Inches(0.3), fill=WARN, shape=MSO_SHAPE.ROUNDED_RECTANGLE)
add_text(s, Inches(0.7), Inches(1.78), Inches(0.95), Inches(0.3),
[("주의", 11, True, WHITE, PP_ALIGN.CENTER)], anchor=MSO_ANCHOR.MIDDLE)
add_text(s, Inches(1.8), Inches(1.65), Inches(10.0), Inches(0.55),
[(d.get("notice", "알람 발생 시 아래 조치 방법을 순서대로 수행하십시오."), 12, True, INK)],
anchor=MSO_ANCHOR.MIDDLE)
headers = d.get("headers", ["코드", "알람명", "발생 원인", "조치 방법"])
widths = d.get("widths") or [1.4, 3.0, 3.5, 3.5]
data = d.get("rows", [])
center_cols = set(d.get("center_cols", [0]))
rows = len(data) + 1
tbl = s.shapes.add_table(rows, len(headers), Inches(0.55), Inches(2.45),
Inches(11.4), Inches(0.5 * rows)).table
for c, w in enumerate(widths):
tbl.columns[c].width = Inches(w)
for c, hd in enumerate(headers):
cell = tbl.cell(0, c)
cell.fill.solid()
cell.fill.fore_color.rgb = ACCENT
_fill_cell(cell, hd, 12, True, WHITE, center=True)
for r, row in enumerate(data, start=1):
for c, val in enumerate(row):
cell = tbl.cell(r, c)
cell.fill.solid()
cell.fill.fore_color.rgb = BG_SOFT if r % 2 == 0 else WHITE
_fill_cell(cell, val, 11, False, INK, center=(c in center_cols))
add_logo(s)
footer(s, d.get("page"), d.get("doc_title", ""))
return s
BUILDERS = {
"cover": L_cover, "history": L_history, "agenda": L_agenda, "divider": L_divider,
"content": L_content, "flow": L_flow, "table": L_table, "closing": L_closing,
"screen_guide": L_screen_guide, "procedure": L_procedure,
"icon_ref": L_icon_ref, "alarm": L_alarm,
}
def build(spec, out_path):
prs = Presentation()
prs.slide_width = EMU_W
prs.slide_height = EMU_H
# 슬라이드 마스터 배경 적용 (흰 배경 + Ubisam 워터마크 + accent 바)
_apply_master_background(prs)
doc_title = spec.get("doc_title", "")
page = 0
for sl in spec["slides"]:
t = sl["type"]
if t not in BUILDERS:
raise ValueError("unknown slide type: " + t)
sl.setdefault("doc_title", doc_title)
if t in ("history", "agenda", "content", "flow", "table",
"screen_guide", "procedure", "icon_ref", "alarm"):
page += 1
sl.setdefault("page", page)
BUILDERS[t](prs, sl)
prs.save(out_path)
return len(prs.slides._sldIdLst)
def sample_spec():
return {
"doc_title": "P-PRJ BFHB SW 설계서",
"slides": [
{"type": "cover", "tag": "SW DESIGN DOCUMENT", "title": "P-PRJ BFHB SW 설계서",
"subtitles": ["인도향 P-Project · HFHB (Button Flex Hot Bar 열압착 본딩기)",
"A device that compresses a button flex by applying pressure and temperature to a point"],
"team": "시스템 사업팀",
"contacts": ["조정묵 과장 jojeongmuk@ubisam.com", "남재근 대리 namjaegeun@ubisam.com"]},
{"type": "history", "rows": [
["1", "1.0", "2025.04.28", "조정묵", "초안 작성"],
["2", "1.1", "2025.04.29", "조정묵", "UI 안, Class 내용 추가"],
["3", "1.2", "2025.05.09", "조정묵", "250425_엑추에이터 정리.xlsx 내용 반영"],
["4", "1.3", "2025.05.15", "조정묵", "[DFM]_LG PRI_V57_BFHB_1.2 내용 반영"],
["5", "1.4", "2025.05.21", "조정묵", "[DFM]_LG PRI_V57_BFHB_1.7 내용 반영"],
]},
{"type": "agenda", "items": [
{"no": "01", "title": "System Overview", "sub": "시스템 구성도"},
{"no": "02", "title": "UI Concept", "sub": "Main / Teaching UI 구성"},
{"no": "03", "title": "Sequence Flowchart", "sub": "Loading · Bonding · Unloading · PDCA"},
{"no": "04", "title": "Class 관계도", "sub": "Class Diagram"},
{"no": "05", "title": "축 구성", "sub": "Axis Configuration"},
{"no": "06", "title": "IO 구성", "sub": "I/O Map"},
]},
{"type": "divider", "no": "01", "title": "System Overview", "subtitle": "시스템 구성도"},
{"type": "content", "no": "02.", "title": "UI Concept", "subtitle": "Main UI 구성 1", "panels": [
{"title": "HotBar 측정 값", "body": "선형 그래프 (최근 생산 100개 표시)"},
{"title": "IO Monitoring", "body": "조작 버튼 / 상태 표시"},
{"title": "CPK", "body": "막대형 그래프"},
{"title": "OK 판정률", "body": "도넛 그래프 (Bali 수신 + 현재공정 판정)"},
]},
{"type": "flow", "no": "03.", "title": "Sequence Flowchart", "subtitle": "[Loading]",
"steps": [
{"title": "Wait (Is Loader Empty)", "code": "SendMachine_Ready", "kind": "proc"},
{"title": "Is UpStream Ready", "code": "CHECK_UPSTREAM", "kind": "dec"},
{"title": "Wait (Is Board Available)", "code": "BoardAvailable", "kind": "proc"},
{"title": "Loader Conveyor On", "code": "SendStartTransport", "kind": "proc"},
{"title": "Is TransportError", "code": "TransportError", "kind": "dec"},
],
"branch": {"title": "Conveyor Stop", "sub": "Sequence Stop"}},
{"type": "table", "no": "06.", "title": "IO 구성", "subtitle": "I/O Map",
"headers": ["No.", "Type", "Address", "Signal Name", "Description"],
"widths": [0.8, 1.4, 1.7, 3.3, 4.2], "center_cols": [0, 1, 2],
"rows": [
["1", "DI", "X00.0", "Start_Button", "사이클 시작 입력"],
["2", "DI", "X00.1", "Stop_Button", "사이클 정지 입력"],
["3", "DI", "X00.2", "EMG_Stop", "비상 정지"],
["4", "DO", "Y10.0", "Tower_Lamp_R", "경광등 적색"],
["5", "DO", "Y10.1", "Conveyor_Run", "컨베이어 구동"],
["6", "DO", "Y10.2", "Stopper1_Up", "스토퍼1 상승"],
]},
{"type": "divider", "no": "07", "title": "Operation Manual", "subtitle": "조작 매뉴얼"},
{"type": "screen_guide", "no": "07.", "title": "화면 설명", "subtitle": "Main 운전 화면",
"caption": "Main 운전 화면 캡처",
"items": [
{"title": "운전 상태 표시", "desc": "현재 사이클/공정 상태 램프", "pos": [0.5, 0.5]},
{"title": "측정값 그래프", "desc": "Load Cell · 온도 실시간 추이", "pos": [3.0, 1.5]},
{"title": "판정 결과", "desc": "OK/NG 도넛 그래프", "pos": [5.5, 2.5]},
{"title": "조작 버튼", "desc": "Start / Stop / Reset", "pos": [1.5, 3.8]},
{"title": "알람 표시줄", "desc": "최근 발생 알람 메시지", "pos": [4.0, 4.3]},
]},
{"type": "procedure", "no": "07.", "title": "조작 절차", "subtitle": "운전 시작 순서",
"steps": [
{"title": "전원 ON 및 초기화", "desc": "메인 전원 인가 후 [Reset] 버튼으로 장비 초기화"},
{"title": "원점 복귀", "desc": "[Home] 버튼 선택 → 전 축 원점 복귀 완료 확인"},
{"title": "레시피 선택", "desc": "생산 모델에 맞는 레시피 로드 및 파라미터 확인"},
{"title": "자동 운전 시작", "desc": "[Auto] 모드 전환 후 [Start] 버튼으로 사이클 시작"},
{"title": "운전 종료", "desc": "[Stop] 후 사이클 완료 대기 → 전원 OFF"},
]},
{"type": "icon_ref", "no": "07.", "title": "버튼 설명", "subtitle": "Main UI 조작 버튼",
"items": [
{"icon": "▶", "name": "Start", "func": "자동 운전 시작"},
{"icon": "■", "name": "Stop", "func": "사이클 정지"},
{"icon": "↺", "name": "Reset", "func": "알람 해제 / 초기화"},
{"icon": "⌂", "name": "Home", "func": "전 축 원점 복귀"},
{"icon": "≡", "name": "Recipe", "func": "레시피 관리 화면"},
{"icon": "⚙", "name": "Setup", "func": "설정 / 파라미터"},
]},
{"type": "alarm", "no": "07.", "title": "알람 및 조치 방법", "subtitle": "주요 알람 코드",
"notice": "알람 발생 시 원인 확인 후 아래 조치 방법을 순서대로 수행하십시오.",
"rows": [
["AL-001", "비상정지 입력", "EMG 버튼 눌림", "안전 확인 후 EMG 해제 → Reset"],
["AL-012", "원점 복귀 실패", "센서 미감지 / 간섭", "축 위치 확인 후 수동 원점 재시도"],
["AL-024", "Load Cell 통신 이상", "케이블 단선 / 노이즈", "커넥터 체결 확인 후 전원 재투입"],
["AL-031", "온도 상한 초과", "Heater 과열", "설정 온도 확인 → 냉각 후 재가동"],
]},
{"type": "closing", "title": "Thank You", "message": "문의 사항은 아래 담당자에게 연락 바랍니다.",
"team": "시스템 사업팀",
"contacts": ["조정묵 과장 jojeongmuk@ubisam.com", "남재근 대리 namjaegeun@ubisam.com"]},
],
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--input", help="spec JSON path (omit for sample showcase)")
ap.add_argument("--output", default="Ubisam_layout_showcase.pptx")
args = ap.parse_args()
if args.input:
with open(args.input, encoding="utf-8") as f:
spec = json.load(f)
else:
spec = sample_spec()
n = build(spec, args.output)
print("saved:", os.path.abspath(args.output), "slides:", n)
if __name__ == "__main__":
main()