# -*- 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'' f'' f'' f'' f'' f'' f'' f'' f'' f'' f'' f'' f'' f'' f'' f'' ) 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'' f'' f'' f'' f'' f'' f'' f'' f'' f'' f'' f'' ) 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()