740 lines
33 KiB
Python
740 lines
33 KiB
Python
# -*- 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()
|