using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Xml.Linq;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Presentation;
using P = DocumentFormat.OpenXml.Presentation;
using A = DocumentFormat.OpenXml.Drawing;
using DC = DocumentFormat.OpenXml.Drawing.Charts;
namespace AxCopilot.Services.Agent;
///
/// 고품질 PowerPoint (.pptx) 프레젠테이션을 네이티브 생성하는 스킬.
/// OpenXML SDK를 사용하여 Python/Node 의존 없이 PPTX를 생성합니다.
///
/// 개선 사항 (v3):
/// - ThemeStyle 시스템: 색상 + 레이아웃/구도를 함께 정의
/// - 타이틀 슬라이드 5가지 변형: bar_left, top_band, center_bold, diagonal, minimal
/// - 콘텐츠 슬라이드 4가지 변형: header_bar, card, minimal, split_accent
/// - 섹션 슬라이드 3가지 변형: full_color, centered_line, side_accent
/// - 장식 원형 도형, 카드 박스 지원
/// - 기본 폰트: Noto Sans KR
/// - 진짜 OpenXML 테이블, 불릿 포인트, 16:9 와이드스크린
/// - 20가지 테마 (각각 고유한 색상 + 레이아웃 조합)
///
public class PptxSkill : IAgentTool
{
public string Name => "pptx_create";
public string Description =>
"Create a high-quality PowerPoint (.pptx) presentation. " +
"Layouts: title (title+subtitle), content (title+bullet body), " +
"section (chapter divider), two_column (title+left+right), " +
"table (title+real styled table), quote (styled quotation slide), blank, " +
"image_full (full-slide background image), " +
"executive_summary (consulting-style headline and takeaways), " +
"recommendation (single recommendation with rationale), " +
"roadmap (phase timeline), comparison (option comparison cards), " +
"kpi_dashboard (metric cards and takeaways), " +
"chart (native OpenXML chart — bar/line/pie with embedded data). " +
"Built-in themes (each controls BOTH colors AND layout/composition): " +
"professional (bar_left), modern (top_band+card), dark (center_bold), " +
"minimal (minimal), vibrant (diagonal+split_accent), navy (bar_left+side_accent), " +
"ocean (top_band+card, centered), forest (bar_left+header_bar), " +
"earth (top_band+card), nordic (bar_left+minimal), " +
"sunset (diagonal+split_accent), rose (top_band+card), " +
"amber (bar_left+header_bar), crimson (center_bold), " +
"slate (minimal), indigo (center_bold+card), " +
"emerald (bar_left+header_bar), corporate (top_band+header_bar), " +
"pastel (minimal+card), midnight (center_bold+split_accent). " +
"Custom theme: set theme='custom' and provide custom_colors object. " +
"Template theme: set theme_file to an existing .pptx path to clone its master/layout AND extract colors (full fidelity). " +
"High-quality templates (set template parameter): " +
"basic100, core100, frame_blue, mr_ppt_01, mr_ppt_02, mr_ppt_03, mr_ppt_04, mr_ppt_05. " +
"Templates clone the original master/layout for maximum fidelity. " +
"Slide cloning: set clone_slides=true to copy ALL pages from template/theme_file (with text replacements). " +
"Per-slide cloning: set source_slide=N to clone page N from the source as a template (with per-slide replacements). " +
"Image support: each slide can embed a local image file via 'image' property. " +
"Built-in icons: use 'icon' property on slides for common symbols (checkmark, warning, " +
"info, star, arrow_right, arrow_up, gear, lightbulb, heart, target, shield, cloud, " +
"chart, document, folder, clock, people, phone, email, plus, minus, rocket, medal, " +
"thumbs_up, calendar, lock, unlock, refresh, search, home, flag). " +
"No external runtime required (native OpenXML). Default font: Noto Sans KR.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["path"] = new() { Type = "string", Description = "Output file path (.pptx). Relative to work folder." },
["title"] = new() { Type = "string", Description = "Presentation title." },
["slides"] = new()
{
Type = "array",
Description =
"Array of slide objects. Each slide: " +
"{\"layout\": \"title|content|section|two_column|table|quote|blank|image_full|chart|executive_summary|recommendation|roadmap|comparison|kpi_dashboard\", " +
"\"title\": \"...\", \"subtitle\": \"...\", " +
"\"headline\": \"single-message headline for the slide\", " +
"\"body\": \"3-5 concise evidence-backed bullets or short statements\", " +
"\"left\": \"...\", \"right\": \"...\", " +
"\"summary_points\": [\"key point 1\", \"key point 2\"], " +
"\"recommendation\": \"recommended action or decision\", " +
"\"next_steps\": [\"step 1\", \"step 2\"], " +
"\"phases\": [{\"title\":\"Phase 1\",\"detail\":\"Design\",\"timeline\":\"Q1\",\"owner\":\"PM\"}], " +
"\"options\": [{\"name\":\"Option A\",\"pros\":\"...\",\"cons\":\"...\",\"verdict\":\"Recommended\"}], " +
"\"kpis\": [{\"label\":\"Revenue\",\"value\":\"12%\",\"trend\":\"YoY\",\"note\":\"Top-line growth\"}], " +
"\"headers\": [\"col1\",\"col2\"], \"rows\": [[\"a\",\"b\"]], " +
"\"quote\": \"Quote text\", \"author\": \"Author\", " +
"\"image\": \"path/to/image.png (local file, embedded in slide; position: top_right|bottom_right|center|full|left_half|right_half; default: center)\", " +
"\"image_position\": \"top_right|bottom_right|top_left|bottom_left|center|full|left_half|right_half\", " +
"\"icon\": \"built-in icon name (checkmark, warning, info, star, arrow_right, gear, lightbulb, heart, target, shield, etc.)\", " +
"\"icon_position\": \"top_right|bottom_right|top_left|bottom_left|center (default: top_right)\", " +
"\"icon_color\": \"hex color WITHOUT # (default: theme accent)\", " +
"\"icon_size\": \"small|medium|large (default: medium)\", " +
"\"chart_type\": \"bar|line|pie (for layout=chart)\", " +
"\"chart_labels\": [\"Q1\",\"Q2\",\"Q3\",\"Q4\"] (category labels), " +
"\"chart_values\": [100,200,150,300] (data values), " +
"\"chart_series_name\": \"매출\" (series name, default: 'Series1'), " +
"\"source_slide\": 1 (clone page N from template/theme_file as base, then overlay content; 1-based index), " +
"\"replacements\": {\"원본텍스트\": \"교체텍스트\"} (per-slide text replacements for source_slide), " +
"\"notes\": \"Speaker notes\"}",
Items = new() { Type = "object" }
},
["theme"] = new()
{
Type = "string",
Description = "Built-in theme name (controls colors AND layout/composition), or 'custom' to use custom_colors. " +
"Built-in: professional, modern, dark, minimal, vibrant, navy, " +
"ocean, forest, sunset, rose, slate, amber, indigo, emerald, crimson, " +
"corporate, pastel, midnight, earth, nordic. Default: basic100. " +
"Recommendation: basic100 (기본 비즈니스), corporate (기업 보고서), modern (일반), vibrant (컬러풀), dark (다크 모드)",
},
["template"] = new()
{
Type = "string",
Description = "High-quality template name. Clones the master/layout from the template .pptx for maximum visual fidelity. " +
"Available: basic100, core100, frame_blue, mr_ppt_01, mr_ppt_02, mr_ppt_03, mr_ppt_04, mr_ppt_05. " +
"Overrides 'theme' and 'theme_file'. Falls back to color extraction if master clone fails.",
},
["theme_file"] = new()
{
Type = "string",
Description = "Path to an existing .pptx file. Clones the master/layout AND extracts theme colors " +
"(same fidelity as 'template'). Use this to replicate ANY custom .pptx design. " +
"Overrides 'theme' if both are specified.",
},
["custom_colors"] = new()
{
Type = "object",
Description = "Custom color set when theme='custom'. All values are hex strings WITHOUT '#'. " +
"Fields: primary, accent, text_dark, text_light, bg, bg_alt, header_bg, header_text. " +
"Example: {\"primary\":\"1F4E79\",\"accent\":\"2E75B6\",\"text_dark\":\"1A1A2E\",...}",
},
["clone_slides"] = new()
{
Type = "boolean",
Description = "When true AND template/theme_file is set, clones ALL original slides from the source .pptx first, " +
"then appends any slides defined in the 'slides' array after them. " +
"Combine with 'replacements' to substitute text in the cloned slides.",
},
["replacements"] = new()
{
Type = "object",
Description = "Text replacement map for cloned slides (used with clone_slides=true). " +
"Keys = original text to find, Values = new text. Case-sensitive exact match. " +
"Example: {\"회사명\": \"AX Corp\", \"2024년\": \"2025년\", \"이름\": \"홍길동\"}. " +
"Applied to ALL text runs across ALL cloned slides.",
},
["aspect"] = new()
{
Type = "string",
Description = "Slide aspect ratio. Default: widescreen (16:9)",
Enum = ["widescreen", "standard"]
},
},
Required = ["slides"]
};
// ── 색상 레코드 ────────────────────────────────────────────────────────────
// Primary, Accent, TextDark, TextLight, Bg, BgAlt, HeaderBg, HeaderText
private record ThemeColors(
string Primary, string Accent,
string TextDark, string TextLight,
string Bg, string BgAlt,
string HeaderBg, string HeaderText);
// ── 레이아웃/구도 레코드 ──────────────────────────────────────────────────
private record ThemeLayout(
// 타이틀 슬라이드 변형
string TitleVariant, // "bar_left" | "top_band" | "center_bold" | "diagonal" | "minimal"
// 내용 슬라이드 변형
string ContentVariant, // "header_bar" | "card" | "minimal" | "split_accent"
// 섹션 슬라이드 변형
string SectionVariant, // "full_color" | "centered_line" | "side_accent"
// 텍스트 정렬
string TitleAlign, // "l" | "ctr" | "r"
// 액센트 바 위치
string AccentPos, // "left" | "top" | "bottom" | "right" | "none"
int AccentThickPct, // 두께 % of slide dimension (1~15)
// 타이포그래피 (100분의 1 pt 단위)
int TitlePt, // 타이틀 슬라이드 제목 (기본 4400 = 44pt)
int SubtitlePt, // 부제목 (기본 2000)
int SlideTitlePt, // 슬라이드 헤더 제목 (기본 2800)
int BodyPt, // 본문 텍스트 (기본 1600)
// 장식
bool HasDecorCircle, // 장식 원형 도형 추가 여부
bool ContentHasCard // 콘텐츠 영역에 박스 카드 여부
);
// ── 통합 테마 레코드 ──────────────────────────────────────────────────────
private record FullTheme(ThemeColors Colors, ThemeLayout Layout);
// ── 고품질 템플릿 레지스트리 ────────────────────────────────────────────────
// 이름 → Assets/ppt/ 아래 파일명 매핑. 실행 시 디스크에서 열어 마스터 복제.
// 토큰 컨텍스트 영향 0 — 바이너리 파일은 LLM에 전달되지 않음.
private static readonly Dictionary TemplateRegistry = new(StringComparer.OrdinalIgnoreCase)
{
["basic100"] = "BASIC100 기준 템플릿 V1.pptx",
["core100"] = "CORE100 기준템플릿 V1.pptx",
["frame_blue"] = "P01_01_프레임디자인_PPT템플릿_블루(PPT_편집용).pptx",
["mr_ppt_01"] = "미스터 피피티 템플릿 01_원본.pptx",
["mr_ppt_02"] = "미스터 피피티 템플릿 02_원본.pptx",
["mr_ppt_03"] = "미스터 피피티 03_원본.pptx",
["mr_ppt_04"] = "미스터 피피티 템플릿 04_원본.pptx",
["mr_ppt_05"] = "미스터 피피티 템플릿_05_원본.pptx",
};
///
/// 템플릿 이름으로 .pptx 파일 전체 경로를 반환합니다.
/// 설치파일 용량 문제로 템플릿은 빌드에 포함하지 않으며,
/// 여러 알려진 경로를 순서대로 탐색합니다.
///
private static string? ResolveTemplatePath(string templateName)
{
if (!TemplateRegistry.TryGetValue(templateName, out var fileName))
return null;
var candidates = new List();
var exeDir = AppContext.BaseDirectory;
// 1) 실행 파일 옆 Assets/ppt/ (수동 배치)
candidates.Add(Path.Combine(exeDir, "Assets", "ppt", fileName));
// 2) %APPDATA%/AXCopilot/templates/ppt/ (사용자 설치)
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
if (!string.IsNullOrEmpty(appData))
candidates.Add(Path.Combine(appData, "AXCopilot", "templates", "ppt", fileName));
// 3) 소스 프로젝트 디렉토리 (개발 환경 — bin/Release/net8.0-windows... 기준)
candidates.Add(Path.GetFullPath(Path.Combine(exeDir, "..", "..", "..", "Assets", "ppt", fileName)));
// 4) 워킹 디렉토리/src/AxCopilot/Assets/ppt/ (개발 환경 루트에서 실행)
candidates.Add(Path.Combine("src", "AxCopilot", "Assets", "ppt", fileName));
// 5) 프로젝트 루트 추정 (E:\AX Copilot - Claude 등)
candidates.Add(Path.GetFullPath(Path.Combine(exeDir, "..", "..", "..", "..", "..", "Assets", "ppt", fileName)));
foreach (var path in candidates)
{
if (File.Exists(path))
return Path.GetFullPath(path);
}
return null;
}
// ── 20가지 테마 사전 ──────────────────────────────────────────────────────
private static readonly Dictionary FullThemes = new(StringComparer.OrdinalIgnoreCase)
{
// ── 1. professional ─ 좌측 바, 헤더 바, 전통적인 비즈니스
["professional"] = new(
new("1F4E79", "2E75B6", "1A1A2E", "FFFFFF", "FFFFFF", "F2F7FC", "1F4E79", "FFFFFF"),
new(TitleVariant: "bar_left", ContentVariant: "header_bar", SectionVariant: "full_color",
TitleAlign: "l", AccentPos: "left", AccentThickPct: 5,
TitlePt: 4400, SubtitlePt: 2000, SlideTitlePt: 2800, BodyPt: 2000,
HasDecorCircle: false, ContentHasCard: false)),
// ── 2. modern ─ 상단 밴드, 카드 콘텐츠, 청록색 계열
["modern"] = new(
new("0D9488", "0891B2", "1A1A2E", "FFFFFF", "FFFFFF", "F0FDFA", "0D9488", "FFFFFF"),
new(TitleVariant: "top_band", ContentVariant: "card", SectionVariant: "centered_line",
TitleAlign: "l", AccentPos: "top", AccentThickPct: 4,
TitlePt: 4200, SubtitlePt: 1900, SlideTitlePt: 2600, BodyPt: 2000,
HasDecorCircle: false, ContentHasCard: true)),
// ── 3. dark ─ 중앙 굵은 제목, 어두운 배경, 장식 원
["dark"] = new(
new("1E293B", "6366F1", "F1F5F9", "FFFFFF", "0F172A", "1E293B", "374151", "F1F5F9"),
new(TitleVariant: "center_bold", ContentVariant: "minimal", SectionVariant: "full_color",
TitleAlign: "ctr", AccentPos: "bottom", AccentThickPct: 3,
TitlePt: 4600, SubtitlePt: 2000, SlideTitlePt: 2800, BodyPt: 2000,
HasDecorCircle: true, ContentHasCard: false)),
// ── 4. minimal ─ 최소한의 장식, 깔끔한 흰 배경
["minimal"] = new(
new("374151", "3B82F6", "111827", "FFFFFF", "FFFFFF", "F9FAFB", "F3F4F6", "111827"),
new(TitleVariant: "minimal", ContentVariant: "minimal", SectionVariant: "centered_line",
TitleAlign: "l", AccentPos: "bottom", AccentThickPct: 2,
TitlePt: 5000, SubtitlePt: 2000, SlideTitlePt: 2800, BodyPt: 2000,
HasDecorCircle: false, ContentHasCard: false)),
// ── 5. vibrant ─ 대각선 분할, 사이드 액센트, 생동감 있는 색상
["vibrant"] = new(
new("7C3AED", "EC4899", "1A1A2E", "FFFFFF", "FFFFFF", "FAF5FF", "7C3AED", "FFFFFF"),
new(TitleVariant: "diagonal", ContentVariant: "split_accent", SectionVariant: "full_color",
TitleAlign: "l", AccentPos: "left", AccentThickPct: 8,
TitlePt: 4400, SubtitlePt: 2000, SlideTitlePt: 2600, BodyPt: 2000,
HasDecorCircle: true, ContentHasCard: false)),
// ── 6. navy ─ 좌측 바, 사이드 액센트 섹션, 네이비 블루
["navy"] = new(
new("0F2744", "1E6FBF", "1A1A2E", "FFFFFF", "FFFFFF", "EEF4FB", "0F2744", "FFFFFF"),
new(TitleVariant: "bar_left", ContentVariant: "header_bar", SectionVariant: "side_accent",
TitleAlign: "l", AccentPos: "left", AccentThickPct: 6,
TitlePt: 4200, SubtitlePt: 1900, SlideTitlePt: 2800, BodyPt: 2000,
HasDecorCircle: false, ContentHasCard: false)),
// ── 7. ocean ─ 상단 밴드, 카드, 중앙 정렬, 청색 계열
["ocean"] = new(
new("0369A1", "06B6D4", "0C4A6E", "FFFFFF", "FFFFFF", "F0F9FF", "0369A1", "FFFFFF"),
new(TitleVariant: "top_band", ContentVariant: "card", SectionVariant: "centered_line",
TitleAlign: "ctr", AccentPos: "top", AccentThickPct: 4,
TitlePt: 4200, SubtitlePt: 1900, SlideTitlePt: 2600, BodyPt: 2000,
HasDecorCircle: false, ContentHasCard: true)),
// ── 8. forest ─ 좌측 바, 헤더 바, 초록 자연
["forest"] = new(
new("166534", "22C55E", "14532D", "FFFFFF", "FFFFFF", "F0FDF4", "166534", "FFFFFF"),
new(TitleVariant: "bar_left", ContentVariant: "header_bar", SectionVariant: "full_color",
TitleAlign: "l", AccentPos: "left", AccentThickPct: 5,
TitlePt: 4400, SubtitlePt: 2000, SlideTitlePt: 2800, BodyPt: 2000,
HasDecorCircle: false, ContentHasCard: false)),
// ── 9. earth ─ 상단 밴드, 카드, 따뜻한 흙색
["earth"] = new(
new("78350F", "D97706", "1C1917", "FFFFFF", "FEFCE8", "FEF3C7", "78350F", "FFFFFF"),
new(TitleVariant: "top_band", ContentVariant: "card", SectionVariant: "full_color",
TitleAlign: "l", AccentPos: "top", AccentThickPct: 4,
TitlePt: 4200, SubtitlePt: 1900, SlideTitlePt: 2600, BodyPt: 2000,
HasDecorCircle: false, ContentHasCard: true)),
// ── 10. nordic ─ 좌측 바, 미니멀 콘텐츠, 북유럽 스타일
["nordic"] = new(
new("1E3A5F", "4FC3F7", "1A2332", "FFFFFF", "F0F4F8", "E1EAF2", "1E3A5F", "FFFFFF"),
new(TitleVariant: "bar_left", ContentVariant: "minimal", SectionVariant: "centered_line",
TitleAlign: "l", AccentPos: "left", AccentThickPct: 4,
TitlePt: 4400, SubtitlePt: 2000, SlideTitlePt: 2800, BodyPt: 2000,
HasDecorCircle: false, ContentHasCard: false)),
// ── 11. sunset ─ 대각선 분할, 사이드 액센트, 따뜻한 오렌지
["sunset"] = new(
new("C2410C", "F59E0B", "1A1A2E", "FFFFFF", "FFFFFF", "FFF7ED", "C2410C", "FFFFFF"),
new(TitleVariant: "diagonal", ContentVariant: "split_accent", SectionVariant: "full_color",
TitleAlign: "l", AccentPos: "left", AccentThickPct: 7,
TitlePt: 4400, SubtitlePt: 2000, SlideTitlePt: 2600, BodyPt: 2000,
HasDecorCircle: true, ContentHasCard: false)),
// ── 12. rose ─ 상단 밴드, 카드, 로즈 핑크
["rose"] = new(
new("9D174D", "F43F5E", "1A1A2E", "FFFFFF", "FFFFFF", "FFF1F2", "9D174D", "FFFFFF"),
new(TitleVariant: "top_band", ContentVariant: "card", SectionVariant: "centered_line",
TitleAlign: "l", AccentPos: "top", AccentThickPct: 4,
TitlePt: 4200, SubtitlePt: 1900, SlideTitlePt: 2600, BodyPt: 2000,
HasDecorCircle: false, ContentHasCard: true)),
// ── 13. amber ─ 좌측 바, 헤더 바, 황금 호박색
["amber"] = new(
new("92400E", "F59E0B", "1C1917", "FFFFFF", "FFFBEB", "FEF3C7", "92400E", "FFFFFF"),
new(TitleVariant: "bar_left", ContentVariant: "header_bar", SectionVariant: "full_color",
TitleAlign: "l", AccentPos: "left", AccentThickPct: 5,
TitlePt: 4200, SubtitlePt: 2000, SlideTitlePt: 2800, BodyPt: 2000,
HasDecorCircle: false, ContentHasCard: false)),
// ── 14. crimson ─ 중앙 굵은 제목, 장식 원, 크림슨 레드
["crimson"] = new(
new("7F1D1D", "EF4444", "1A1A2E", "FFFFFF", "FFFFFF", "FEF2F2", "7F1D1D", "FFFFFF"),
new(TitleVariant: "center_bold", ContentVariant: "minimal", SectionVariant: "full_color",
TitleAlign: "ctr", AccentPos: "bottom", AccentThickPct: 3,
TitlePt: 4600, SubtitlePt: 2000, SlideTitlePt: 2800, BodyPt: 2000,
HasDecorCircle: true, ContentHasCard: false)),
// ── 15. slate ─ 미니멀, 심플한 슬레이트 그레이
["slate"] = new(
new("334155", "64748B", "0F172A", "FFFFFF", "FFFFFF", "F8FAFC", "334155", "FFFFFF"),
new(TitleVariant: "minimal", ContentVariant: "minimal", SectionVariant: "centered_line",
TitleAlign: "l", AccentPos: "bottom", AccentThickPct: 2,
TitlePt: 4800, SubtitlePt: 2000, SlideTitlePt: 2800, BodyPt: 2000,
HasDecorCircle: false, ContentHasCard: false)),
// ── 16. indigo ─ 중앙 굵은 제목, 카드 콘텐츠, 인디고 블루
["indigo"] = new(
new("3730A3", "6366F1", "1E1B4B", "FFFFFF", "FFFFFF", "EEF2FF", "3730A3", "FFFFFF"),
new(TitleVariant: "center_bold", ContentVariant: "card", SectionVariant: "full_color",
TitleAlign: "ctr", AccentPos: "bottom", AccentThickPct: 3,
TitlePt: 4600, SubtitlePt: 2000, SlideTitlePt: 2800, BodyPt: 2000,
HasDecorCircle: true, ContentHasCard: true)),
// ── 17. emerald ─ 좌측 바, 헤더 바, 에메랄드 그린
["emerald"] = new(
new("065F46", "10B981", "022C22", "FFFFFF", "FFFFFF", "ECFDF5", "065F46", "FFFFFF"),
new(TitleVariant: "bar_left", ContentVariant: "header_bar", SectionVariant: "side_accent",
TitleAlign: "l", AccentPos: "left", AccentThickPct: 5,
TitlePt: 4400, SubtitlePt: 2000, SlideTitlePt: 2800, BodyPt: 2000,
HasDecorCircle: false, ContentHasCard: false)),
// ── 18. corporate ─ 상단 밴드, 헤더 바, 골드 포인트 기업 스타일
["corporate"] = new(
new("1E3A5F", "B8860B", "1A1A2E", "FFFFFF", "FFFFFF", "F5F5F0", "1E3A5F", "FFFFFF"),
new(TitleVariant: "top_band", ContentVariant: "header_bar", SectionVariant: "side_accent",
TitleAlign: "l", AccentPos: "top", AccentThickPct: 4,
TitlePt: 4200, SubtitlePt: 1900, SlideTitlePt: 2800, BodyPt: 2000,
HasDecorCircle: false, ContentHasCard: false)),
// ── 19. pastel ─ 미니멀, 카드 콘텐츠, 밝고 부드러운 파스텔
["pastel"] = new(
new("6B7280", "A78BFA", "374151", "FFFFFF", "FAFAFA", "F3F4F6", "E5E7EB", "374151"),
new(TitleVariant: "minimal", ContentVariant: "card", SectionVariant: "centered_line",
TitleAlign: "l", AccentPos: "bottom", AccentThickPct: 2,
TitlePt: 4600, SubtitlePt: 2000, SlideTitlePt: 2600, BodyPt: 2000,
HasDecorCircle: false, ContentHasCard: true)),
// ── 20. midnight ─ 중앙 굵은 제목, 사이드 액센트, 미드나잇 다크
["midnight"] = new(
new("0F0F1A", "818CF8", "E2E8F0", "FFFFFF", "0A0A14", "13131F", "1E1E35", "E2E8F0"),
new(TitleVariant: "center_bold", ContentVariant: "split_accent", SectionVariant: "full_color",
TitleAlign: "ctr", AccentPos: "bottom", AccentThickPct: 3,
TitlePt: 4600, SubtitlePt: 2000, SlideTitlePt: 2800, BodyPt: 2000,
HasDecorCircle: true, ContentHasCard: false)),
// ══════════════════════════════════════════════════════════════════════
// 고품질 템플릿에서 추출한 내장 테마 (Assets/ppt/*.pptx 색상 메타데이터)
// 원본 .pptx가 있으면 마스터 복제(고품질), 없으면 아래 색상으로 자체 생성
// ══════════════════════════════════════════════════════════════════════
// ── T1. basic100 ─ 모던 블루 (BASIC100 기준 템플릿)
// dk1=000000 dk2=44546A acc1=2572EF acc2=1F4B99 shape=C8806B
["basic100"] = new(
new("44546A", "2572EF", "000000", "FFFFFF", "FFFFFF", "E7E6E6", "44546A", "FFFFFF"),
new(TitleVariant: "bar_left", ContentVariant: "header_bar", SectionVariant: "full_color",
TitleAlign: "l", AccentPos: "left", AccentThickPct: 5,
TitlePt: 4400, SubtitlePt: 2000, SlideTitlePt: 2800, BodyPt: 2000,
HasDecorCircle: false, ContentHasCard: false)),
// ── T2. core100 ─ 딥 블루 (CORE100 기준 템플릿)
// dk1=000000 dk2=44546A acc1=2572EF acc2=1F4B99 shape=266DF1,70AD47
["core100"] = new(
new("44546A", "266DF1", "000000", "FFFFFF", "FFFFFF", "E7E6E6", "1F4B99", "FFFFFF"),
new(TitleVariant: "top_band", ContentVariant: "header_bar", SectionVariant: "full_color",
TitleAlign: "l", AccentPos: "top", AccentThickPct: 4,
TitlePt: 4400, SubtitlePt: 2000, SlideTitlePt: 2800, BodyPt: 2000,
HasDecorCircle: false, ContentHasCard: false)),
// ── T3. frame_blue ─ 프레임 블루 (P01 프레임디자인 블루)
// dk2=44546A acc1=4472C4 shape=126BF6,F6F7FA
["frame_blue"] = new(
new("44546A", "126BF6", "000000", "FFFFFF", "FFFFFF", "F6F7FA", "44546A", "FFFFFF"),
new(TitleVariant: "bar_left", ContentVariant: "card", SectionVariant: "centered_line",
TitleAlign: "l", AccentPos: "left", AccentThickPct: 4,
TitlePt: 4400, SubtitlePt: 2000, SlideTitlePt: 2800, BodyPt: 2000,
HasDecorCircle: false, ContentHasCard: true)),
// ── T4. mr_ppt_01 ─ 미스터 피피티 01 (다크 네이비+화이트)
// acc2=0C0C0C acc3=0049F0
["mr_ppt_01"] = new(
new("1A1A2E", "0049F0", "0C0C0C", "FFFFFF", "FFFFFF", "F5F5F7", "1A1A2E", "FFFFFF"),
new(TitleVariant: "center_bold", ContentVariant: "header_bar", SectionVariant: "full_color",
TitleAlign: "ctr", AccentPos: "bottom", AccentThickPct: 3,
TitlePt: 4600, SubtitlePt: 2000, SlideTitlePt: 2800, BodyPt: 2000,
HasDecorCircle: false, ContentHasCard: false)),
// ── T5. mr_ppt_02 ─ 미스터 피피티 02 (블루+그레이)
// acc2=262626 acc3=2269F7 shape=4472C4
["mr_ppt_02"] = new(
new("262626", "2269F7", "262626", "FFFFFF", "FFFFFF", "F2F2F2", "2269F7", "FFFFFF"),
new(TitleVariant: "top_band", ContentVariant: "card", SectionVariant: "centered_line",
TitleAlign: "l", AccentPos: "top", AccentThickPct: 4,
TitlePt: 4400, SubtitlePt: 2000, SlideTitlePt: 2800, BodyPt: 2000,
HasDecorCircle: false, ContentHasCard: true)),
// ── T6. mr_ppt_03 ─ 미스터 피피티 03 (네이비+골드)
// acc2=333F5E acc3=F4BB05
["mr_ppt_03"] = new(
new("333F5E", "F4BB05", "333F5E", "FFFFFF", "FFFFFF", "F5F5F7", "333F5E", "FFFFFF"),
new(TitleVariant: "bar_left", ContentVariant: "header_bar", SectionVariant: "side_accent",
TitleAlign: "l", AccentPos: "left", AccentThickPct: 5,
TitlePt: 4400, SubtitlePt: 2000, SlideTitlePt: 2800, BodyPt: 2000,
HasDecorCircle: false, ContentHasCard: false)),
// ── T7. mr_ppt_04 ─ 미스터 피피티 04 (딥 인디고+스카이블루)
// acc2=232669 acc3=0583F2 shape=ACB0C0
["mr_ppt_04"] = new(
new("232669", "0583F2", "232669", "FFFFFF", "FFFFFF", "EBECF0", "232669", "FFFFFF"),
new(TitleVariant: "diagonal", ContentVariant: "split_accent", SectionVariant: "full_color",
TitleAlign: "l", AccentPos: "left", AccentThickPct: 5,
TitlePt: 4400, SubtitlePt: 2000, SlideTitlePt: 2800, BodyPt: 2000,
HasDecorCircle: true, ContentHasCard: false)),
// ── T8. mr_ppt_05 ─ 미스터 피피티 05 (모던 블랙+블루)
// acc2=3B3B3B acc3=007AF9
["mr_ppt_05"] = new(
new("3B3B3B", "007AF9", "3B3B3B", "FFFFFF", "FFFFFF", "F5F5F7", "3B3B3B", "FFFFFF"),
new(TitleVariant: "minimal", ContentVariant: "minimal", SectionVariant: "centered_line",
TitleAlign: "ctr", AccentPos: "bottom", AccentThickPct: 2,
TitlePt: 4600, SubtitlePt: 2000, SlideTitlePt: 2800, BodyPt: 2000,
HasDecorCircle: false, ContentHasCard: false)),
};
private const string FontKo = "Noto Sans KR";
private const string FontEn = "Noto Sans KR";
public async Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
{
var presTitle = args.SafeTryGetProperty("title", out var tt) ? tt.SafeGetString() ?? "Presentation" : "Presentation";
string path;
if (args.SafeTryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String
&& !string.IsNullOrWhiteSpace(pathEl.SafeGetString()))
{
path = pathEl.SafeGetString()!;
}
else
{
var safe = System.Text.RegularExpressions.Regex.Replace(presTitle, @"[\\/:*?""<>|]", "_").Trim().TrimEnd('.');
if (safe.Length > 60) safe = safe[..60].TrimEnd();
path = (string.IsNullOrWhiteSpace(safe) ? "presentation" : safe) + ".pptx";
}
var theme = args.SafeTryGetProperty("theme", out var th) ? th.SafeGetString() ?? "basic100" : "basic100";
var aspect = args.SafeTryGetProperty("aspect", out var asp) ? asp.SafeGetString() ?? "widescreen" : "widescreen";
var cloneAll = args.SafeTryGetProperty("clone_slides", out var cloneEl) && cloneEl.ValueKind == JsonValueKind.True;
// 전체 복제용 텍스트 교체 맵
Dictionary? globalReplacements = null;
if (args.SafeTryGetProperty("replacements", out var replEl) && replEl.ValueKind == JsonValueKind.Object)
{
globalReplacements = new Dictionary();
foreach (var prop in replEl.EnumerateObject())
{
if (prop.Value.ValueKind == JsonValueKind.String)
globalReplacements[prop.Name] = prop.Value.SafeGetString() ?? "";
}
}
var hasSlidesArray = args.SafeTryGetProperty("slides", out var slidesEl) && slidesEl.ValueKind == JsonValueKind.Array;
if (!hasSlidesArray && !cloneAll)
return ToolResult.Fail("slides 배열이 필요합니다.");
var fullPath = FileReadTool.ResolvePath(path, context.WorkFolder);
if (context.ActiveTab == "Cowork") fullPath = AgentContext.EnsureTimestampedPath(fullPath);
if (!fullPath.EndsWith(".pptx", StringComparison.OrdinalIgnoreCase))
fullPath += ".pptx";
if (!context.IsPathAllowed(fullPath))
return ToolResult.Fail($"경로 접근 차단: {fullPath}");
if (!await context.CheckWritePermissionAsync(Name, fullPath))
return ToolResult.Fail($"쓰기 권한 거부: {fullPath}");
var dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
// ── 테마 결정 우선순위: template > theme_file > custom_colors > theme 이름 ──
FullTheme fullTheme;
string? templatePptxPath = null; // 마스터 복제용 템플릿 경로 (null이면 자체 마스터 생성)
var templateName = args.SafeTryGetProperty("template", out var tplEl) ? tplEl.SafeGetString() : null;
if (!string.IsNullOrWhiteSpace(templateName))
{
// 고품질 템플릿: 마스터 복제 시도 → 실패 시 내장 메타데이터 폴백
templatePptxPath = ResolveTemplatePath(templateName!);
// 내장 테마에 템플릿 이름이 등록되어 있으면 해당 색상/레이아웃 사용
if (FullThemes.TryGetValue(templateName!, out var templateTheme))
fullTheme = templateTheme;
else if (templatePptxPath != null)
{
// 미등록 템플릿이지만 파일이 있으면 색상 추출
var extracted = ExtractThemeFromPptx(templatePptxPath);
var baseLayout = FullThemes["professional"].Layout;
fullTheme = extracted != null
? new FullTheme(extracted, baseLayout)
: FullThemes["professional"];
}
else
{
fullTheme = FullThemes["professional"];
}
}
else if (args.SafeTryGetProperty("theme_file", out var tfEl) && !string.IsNullOrEmpty(tfEl.SafeGetString()))
{
// 사용자 지정 PPTX: 마스터 복제 시도 + 색상 추출 (template과 동일 전략)
var tfPath = FileReadTool.ResolvePath(tfEl.SafeGetString()!, context.WorkFolder);
if (File.Exists(tfPath))
templatePptxPath = tfPath; // 마스터 복제 대상으로 설정
var extracted = ExtractThemeFromPptx(tfPath);
var baseLayout = FullThemes["professional"].Layout;
fullTheme = extracted != null
? new FullTheme(extracted, baseLayout)
: FullThemes["professional"];
}
else if (string.Equals(theme, "custom", StringComparison.OrdinalIgnoreCase)
&& args.SafeTryGetProperty("custom_colors", out var ccEl)
&& ccEl.ValueKind == JsonValueKind.Object)
{
// 사용자 지정 색상 → default layout (professional)
static string Hex(JsonElement obj, string key, string fallback) =>
obj.SafeTryGetProperty(key, out var v) ? (v.SafeGetString() ?? fallback).TrimStart('#') : fallback;
var customColors = new ThemeColors(
Primary: Hex(ccEl, "primary", "1F4E79"),
Accent: Hex(ccEl, "accent", "2E75B6"),
TextDark: Hex(ccEl, "text_dark", "1A1A2E"),
TextLight: Hex(ccEl, "text_light", "FFFFFF"),
Bg: Hex(ccEl, "bg", "FFFFFF"),
BgAlt: Hex(ccEl, "bg_alt", "F2F7FC"),
HeaderBg: Hex(ccEl, "header_bg", "1F4E79"),
HeaderText: Hex(ccEl, "header_text", "FFFFFF"));
fullTheme = new FullTheme(customColors, FullThemes["professional"].Layout);
}
else
{
if (!FullThemes.TryGetValue(theme ?? "professional", out fullTheme!))
fullTheme = FullThemes["professional"];
}
// 슬라이드 치수 (EMU: 1 inch = 914,400 EMU)
var isWide = !string.Equals(aspect, "standard", StringComparison.OrdinalIgnoreCase);
var slideW = isWide ? 12192000L : 9144000L;
var slideH = 6858000L;
try
{
var cloneInfo_srcLabel = templatePptxPath != null ? Path.GetFileName(templatePptxPath) : null;
var cloneInfo_cloned = false;
int slideCount_final = 0;
using (var pres = PresentationDocument.Create(fullPath, PresentationDocumentType.Presentation))
{
var presPart = pres.AddPresentationPart();
presPart.Presentation = new P.Presentation();
SlideLayoutPart layoutPart;
var clonedFromTemplate = false;
// ── 마스터 복제 또는 자체 생성 ─────────────────────────────────
if (templatePptxPath != null && TryCloneMasterFromTemplate(templatePptxPath, presPart, out layoutPart!))
{
clonedFromTemplate = true;
}
else
{
// 자체 마스터 생성 (기존 방식)
var masterPart = presPart.AddNewPart();
masterPart.SlideMaster = new SlideMaster(
new CommonSlideData(new ShapeTree(
new P.NonVisualGroupShapeProperties(
new P.NonVisualDrawingProperties { Id = 1, Name = "" },
new P.NonVisualGroupShapeDrawingProperties(),
new ApplicationNonVisualDrawingProperties()),
new GroupShapeProperties(new A.TransformGroup()))),
new P.ColorMap
{
Background1 = A.ColorSchemeIndexValues.Light1,
Text1 = A.ColorSchemeIndexValues.Dark1,
Background2 = A.ColorSchemeIndexValues.Light2,
Text2 = A.ColorSchemeIndexValues.Dark2,
Accent1 = A.ColorSchemeIndexValues.Accent1,
Accent2 = A.ColorSchemeIndexValues.Accent2,
Accent3 = A.ColorSchemeIndexValues.Accent3,
Accent4 = A.ColorSchemeIndexValues.Accent4,
Accent5 = A.ColorSchemeIndexValues.Accent5,
Accent6 = A.ColorSchemeIndexValues.Accent6,
Hyperlink = A.ColorSchemeIndexValues.Hyperlink,
FollowedHyperlink = A.ColorSchemeIndexValues.FollowedHyperlink,
},
new SlideLayoutIdList());
layoutPart = masterPart.AddNewPart();
layoutPart.SlideLayout = new SlideLayout(
new CommonSlideData(new ShapeTree(
new P.NonVisualGroupShapeProperties(
new P.NonVisualDrawingProperties { Id = 1, Name = "" },
new P.NonVisualGroupShapeDrawingProperties(),
new ApplicationNonVisualDrawingProperties()),
new GroupShapeProperties(new A.TransformGroup()))))
{ Type = SlideLayoutValues.Blank, Preserve = true };
layoutPart.AddPart(masterPart);
masterPart.SlideMaster.SlideLayoutIdList!.AppendChild(new SlideLayoutId
{
Id = 2147483649U,
RelationshipId = masterPart.GetIdOfPart(layoutPart)
});
presPart.Presentation.SlideMasterIdList = new SlideMasterIdList(
new SlideMasterId { Id = 2147483648U, RelationshipId = presPart.GetIdOfPart(masterPart) });
// ThemePart 생성 — PowerPoint 필수 요소 (없으면 파일이 깨짐)
var tc = fullTheme.Colors;
var themePart = masterPart.AddNewPart();
themePart.Theme = new A.Theme(
new A.ThemeElements(
new A.ColorScheme(
new A.Dark1Color(new A.SystemColor { Val = A.SystemColorValues.WindowText, LastColor = "000000" }),
new A.Light1Color(new A.SystemColor { Val = A.SystemColorValues.Window, LastColor = "FFFFFF" }),
new A.Dark2Color(new A.RgbColorModelHex { Val = tc.TextDark.TrimStart('#') }),
new A.Light2Color(new A.RgbColorModelHex { Val = tc.BgAlt.TrimStart('#') }),
new A.Accent1Color(new A.RgbColorModelHex { Val = tc.Primary.TrimStart('#') }),
new A.Accent2Color(new A.RgbColorModelHex { Val = tc.Accent.TrimStart('#') }),
new A.Accent3Color(new A.RgbColorModelHex { Val = tc.HeaderBg.TrimStart('#') }),
new A.Accent4Color(new A.RgbColorModelHex { Val = tc.Accent.TrimStart('#') }),
new A.Accent5Color(new A.RgbColorModelHex { Val = tc.Primary.TrimStart('#') }),
new A.Accent6Color(new A.RgbColorModelHex { Val = tc.Accent.TrimStart('#') }),
new A.Hyperlink(new A.RgbColorModelHex { Val = "0563C1" }),
new A.FollowedHyperlinkColor(new A.RgbColorModelHex { Val = "954F72" })
) { Name = "Custom" },
new A.FontScheme(
new A.MajorFont(new A.LatinFont { Typeface = "Noto Sans KR" },
new A.EastAsianFont { Typeface = "Noto Sans KR" },
new A.ComplexScriptFont { Typeface = "Noto Sans KR" }),
new A.MinorFont(new A.LatinFont { Typeface = "Noto Sans KR" },
new A.EastAsianFont { Typeface = "Noto Sans KR" },
new A.ComplexScriptFont { Typeface = "Noto Sans KR" })
) { Name = "Custom" },
new A.FormatScheme(
new A.FillStyleList(
new A.SolidFill(new A.SchemeColor { Val = A.SchemeColorValues.PhColor }),
new A.SolidFill(new A.SchemeColor { Val = A.SchemeColorValues.PhColor }),
new A.SolidFill(new A.SchemeColor { Val = A.SchemeColorValues.PhColor })),
new A.LineStyleList(
new A.Outline(new A.SolidFill(new A.SchemeColor { Val = A.SchemeColorValues.PhColor })) { Width = 9525 },
new A.Outline(new A.SolidFill(new A.SchemeColor { Val = A.SchemeColorValues.PhColor })) { Width = 9525 },
new A.Outline(new A.SolidFill(new A.SchemeColor { Val = A.SchemeColorValues.PhColor })) { Width = 9525 }),
new A.EffectStyleList(
new A.EffectStyle(new A.EffectList()),
new A.EffectStyle(new A.EffectList()),
new A.EffectStyle(new A.EffectList())),
new A.BackgroundFillStyleList(
new A.SolidFill(new A.SchemeColor { Val = A.SchemeColorValues.PhColor }),
new A.SolidFill(new A.SchemeColor { Val = A.SchemeColorValues.PhColor }),
new A.SolidFill(new A.SchemeColor { Val = A.SchemeColorValues.PhColor }))
) { Name = "Custom" }
),
new A.ObjectDefaults(),
new A.ExtraColorSchemeList()
) { Name = "Custom Theme" };
themePart.Theme.Save();
masterPart.SlideMaster.Save();
layoutPart.SlideLayout.Save();
}
presPart.Presentation.SlideIdList = new SlideIdList();
presPart.Presentation.SlideSize = new SlideSize
{
Cx = (Int32Value)(int)slideW,
Cy = (Int32Value)(int)slideH,
Type = isWide ? SlideSizeValues.Screen16x9 : SlideSizeValues.Screen4x3,
};
presPart.Presentation.NotesSize = new NotesSize { Cx = 6858000, Cy = 9144000 };
// ── 슬라이드 생성 ────────────────────────────────────────────────
uint slideId = 256;
int slideCount = 0;
const long M = 450000; // 공통 마진 (이미지/아이콘 배치에도 사용)
var workFolder = context.WorkFolder;
// ── 방법 1: clone_slides=true → 원본 슬라이드 전체 복제 ─────────
if (cloneAll && templatePptxPath != null)
{
var clonedCount = CloneAllSlidesFromSource(
templatePptxPath, presPart, layoutPart, globalReplacements,
ref slideId, ref slideCount);
if (clonedCount > 0)
Services.LogService.Info($"원본 슬라이드 {clonedCount}장 복제 완료 ({Path.GetFileName(templatePptxPath)})");
}
if (hasSlidesArray)
foreach (var slideEl in slidesEl.EnumerateArray())
{
// ── 방법 2: source_slide → 원본의 특정 페이지를 복제 후 내용 교체 ──
if (slideEl.SafeTryGetProperty("source_slide", out var srcSlideEl)
&& srcSlideEl.ValueKind == JsonValueKind.Number)
{
var srcIdx = srcSlideEl.GetInt32() - 1; // 1-based → 0-based
if (templatePptxPath != null)
{
var cloned = CloneSingleSlideFromSource(
templatePptxPath, srcIdx, presPart, layoutPart,
slideEl, ref slideId, ref slideCount);
if (cloned) continue; // 복제 성공 → 다음 슬라이드로
}
// 복제 실패 시 일반 슬라이드 생성으로 폴백
}
var layout = slideEl.SafeTryGetProperty("layout", out var lay) ? lay.SafeGetString() ?? "content" : "content";
var slidePart = presPart.AddNewPart();
slidePart.AddPart(layoutPart);
var shapeTree = new ShapeTree(
new P.NonVisualGroupShapeProperties(
new P.NonVisualDrawingProperties { Id = 1, Name = "" },
new P.NonVisualGroupShapeDrawingProperties(),
new ApplicationNonVisualDrawingProperties()),
new GroupShapeProperties(new A.TransformGroup()));
slidePart.Slide = new Slide(new CommonSlideData(shapeTree));
uint sid = 2;
switch (layout.ToLowerInvariant())
{
case "title":
BuildTitleSlide(shapeTree, slideEl, fullTheme, slideW, slideH, ref sid);
break;
case "section":
BuildSectionSlide(shapeTree, slideEl, fullTheme, slideW, slideH, ref sid);
break;
case "quote":
BuildQuoteSlide(shapeTree, slideEl, fullTheme.Colors, slideW, slideH, ref sid);
break;
case "two_column":
BuildTwoColumnSlide(shapeTree, slideEl, fullTheme.Colors, slideW, slideH, ref sid);
break;
case "table":
BuildTableSlide(shapeTree, slideEl, fullTheme.Colors, slideW, slideH, ref sid);
break;
case "image_full":
// 이미지 전용 슬라이드 — 이미지가 슬라이드 전체를 채움
if (slideEl.SafeTryGetProperty("image", out var imgFullEl))
{
var imgFullPath = ResolveImagePath(imgFullEl.SafeGetString(), workFolder);
if (imgFullPath != null)
AddImage(slidePart, shapeTree, ref sid, imgFullPath, 0, 0, slideW, slideH);
}
// 이미지 위에 반투명 텍스트 오버레이
var imgTitle = Str(slideEl, "title");
var imgSub = Str(slideEl, "subtitle");
if (!string.IsNullOrWhiteSpace(imgTitle))
{
// 반투명 어두운 바
AddRect(shapeTree, ref sid, 0, slideH - 2000000, slideW, 2000000, "000000");
// 위에 그린 바의 알파 설정은 복잡하므로 텍스트만 밝게
AddText(shapeTree, ref sid, M, slideH - 1800000, slideW - M * 2, 900000,
imgTitle, 3200, "FFFFFF", bold: true, align: "l");
if (!string.IsNullOrWhiteSpace(imgSub))
AddText(shapeTree, ref sid, M, slideH - 900000, slideW - M * 2, 600000,
imgSub, 1800, "DDDDDD", bold: false, align: "l");
}
break;
case "chart":
BuildChartSlide(slidePart, shapeTree, slideEl, fullTheme, slideW, slideH, ref sid);
break;
case "executive_summary":
BuildExecutiveSummarySlide(shapeTree, slideEl, fullTheme, slideW, slideH, ref sid);
break;
case "recommendation":
BuildRecommendationSlide(shapeTree, slideEl, fullTheme, slideW, slideH, ref sid);
break;
case "roadmap":
BuildRoadmapSlide(shapeTree, slideEl, fullTheme, slideW, slideH, ref sid);
break;
case "comparison":
BuildComparisonSlide(shapeTree, slideEl, fullTheme, slideW, slideH, ref sid);
break;
case "kpi_dashboard":
BuildKpiDashboardSlide(shapeTree, slideEl, fullTheme, slideW, slideH, ref sid);
break;
case "blank":
break;
default: // content
BuildContentSlide(shapeTree, slideEl, fullTheme, slideW, slideH, ref sid);
break;
}
// ── 슬라이드별 이미지 삽입 (image_full 이외의 레이아웃) ──────────
if (layout.ToLowerInvariant() != "image_full"
&& slideEl.SafeTryGetProperty("image", out var imgEl))
{
var imgPath = ResolveImagePath(imgEl.SafeGetString(), workFolder);
if (imgPath != null)
{
var imgPos = Str(slideEl, "image_position").ToLowerInvariant();
if (string.IsNullOrEmpty(imgPos)) imgPos = "center";
var (ix, iy, iw, ih) = CalcImagePlacement(imgPos, slideW, slideH, imgPath);
AddImage(slidePart, shapeTree, ref sid, imgPath, ix, iy, iw, ih);
}
}
// ── 슬라이드별 내장 아이콘 삽입 ──────────────────────────────────
if (slideEl.SafeTryGetProperty("icon", out var iconEl))
{
var iconName = iconEl.SafeGetString()?.ToLowerInvariant() ?? "";
if (!string.IsNullOrEmpty(iconName))
{
var iconPos = Str(slideEl, "icon_position").ToLowerInvariant();
if (string.IsNullOrEmpty(iconPos)) iconPos = "top_right";
var iconColor = Str(slideEl, "icon_color");
if (string.IsNullOrEmpty(iconColor)) iconColor = fullTheme.Colors.Accent;
var iconSizeStr = Str(slideEl, "icon_size").ToLowerInvariant();
var iconSize = iconSizeStr switch
{
"small" => 500000L,
"large" => 1200000L,
_ => 800000L // medium
};
var (icx, icy) = CalcIconPosition(iconPos, slideW, slideH, iconSize);
AddBuiltInIcon(shapeTree, ref sid, iconName, icx, icy, iconSize, iconColor);
}
}
// 발표자 노트
if (slideEl.SafeTryGetProperty("notes", out var notesEl) &&
notesEl.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(notesEl.SafeGetString()))
{
AddNotesSlide(slidePart, notesEl.SafeGetString()!);
}
slidePart.Slide.Save();
presPart.Presentation.SlideIdList.AppendChild(new SlideId
{
Id = slideId++,
RelationshipId = presPart.GetIdOfPart(slidePart)
});
slideCount++;
}
presPart.Presentation.Save();
cloneInfo_cloned = clonedFromTemplate;
slideCount_final = slideCount;
pres.Save();
} // using pres — 문서 닫힘
// ── [Content_Types].xml 복구 ─────────────────────────────────────
// OpenXML SDK가 에 presentation.main 타입을
// 등록하는 버그가 있어 PowerPoint이 파일을 열지 못함.
// Default를 application/xml로 수정하고 presentation.xml에 Override 추가.
RepairContentTypes(fullPath);
var themeLabel = cloneInfo_cloned
? $"template:{templateName ?? cloneInfo_srcLabel} (master cloned{(cloneAll ? " + slides cloned" : "")})"
: !string.IsNullOrWhiteSpace(templateName)
? $"template:{templateName} (color fallback)"
: templatePptxPath != null
? $"theme_file:{cloneInfo_srcLabel} (master cloned{(cloneAll ? " + slides cloned" : "")})"
: theme;
return ToolResult.Ok(
$"✅ PPTX 생성 완료: {fullPath} ({slideCount_final}슬라이드, {themeLabel}, {(isWide ? "16:9" : "4:3")})",
fullPath);
}
catch (Exception ex)
{
if (File.Exists(fullPath)) try { File.Delete(fullPath); } catch { }
return ToolResult.Fail($"PPTX 생성 실패: {ex.Message}");
}
}
// ══════════════════════════════════════════════════════════════════════════
// [Content_Types].xml 복구 — OpenXML SDK 버그 우회
// ══════════════════════════════════════════════════════════════════════════
///
/// OpenXML SDK가 <Default Extension="xml">에 presentation.main 타입을 등록하는
/// 버그를 수정합니다. PowerPoint은 이 잘못된 Default 때문에 파일을 열지 못합니다.
/// 수정: Default xml → application/xml, presentation.xml에 Override 추가.
///
private static void RepairContentTypes(string pptxPath)
{
try
{
const string presMainType = "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml";
using var zipStream = new FileStream(pptxPath, FileMode.Open, FileAccess.ReadWrite);
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Update);
var ctEntry = archive.GetEntry("[Content_Types].xml");
if (ctEntry == null) return;
string ctXml;
using (var reader = new StreamReader(ctEntry.Open()))
ctXml = reader.ReadToEnd();
// Default Extension="xml"이 presentation.main 타입인지 확인
if (!ctXml.Contains(presMainType)) return; // 이미 정상
var doc = XDocument.Parse(ctXml);
var ns = XNamespace.Get("http://schemas.openxmlformats.org/package/2006/content-types");
var changed = false;
// 1. 의 ContentType을 application/xml로 수정
foreach (var def in doc.Descendants(ns + "Default").ToList())
{
var ext = def.Attribute("Extension")?.Value;
var ct = def.Attribute("ContentType")?.Value;
if (string.Equals(ext, "xml", StringComparison.OrdinalIgnoreCase)
&& string.Equals(ct, presMainType, StringComparison.OrdinalIgnoreCase))
{
def.SetAttributeValue("ContentType", "application/xml");
changed = true;
}
}
// 2. presentation.xml에 대한 Override가 없으면 추가
var hasPresOverride = doc.Descendants(ns + "Override")
.Any(o => string.Equals(o.Attribute("PartName")?.Value, "/ppt/presentation.xml",
StringComparison.OrdinalIgnoreCase));
if (!hasPresOverride)
{
doc.Root!.Add(new XElement(ns + "Override",
new XAttribute("PartName", "/ppt/presentation.xml"),
new XAttribute("ContentType", presMainType)));
changed = true;
}
if (!changed) return;
// 수정된 [Content_Types].xml을 다시 쓰기
ctEntry.Delete();
var newEntry = archive.CreateEntry("[Content_Types].xml", CompressionLevel.Optimal);
using (var writer = new StreamWriter(newEntry.Open(), Encoding.UTF8))
{
writer.Write("");
// XDocument.ToString()은 XML declaration을 포함하지 않으므로 직접 Root만 작성
writer.Write(doc.Root!.ToString(SaveOptions.DisableFormatting));
}
Services.LogService.Info("[Content_Types].xml 복구 완료 — Default xml 타입을 application/xml로 수정");
}
catch (Exception ex)
{
Services.LogService.Warn($"[Content_Types].xml 복구 실패: {ex.Message}");
}
}
// ══════════════════════════════════════════════════════════════════════════
// 슬라이드 레이아웃 빌더
// ══════════════════════════════════════════════════════════════════════════
private static void BuildTitleSlide(ShapeTree t, JsonElement s, FullTheme theme, long W, long H, ref uint id)
{
var c = theme.Colors;
var lay = theme.Layout;
var title = Str(s, "title");
var subtitle = Str(s, "subtitle");
switch (lay.TitleVariant)
{
case "top_band":
BuildTitleTopBand(t, ref id, c, lay, title, subtitle, W, H);
break;
case "center_bold":
BuildTitleCenterBold(t, ref id, c, lay, title, subtitle, W, H);
break;
case "diagonal":
BuildTitleDiagonal(t, ref id, c, lay, title, subtitle, W, H);
break;
case "minimal":
BuildTitleMinimal(t, ref id, c, lay, title, subtitle, W, H);
break;
default: // bar_left
BuildTitleBarLeft(t, ref id, c, lay, title, subtitle, W, H);
break;
}
}
// ── 타이틀 변형: bar_left ─────────────────────────────────────────────────
// 전체 Primary 배경 + 좌측 세로 Accent 바 + 하단 미세 라인
private static void BuildTitleBarLeft(ShapeTree t, ref uint id,
ThemeColors c, ThemeLayout lay, string title, string subtitle, long W, long H)
{
AddRect(t, ref id, 0, 0, W, H, c.Primary);
long barW = W * lay.AccentThickPct / 100;
AddRect(t, ref id, 0, 0, barW, H, c.Accent);
AddRect(t, ref id, barW, H - 8000, W - barW, 8000, c.Accent);
long textX = barW + 250000;
long textW = W - barW - 400000;
long textY = H * 28 / 100;
AddText(t, ref id, textX, textY, textW, H / 5,
title, lay.TitlePt, c.TextLight, bold: true, align: lay.TitleAlign);
if (!string.IsNullOrWhiteSpace(subtitle))
AddText(t, ref id, textX, textY + H / 5 + 120000, textW, H / 6,
subtitle, lay.SubtitlePt, c.Accent, bold: false, align: lay.TitleAlign);
}
// ── 타이틀 변형: top_band ─────────────────────────────────────────────────
// 흰/Bg 배경 + 상단 컬러 밴드 (20% 높이) + 밴드 하단 Accent 라인 + 제목 아래 위치
private static void BuildTitleTopBand(ShapeTree t, ref uint id,
ThemeColors c, ThemeLayout lay, string title, string subtitle, long W, long H)
{
// 배경
AddRect(t, ref id, 0, 0, W, H, c.Bg);
// 상단 밴드
long bandH = H * 20 / 100;
AddRect(t, ref id, 0, 0, W, bandH, c.Primary);
// 밴드 하단 Accent 라인
long lineH = (long)(H * lay.AccentThickPct / 100.0 * 0.3);
if (lineH < 6000) lineH = 6000;
AddRect(t, ref id, 0, bandH, W, lineH, c.Accent);
long textY = bandH + lineH + H * 5 / 100;
long textX = W / 12;
long textW = W * 10 / 12;
AddText(t, ref id, textX, textY, textW, H / 4,
title, lay.TitlePt, c.Primary, bold: true, align: lay.TitleAlign);
if (!string.IsNullOrWhiteSpace(subtitle))
AddText(t, ref id, textX, textY + H / 4 + 80000, textW, H / 6,
subtitle, lay.SubtitlePt, c.Accent, bold: false, align: lay.TitleAlign);
}
// ── 타이틀 변형: center_bold ──────────────────────────────────────────────
// 전체 Primary 배경 + 선택적 장식 원 + 중앙 큰 제목 + Accent 구분선 + 부제목
private static void BuildTitleCenterBold(ShapeTree t, ref uint id,
ThemeColors c, ThemeLayout lay, string title, string subtitle, long W, long H)
{
AddRect(t, ref id, 0, 0, W, H, c.Primary);
if (lay.HasDecorCircle)
{
// 우측 상단 큰 반투명 장식 원
long circSize = H * 90 / 100;
AddEllipse(t, ref id, W - circSize / 2, -circSize / 4, circSize, circSize, c.Accent, alpha: 15);
}
long accentLineW = W * 40 / 100;
long accentLineX = (W - accentLineW) / 2;
long centerY = H * 35 / 100;
AddText(t, ref id, W / 8, centerY - H / 10, W * 6 / 8, H / 4,
title, lay.TitlePt, c.TextLight, bold: true, align: "ctr");
// Accent 하단 수평선 (중앙, 40% 너비)
long lineH = H * lay.AccentThickPct / 100 / 3;
if (lineH < 6000) lineH = 6000;
AddRect(t, ref id, accentLineX, centerY + H / 7, accentLineW, lineH, c.Accent);
if (!string.IsNullOrWhiteSpace(subtitle))
AddText(t, ref id, W / 8, centerY + H / 7 + lineH + 80000, W * 6 / 8, H / 6,
subtitle, lay.SubtitlePt, c.Accent, bold: false, align: "ctr");
}
// ── 타이틀 변형: diagonal ─────────────────────────────────────────────────
// 좌측 40% Primary 채움 + 우측 Bg 배경 + 좌측 장식 원 + 우측에 제목/부제목
private static void BuildTitleDiagonal(ShapeTree t, ref uint id,
ThemeColors c, ThemeLayout lay, string title, string subtitle, long W, long H)
{
// 전체 배경
AddRect(t, ref id, 0, 0, W, H, c.Bg);
// 좌측 컬러 블록 (40%)
long leftW = W * 40 / 100;
AddRect(t, ref id, 0, 0, leftW, H, c.Primary);
// 대각 스트라이프 효과: 얇고 기울어진 느낌을 근사하는 직사각형 오버랩
// 실제 OpenXML 회전은 spPr.Transform2D.Rotation으로 표현(단위: 1/60000도)
// 여기서는 삼각형 모양의 사각형을 오른쪽으로 약간 확장하여 경계를 만듦
long accentW = W * lay.AccentThickPct / 100;
AddRect(t, ref id, leftW - accentW / 2, 0, accentW, H, c.Accent);
if (lay.HasDecorCircle)
{
// 좌측 중앙 장식 원
long circSize = leftW * 60 / 100;
AddEllipse(t, ref id, leftW / 2 - circSize / 2, H / 2 - circSize / 2, circSize, circSize, c.Accent, alpha: 20);
}
long textX = leftW + accentW / 2 + 200000;
long textW = W - textX - 200000;
long textY = H * 28 / 100;
AddText(t, ref id, textX, textY, textW, H / 4,
title, lay.TitlePt, c.Primary, bold: true, align: "l");
if (!string.IsNullOrWhiteSpace(subtitle))
AddText(t, ref id, textX, textY + H / 4 + 100000, textW, H / 6,
subtitle, lay.SubtitlePt, c.Accent, bold: false, align: "l");
}
// ── 타이틀 변형: minimal ──────────────────────────────────────────────────
// 흰 배경 + 하단 Accent 라인만 + 대형 굵은 제목 + 부제목
private static void BuildTitleMinimal(ShapeTree t, ref uint id,
ThemeColors c, ThemeLayout lay, string title, string subtitle, long W, long H)
{
AddRect(t, ref id, 0, 0, W, H, c.Bg);
long lineH = H * lay.AccentThickPct / 100;
if (lineH < 8000) lineH = 8000;
AddRect(t, ref id, 0, H - lineH, W, lineH, c.Accent);
long textX = W / 12;
long textW = W * 10 / 12;
long textY = H * 22 / 100;
AddText(t, ref id, textX, textY, textW, H * 35 / 100,
title, lay.TitlePt, c.Primary, bold: true, align: lay.TitleAlign);
if (!string.IsNullOrWhiteSpace(subtitle))
AddText(t, ref id, textX, textY + H * 35 / 100 + 80000, textW, H / 6,
subtitle, lay.SubtitlePt, c.TextDark, bold: false, align: lay.TitleAlign);
}
// ══════════════════════════════════════════════════════════════════════════
// 섹션 슬라이드 빌더
// ══════════════════════════════════════════════════════════════════════════
private static void BuildSectionSlide(ShapeTree t, JsonElement s, FullTheme theme, long W, long H, ref uint id)
{
var c = theme.Colors;
var lay = theme.Layout;
var title = Str(s, "title");
var subtitle = Str(s, "subtitle");
switch (lay.SectionVariant)
{
case "centered_line":
BuildSectionCenteredLine(t, ref id, c, lay, title, subtitle, W, H);
break;
case "side_accent":
BuildSectionSideAccent(t, ref id, c, lay, title, subtitle, W, H);
break;
default: // full_color
BuildSectionFullColor(t, ref id, c, lay, title, subtitle, W, H);
break;
}
}
// ── 섹션 변형: full_color ─────────────────────────────────────────────────
// 전체 Primary 배경 + 가로 Accent 스트라이프 + 중앙 제목/부제목
private static void BuildSectionFullColor(ShapeTree t, ref uint id,
ThemeColors c, ThemeLayout lay, string title, string subtitle, long W, long H)
{
AddRect(t, ref id, 0, 0, W, H, c.Primary);
AddRect(t, ref id, W / 8, H / 2 - 5000, W * 3 / 4, 10000, c.Accent);
AddText(t, ref id, W / 8, H / 4, W * 3 / 4, H / 3,
title, lay.SlideTitlePt + 800, c.TextLight, bold: true, align: "ctr");
if (!string.IsNullOrWhiteSpace(subtitle))
AddText(t, ref id, W / 8, H * 58 / 100, W * 3 / 4, H / 6,
subtitle, lay.SubtitlePt, c.Accent, bold: false, align: "ctr");
}
// ── 섹션 변형: centered_line ──────────────────────────────────────────────
// 흰 배경 + 중앙 큰 Primary 색 제목 + Accent 언더라인 + 부제목
private static void BuildSectionCenteredLine(ShapeTree t, ref uint id,
ThemeColors c, ThemeLayout lay, string title, string subtitle, long W, long H)
{
AddRect(t, ref id, 0, 0, W, H, c.Bg);
long textY = H * 28 / 100;
long lineW = W * 30 / 100;
long lineX = (W - lineW) / 2;
long lineH = 10000;
AddText(t, ref id, W / 8, textY, W * 6 / 8, H / 4,
title, lay.SlideTitlePt + 800, c.Primary, bold: true, align: "ctr");
AddRect(t, ref id, lineX, textY + H / 4 + 40000, lineW, lineH, c.Accent);
if (!string.IsNullOrWhiteSpace(subtitle))
AddText(t, ref id, W / 8, textY + H / 4 + lineH + 120000, W * 6 / 8, H / 6,
subtitle, lay.SubtitlePt, c.TextDark, bold: false, align: "ctr");
}
// ── 섹션 변형: side_accent ────────────────────────────────────────────────
// 흰 배경 + 좌측 10% Primary 스트라이프 + 우측에 큰 제목/부제목
private static void BuildSectionSideAccent(ShapeTree t, ref uint id,
ThemeColors c, ThemeLayout lay, string title, string subtitle, long W, long H)
{
AddRect(t, ref id, 0, 0, W, H, c.Bg);
long stripW = W / 10;
AddRect(t, ref id, 0, 0, stripW, H, c.Primary);
long textX = stripW + 200000;
long textW = W - textX - 200000;
long textY = H * 30 / 100;
AddText(t, ref id, textX, textY, textW, H / 3,
title, lay.SlideTitlePt + 1000, c.Primary, bold: true, align: "l");
if (!string.IsNullOrWhiteSpace(subtitle))
AddText(t, ref id, textX, textY + H / 3 + 80000, textW, H / 6,
subtitle, lay.SubtitlePt, c.Accent, bold: false, align: "l");
}
// ══════════════════════════════════════════════════════════════════════════
// 콘텐츠 슬라이드 빌더
// ══════════════════════════════════════════════════════════════════════════
private static void BuildContentSlide(ShapeTree t, JsonElement s, FullTheme theme, long W, long H, ref uint id)
{
var c = theme.Colors;
var lay = theme.Layout;
var title = Str(s, "title");
var body = Str(s, "body");
switch (lay.ContentVariant)
{
case "card":
BuildContentCard(t, ref id, c, lay, title, body, W, H);
break;
case "split_accent":
BuildContentSplitAccent(t, ref id, c, lay, title, body, W, H);
break;
case "minimal":
BuildContentMinimal(t, ref id, c, lay, title, body, W, H);
break;
default: // header_bar
BuildContentHeaderBar(t, ref id, c, lay, title, body, W, H);
break;
}
}
// ── 콘텐츠 변형: header_bar ───────────────────────────────────────────────
// 상단 헤더 바 (Primary 색) + 제목 텍스트 + 하단 본문
private static void BuildContentHeaderBar(ShapeTree t, ref uint id,
ThemeColors c, ThemeLayout lay, string title, string body, long W, long H)
{
const long M = 450000;
long hdrH = 1100000L;
// 헤더 배경 바
AddRect(t, ref id, 0, 0, W, hdrH, c.Primary);
// 헤더 하단 Accent 라인
AddRect(t, ref id, 0, hdrH, W, 12000, c.Accent);
AddText(t, ref id, M, 0, W - M * 2, hdrH,
title, lay.SlideTitlePt, c.TextLight, bold: true, align: "l");
long bodyY = hdrH + 12000 + 80000;
AddBulletBody(t, ref id, M, bodyY, W - M * 2, H - bodyY - M,
body, lay.BodyPt, c.TextDark, c.Accent);
}
// ── 콘텐츠 변형: card ─────────────────────────────────────────────────────
// 흰 배경 + 제목 + Accent 언더라인 + 내용을 BgAlt 카드 박스 안에
private static void BuildContentCard(ShapeTree t, ref uint id,
ThemeColors c, ThemeLayout lay, string title, string body, long W, long H)
{
const long M = 450000;
AddRect(t, ref id, 0, 0, W, H, c.Bg);
// 제목
AddText(t, ref id, M, 180000, W - M * 2, 840000,
title, lay.SlideTitlePt, c.Primary, bold: true, align: "l");
// 제목 하단 Accent 언더라인
long underlineW = W * 35 / 100;
AddRect(t, ref id, M, 1060000, underlineW, 10000, c.Accent);
// 콘텐츠 카드 박스 (BgAlt 배경의 둥근 모서리 사각형)
long cardY = 1120000;
long cardH = H - cardY - M;
AddRoundedRect(t, ref id, M, cardY, W - M * 2, cardH, c.BgAlt);
// 카드 안 본문
AddBulletBody(t, ref id, M + 150000, cardY + 120000, W - M * 2 - 300000, cardH - 240000,
body, lay.BodyPt, c.TextDark, c.Accent);
}
// ── 콘텐츠 변형: minimal ──────────────────────────────────────────────────
// 흰 배경 + 제목 (어두운 색) + 하단 Accent 라인 + 본문 바로 아래
private static void BuildContentMinimal(ShapeTree t, ref uint id,
ThemeColors c, ThemeLayout lay, string title, string body, long W, long H)
{
const long M = 450000;
AddRect(t, ref id, 0, 0, W, H, c.Bg);
AddText(t, ref id, M, 200000, W - M * 2, 900000,
title, lay.SlideTitlePt, c.Primary, bold: true, align: "l");
AddRect(t, ref id, M, 1140000, W - M * 2, 8000, c.Accent);
AddBulletBody(t, ref id, M, 1200000, W - M * 2, H - 1200000 - M,
body, lay.BodyPt, c.TextDark, c.Accent);
}
// ── 콘텐츠 변형: split_accent ─────────────────────────────────────────────
// 흰 배경 + 좌측 Accent 스트라이프 + 우측에 제목/본문
private static void BuildContentSplitAccent(ShapeTree t, ref uint id,
ThemeColors c, ThemeLayout lay, string title, string body, long W, long H)
{
const long M = 450000;
AddRect(t, ref id, 0, 0, W, H, c.Bg);
long stripW = W * lay.AccentThickPct / 100;
AddRect(t, ref id, M, M, stripW, H - M * 2, c.Accent);
long textX = M + stripW + 160000;
long textW = W - textX - M;
AddText(t, ref id, textX, 200000, textW, 900000,
title, lay.SlideTitlePt, c.Primary, bold: true, align: "l");
AddRect(t, ref id, textX, 1140000, textW, 8000, c.Primary);
AddBulletBody(t, ref id, textX, 1200000, textW, H - 1200000 - M,
body, lay.BodyPt, c.TextDark, c.Accent);
}
// ══════════════════════════════════════════════════════════════════════════
// 기타 레이아웃 빌더 (두 컬럼, 인용구, 테이블)
// ══════════════════════════════════════════════════════════════════════════
private static void BuildTwoColumnSlide(ShapeTree t, JsonElement s, ThemeColors c, long W, long H, ref uint id)
{
var title = Str(s, "title");
var left = Str(s, "left");
var right = Str(s, "right");
const long M = 450000;
const long gap = 250000;
long colW = (W - M * 2 - gap) / 2;
long colY = 1200000;
long hdrH = 380000;
AddRect(t, ref id, M, 1150000, W - M * 2, 10000, c.Accent);
AddText(t, ref id, M, 220000, W - M * 2, 900000,
title, 2800, c.Primary, bold: true, align: "l");
// 왼쪽 컬럼 헤더 배경
AddRect(t, ref id, M, colY, colW, hdrH, c.Primary);
// 오른쪽 컬럼 헤더 배경
AddRect(t, ref id, M + colW + gap, colY, colW, hdrH, c.Accent);
// 왼쪽 불릿 본문
AddBulletBody(t, ref id, M, colY + hdrH + 60000, colW, H - colY - hdrH - M - 60000,
left, 1500, c.TextDark, c.Primary);
// 오른쪽 불릿 본문
AddBulletBody(t, ref id, M + colW + gap, colY + hdrH + 60000, colW, H - colY - hdrH - M - 60000,
right, 1500, c.TextDark, c.Accent);
}
private static void BuildQuoteSlide(ShapeTree t, JsonElement s, ThemeColors c, long W, long H, ref uint id)
{
var quote = Str(s, "quote");
var author = Str(s, "author");
AddRect(t, ref id, 0, 0, W, H, c.BgAlt);
AddRect(t, ref id, W / 10, H / 5, W / 100, H * 3 / 5, c.Accent);
AddText(t, ref id, W / 10 + 60000, H / 10, W / 5, H / 4,
"\u201C", 9600, c.Primary, bold: true, align: "l");
AddTextEx(t, ref id, W / 10 + 180000, H / 4, W * 7 / 10, H * 2 / 4,
quote, 2200, c.TextDark, bold: false, italic: true, align: "l");
if (!string.IsNullOrWhiteSpace(author))
{
AddRect(t, ref id, W / 10 + 180000, H * 74 / 100, W / 8, 8000, c.Accent);
AddText(t, ref id, W / 10 + 180000 + W / 8 + 80000, H * 73 / 100, W / 2, 300000,
$"— {author}", 1600, c.Primary, bold: true, align: "l");
}
}
private static void BuildTableSlide(ShapeTree t, JsonElement s, ThemeColors c, long W, long H, ref uint id)
{
var title = Str(s, "title");
var headers = s.SafeTryGetProperty("headers", out var hEl)
? hEl.EnumerateArray().Select(x => x.SafeGetString() ?? "").ToList()
: new List();
var rows = s.SafeTryGetProperty("rows", out var rEl)
? rEl.EnumerateArray().Select(r => r.EnumerateArray().Select(c2 => c2.SafeGetString() ?? "").ToList()).ToList()
: new List>();
const long M = 450000;
AddRect(t, ref id, M, 1150000, W - M * 2, 10000, c.Accent);
AddText(t, ref id, M, 220000, W - M * 2, 900000,
title, 2800, c.Primary, bold: true, align: "l");
if (headers.Count > 0)
AddOpenXmlTable(t, ref id, headers, rows, c, M, 1260000, W - M * 2, H - 1260000 - M);
}
// ══════════════════════════════════════════════════════════════════════════
// 차트 슬라이드 빌더 (네이티브 OpenXML ChartPart)
// ══════════════════════════════════════════════════════════════════════════
///
/// 네이티브 OpenXML 차트를 포함하는 슬라이드를 생성합니다.
/// bar(세로 막대), line(꺾은선), pie(원형) 차트를 지원합니다.
/// ChartPart에 내장 스프레드시트 데이터를 포함하므로 PowerPoint에서 편집 가능합니다.
///
private sealed record PresentationCardItem(string Title, string Primary, string Secondary, string Badge);
private static void BuildExecutiveSummarySlide(ShapeTree t, JsonElement s, FullTheme theme, long W, long H, ref uint id)
{
var c = theme.Colors;
const long M = 360000;
AddRect(t, ref id, 0, 0, W, H, c.Bg);
AddRect(t, ref id, 0, 0, W, 170000, c.Accent);
var title = CoalesceText(Str(s, "title"), "Executive Summary");
var headline = CoalesceText(Str(s, "headline"), Str(s, "subtitle"), title);
var summaryPoints = GetStringList(s, "summary_points");
if (summaryPoints.Count == 0)
summaryPoints = GetStringList(s, "body");
if (summaryPoints.Count == 0)
summaryPoints = ["핵심 메시지를 3개 이하로 정리", "가장 중요한 수치와 근거를 포함", "의사결정자가 다음 행동을 바로 이해할 수 있도록 구성"];
var recommendation = CoalesceText(Str(s, "recommendation"), Str(s, "subtitle"), "권고안과 기대효과를 한 문장으로 정리");
var kpis = GetStructuredItems(s, "kpis");
AddText(t, ref id, M, 220000, W - M * 2, 420000, title, 2200, c.Accent, bold: true, align: "l");
AddText(t, ref id, M, 520000, W - M * 2, 760000, headline, 3200, c.Primary, bold: true, align: "l");
long leftX = M;
long topY = 1420000;
long gap = 220000;
long leftW = (W - M * 2 - gap) * 58 / 100;
long rightX = leftX + leftW + gap;
long rightW = W - M - rightX;
long topH = 2300000;
AddRoundedRect(t, ref id, leftX, topY, leftW, topH, c.BgAlt);
AddText(t, ref id, leftX + 120000, topY + 70000, leftW - 240000, 300000, "Key Takeaways", 1800, c.Primary, bold: true, align: "l");
AddBulletBody(t, ref id, leftX + 100000, topY + 360000, leftW - 200000, topH - 460000,
string.Join("\n", summaryPoints.Select(x => $"- {x}")), 1700, c.TextDark, c.Accent);
AddRoundedRect(t, ref id, rightX, topY, rightW, topH, c.Primary);
AddText(t, ref id, rightX + 120000, topY + 70000, rightW - 240000, 260000, "Recommendation", 1800, c.TextLight, bold: true, align: "l");
AddTextEx(t, ref id, rightX + 120000, topY + 420000, rightW - 240000, 1100000, recommendation, 2200, c.TextLight, bold: true, italic: false, align: "l");
var rationaleLines = GetStringList(s, "next_steps");
if (rationaleLines.Count == 0)
rationaleLines = GetStringList(s, "body").Take(2).ToList();
if (rationaleLines.Count > 0)
AddBulletBody(t, ref id, rightX + 100000, topY + 1500000, rightW - 200000, topH - 1620000,
string.Join("\n", rationaleLines.Select(x => $"- {x}")), 1500, c.TextLight, c.Accent);
if (kpis.Count > 0)
{
var metricCount = Math.Min(3, kpis.Count);
long cardY = topY + topH + 200000;
long cardGap = 180000;
long cardW = (W - M * 2 - cardGap * (metricCount - 1)) / metricCount;
long cardH = 1050000;
for (int i = 0; i < metricCount; i++)
{
var metric = kpis[i];
var cardX = M + i * (cardW + cardGap);
AddRoundedRect(t, ref id, cardX, cardY, cardW, cardH, c.BgAlt);
AddText(t, ref id, cardX + 90000, cardY + 70000, cardW - 180000, 220000, metric.Title, 1400, c.TextDark, bold: false, align: "l");
AddText(t, ref id, cardX + 90000, cardY + 260000, cardW - 180000, 330000, CoalesceText(metric.Primary, "-"), 2600, c.Primary, bold: true, align: "l");
AddText(t, ref id, cardX + 90000, cardY + 640000, cardW - 180000, 170000, CoalesceText(metric.Secondary, metric.Badge), 1300, c.Accent, bold: true, align: "l");
}
}
}
private static void BuildRecommendationSlide(ShapeTree t, JsonElement s, FullTheme theme, long W, long H, ref uint id)
{
var c = theme.Colors;
const long M = 380000;
AddRect(t, ref id, 0, 0, W, H, c.Bg);
var title = CoalesceText(Str(s, "title"), "Recommendation");
var recommendation = CoalesceText(Str(s, "recommendation"), Str(s, "headline"), Str(s, "subtitle"), "권고안을 한 문장으로 제시");
var reasons = GetStringList(s, "summary_points");
if (reasons.Count == 0)
reasons = GetStringList(s, "body");
var nextSteps = GetStringList(s, "next_steps");
AddText(t, ref id, M, 220000, W - M * 2, 360000, title, 2200, c.Accent, bold: true, align: "l");
AddText(t, ref id, M, 520000, W - M * 2, 420000, recommendation, 3000, c.Primary, bold: true, align: "l");
long mainY = 1220000;
long gap = 220000;
long leftW = (W - M * 2 - gap) * 52 / 100;
long rightX = M + leftW + gap;
long rightW = W - M - rightX;
long boxH = 2450000;
AddRoundedRect(t, ref id, M, mainY, leftW, boxH, c.Primary);
AddText(t, ref id, M + 120000, mainY + 80000, leftW - 240000, 240000, "Recommended Move", 1800, c.TextLight, bold: true, align: "l");
AddTextEx(t, ref id, M + 120000, mainY + 400000, leftW - 240000, 1200000, recommendation, 2400, c.TextLight, bold: true, italic: false, align: "l");
if (nextSteps.Count > 0)
{
AddText(t, ref id, M + 120000, mainY + 1780000, leftW - 240000, 180000, "Immediate Actions", 1600, c.Accent, bold: true, align: "l");
AddBulletBody(t, ref id, M + 100000, mainY + 1990000, leftW - 200000, boxH - 2090000,
string.Join("\n", nextSteps.Take(3).Select(x => $"- {x}")), 1450, c.TextLight, c.Accent);
}
AddRoundedRect(t, ref id, rightX, mainY, rightW, boxH, c.BgAlt);
AddText(t, ref id, rightX + 120000, mainY + 80000, rightW - 240000, 240000, "Why This Wins", 1800, c.Primary, bold: true, align: "l");
AddBulletBody(t, ref id, rightX + 100000, mainY + 360000, rightW - 200000, boxH - 480000,
string.Join("\n", reasons.Take(5).Select(x => $"- {x}")), 1600, c.TextDark, c.Accent);
}
private static void BuildRoadmapSlide(ShapeTree t, JsonElement s, FullTheme theme, long W, long H, ref uint id)
{
var c = theme.Colors;
const long M = 360000;
AddRect(t, ref id, 0, 0, W, H, c.Bg);
var title = CoalesceText(Str(s, "title"), "Implementation Roadmap");
var headline = CoalesceText(Str(s, "headline"), Str(s, "subtitle"), "단계별 우선순위와 산출물을 한 눈에 정리");
var phases = GetStructuredItems(s, "phases");
if (phases.Count == 0)
{
phases =
[
new("Phase 1", "진단 및 설계", "0-30일", "PM"),
new("Phase 2", "구현 및 검증", "30-60일", "Delivery"),
new("Phase 3", "확산 및 운영정착", "60-90일", "Business")
];
}
AddText(t, ref id, M, 220000, W - M * 2, 320000, title, 2200, c.Accent, bold: true, align: "l");
AddText(t, ref id, M, 500000, W - M * 2, 420000, headline, 2800, c.Primary, bold: true, align: "l");
AddRect(t, ref id, M, 1260000, W - M * 2, 12000, c.Accent);
var phaseCount = Math.Min(4, phases.Count);
long gap = 160000;
long laneY = 1540000;
long laneW = (W - M * 2 - gap * (phaseCount - 1)) / phaseCount;
long laneH = 2100000;
for (int i = 0; i < phaseCount; i++)
{
var phase = phases[i];
var laneX = M + i * (laneW + gap);
var fill = i % 2 == 0 ? c.BgAlt : c.Bg;
AddRoundedRect(t, ref id, laneX, laneY, laneW, laneH, fill);
AddRect(t, ref id, laneX, laneY, laneW, 160000, i % 2 == 0 ? c.Primary : c.Accent);
AddText(t, ref id, laneX + 90000, laneY + 220000, laneW - 180000, 240000, phase.Title, 1800, c.Primary, bold: true, align: "l");
AddText(t, ref id, laneX + 90000, laneY + 520000, laneW - 180000, 520000, CoalesceText(phase.Primary, "핵심 과업 정의"), 1500, c.TextDark, bold: false, align: "l");
AddText(t, ref id, laneX + 90000, laneY + 1200000, laneW - 180000, 180000, CoalesceText(phase.Secondary, "기간 미정"), 1350, c.Accent, bold: true, align: "l");
AddText(t, ref id, laneX + 90000, laneY + 1450000, laneW - 180000, 180000, CoalesceText(phase.Badge, "담당 미정"), 1250, c.TextDark, bold: false, align: "l");
}
}
private static void BuildComparisonSlide(ShapeTree t, JsonElement s, FullTheme theme, long W, long H, ref uint id)
{
var c = theme.Colors;
const long M = 340000;
AddRect(t, ref id, 0, 0, W, H, c.Bg);
var title = CoalesceText(Str(s, "title"), "Option Comparison");
var headline = CoalesceText(Str(s, "headline"), Str(s, "subtitle"), "대안별 장단점과 권고안을 비교");
var options = GetStructuredItems(s, "options");
if (options.Count == 0)
{
options =
[
new("Option A", "빠른 적용", "확장성 제한", "Fastest"),
new("Option B", "균형 잡힌 투자", "의사결정 필요", "Recommended"),
new("Option C", "장기 최적화", "리드타임 길음", "Strategic")
];
}
AddText(t, ref id, M, 220000, W - M * 2, 320000, title, 2200, c.Accent, bold: true, align: "l");
AddText(t, ref id, M, 500000, W - M * 2, 420000, headline, 2800, c.Primary, bold: true, align: "l");
var optionCount = Math.Min(3, options.Count);
long gap = 160000;
long cardY = 1360000;
long cardH = 2600000;
long cardW = (W - M * 2 - gap * (optionCount - 1)) / optionCount;
for (int i = 0; i < optionCount; i++)
{
var option = options[i];
var cardX = M + i * (cardW + gap);
AddRoundedRect(t, ref id, cardX, cardY, cardW, cardH, c.BgAlt);
AddRect(t, ref id, cardX, cardY, cardW, 140000, i == 1 ? c.Accent : c.Primary);
AddText(t, ref id, cardX + 90000, cardY + 220000, cardW - 180000, 240000, option.Title, 1800, c.Primary, bold: true, align: "l");
AddText(t, ref id, cardX + 90000, cardY + 560000, cardW - 180000, 180000, "Pros", 1400, c.Accent, bold: true, align: "l");
AddTextEx(t, ref id, cardX + 90000, cardY + 760000, cardW - 180000, 520000, CoalesceText(option.Primary, "-"), 1450, c.TextDark, bold: false, italic: false, align: "l");
AddText(t, ref id, cardX + 90000, cardY + 1480000, cardW - 180000, 180000, "Risks", 1400, c.Accent, bold: true, align: "l");
AddTextEx(t, ref id, cardX + 90000, cardY + 1680000, cardW - 180000, 460000, CoalesceText(option.Secondary, "-"), 1450, c.TextDark, bold: false, italic: false, align: "l");
AddText(t, ref id, cardX + 90000, cardY + 2280000, cardW - 180000, 160000, CoalesceText(option.Badge, ""), 1300, c.Primary, bold: true, align: "l");
}
}
private static void BuildKpiDashboardSlide(ShapeTree t, JsonElement s, FullTheme theme, long W, long H, ref uint id)
{
var c = theme.Colors;
const long M = 360000;
AddRect(t, ref id, 0, 0, W, H, c.Bg);
var title = CoalesceText(Str(s, "title"), "KPI Dashboard");
var headline = CoalesceText(Str(s, "headline"), Str(s, "subtitle"), "핵심 지표의 현재 상태와 시사점");
var metrics = GetStructuredItems(s, "kpis");
if (metrics.Count == 0)
{
metrics =
[
new("Revenue", "12%", "YoY", "Strong"),
new("Cost", "-8%", "vs plan", "Improving"),
new("NPS", "61", "survey", "Stable"),
new("Delivery", "94%", "SLA", "On track")
];
}
AddText(t, ref id, M, 220000, W - M * 2, 320000, title, 2200, c.Accent, bold: true, align: "l");
AddText(t, ref id, M, 500000, W - M * 2, 420000, headline, 2800, c.Primary, bold: true, align: "l");
long cardTop = 1320000;
long gap = 180000;
long cardW = (W - M * 2 - gap) / 2;
long cardH = 1160000;
for (int i = 0; i < Math.Min(4, metrics.Count); i++)
{
var metric = metrics[i];
long row = i / 2;
long col = i % 2;
long x = M + col * (cardW + gap);
long y = cardTop + row * (cardH + 180000);
AddRoundedRect(t, ref id, x, y, cardW, cardH, c.BgAlt);
AddText(t, ref id, x + 100000, y + 80000, cardW - 200000, 180000, metric.Title, 1450, c.TextDark, bold: false, align: "l");
AddText(t, ref id, x + 100000, y + 280000, cardW - 200000, 340000, CoalesceText(metric.Primary, "-"), 2800, c.Primary, bold: true, align: "l");
AddText(t, ref id, x + 100000, y + 700000, cardW - 200000, 150000, CoalesceText(metric.Secondary, ""), 1300, c.Accent, bold: true, align: "l");
AddText(t, ref id, x + 100000, y + 900000, cardW - 200000, 140000, CoalesceText(metric.Badge, ""), 1200, c.TextDark, bold: false, align: "l");
}
var takeawayLines = GetStringList(s, "summary_points");
if (takeawayLines.Count == 0)
takeawayLines = GetStringList(s, "body");
if (takeawayLines.Count > 0)
{
AddRect(t, ref id, M, H - 650000, W - M * 2, 9000, c.Accent);
AddText(t, ref id, M, H - 560000, W - M * 2, 140000, "Implication", 1500, c.Primary, bold: true, align: "l");
AddTextEx(t, ref id, M, H - 380000, W - M * 2, 220000, takeawayLines[0], 1450, c.TextDark, bold: false, italic: false, align: "l");
}
}
private static List GetStringList(JsonElement source, string propertyName)
{
if (!source.SafeTryGetProperty(propertyName, out var value))
return [];
if (value.ValueKind == JsonValueKind.Array)
{
return value.EnumerateArray()
.Select(item => item.ValueKind switch
{
JsonValueKind.String => item.SafeGetString() ?? string.Empty,
JsonValueKind.Object => CoalesceText(
FirstNonEmpty(item, "title", "name", "label", "value", "detail", "note"),
item.ToString()),
_ => item.ToString()
})
.Where(item => !string.IsNullOrWhiteSpace(item))
.ToList();
}
if (value.ValueKind == JsonValueKind.String)
{
return (value.SafeGetString() ?? string.Empty)
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(item => !string.IsNullOrWhiteSpace(item))
.ToList();
}
return [];
}
private static List GetStructuredItems(JsonElement source, string propertyName)
{
var items = new List();
if (!source.SafeTryGetProperty(propertyName, out var value) || value.ValueKind != JsonValueKind.Array)
return items;
foreach (var item in value.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
var text = item.SafeGetString() ?? string.Empty;
if (!string.IsNullOrWhiteSpace(text))
items.Add(new PresentationCardItem(text, string.Empty, string.Empty, string.Empty));
continue;
}
if (item.ValueKind != JsonValueKind.Object)
continue;
items.Add(new PresentationCardItem(
CoalesceText(FirstNonEmpty(item, "title", "name", "label"), "Item"),
CoalesceText(FirstNonEmpty(item, "value", "detail", "pros", "timeline", "summary"), string.Empty),
CoalesceText(FirstNonEmpty(item, "note", "cons", "trend", "owner", "secondary"), string.Empty),
CoalesceText(FirstNonEmpty(item, "badge", "verdict", "status", "tag"), string.Empty)));
}
return items;
}
private static string? FirstNonEmpty(JsonElement source, params string[] keys)
{
foreach (var key in keys)
{
if (!source.SafeTryGetProperty(key, out var value))
continue;
var text = value.ValueKind switch
{
JsonValueKind.String => value.SafeGetString(),
JsonValueKind.Number => value.ToString(),
_ => null
};
if (!string.IsNullOrWhiteSpace(text))
return text;
}
return null;
}
private static string CoalesceText(params string?[] values)
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))?.Trim() ?? string.Empty;
private static void BuildChartSlide(SlidePart slidePart, ShapeTree tree, JsonElement s,
FullTheme theme, long W, long H, ref uint id)
{
var c = theme.Colors;
const long M = 450000;
var title = Str(s, "title");
var chartType = Str(s, "chart_type").ToLowerInvariant();
if (string.IsNullOrEmpty(chartType)) chartType = "bar";
// 제목
if (!string.IsNullOrWhiteSpace(title))
{
AddRect(tree, ref id, 0, 0, W, 750000, c.HeaderBg);
AddText(tree, ref id, M, 80000, W - M * 2, 600000,
title, theme.Layout.SlideTitlePt, c.HeaderText, bold: true, align: "l");
}
// 차트 데이터 파싱
var labels = new List();
var values = new List();
var seriesName = Str(s, "chart_series_name");
if (string.IsNullOrEmpty(seriesName)) seriesName = "Series1";
if (s.SafeTryGetProperty("chart_labels", out var labelsEl) && labelsEl.ValueKind == JsonValueKind.Array)
foreach (var lbl in labelsEl.EnumerateArray())
labels.Add(lbl.SafeGetString() ?? lbl.ToString());
if (s.SafeTryGetProperty("chart_values", out var valuesEl) && valuesEl.ValueKind == JsonValueKind.Array)
foreach (var val in valuesEl.EnumerateArray())
values.Add(val.ValueKind == JsonValueKind.Number ? val.GetDouble() : 0);
if (labels.Count == 0 || values.Count == 0)
{
AddText(tree, ref id, M, H / 3, W - M * 2, H / 3,
"차트 데이터 없음 (chart_labels, chart_values 필요)", 1600, c.TextDark, false, align: "ctr");
return;
}
// 데이터 수 맞추기
while (values.Count < labels.Count) values.Add(0);
while (labels.Count < values.Count) labels.Add($"Item{labels.Count + 1}");
// ChartPart 생성
var chartPart = slidePart.AddNewPart();
GenerateChartPartContent(chartPart, chartType, labels, values, seriesName, c.Accent);
chartPart.ChartSpace.Save();
// GraphicFrame으로 슬라이드에 배치
var chartY = !string.IsNullOrWhiteSpace(title) ? 850000L : 200000L;
var chartH = H - chartY - 200000;
AddChartFrame(tree, ref id, slidePart, chartPart, M, chartY, W - M * 2, chartH);
}
/// ChartPart의 XML 콘텐츠를 생성합니다.
private static void GenerateChartPartContent(ChartPart chartPart, string chartType,
List labels, List values, string seriesName, string accentHex)
{
var chartSpace = new DC.ChartSpace();
chartSpace.AddNamespaceDeclaration("c", "http://schemas.openxmlformats.org/drawingml/2006/chart");
chartSpace.AddNamespaceDeclaration("a", "http://schemas.openxmlformats.org/drawingml/2006/main");
var chart = new DC.Chart();
var plotArea = new DC.PlotArea();
plotArea.AppendChild(new DC.Layout());
// 카테고리 축 데이터
var catAxisData = new DC.CategoryAxisData();
var strRef = new DC.StringReference { Formula = new DC.Formula("Sheet1!$A$2:$A$" + (labels.Count + 1)) };
var strCache = new DC.StringCache { PointCount = new DC.PointCount { Val = (uint)labels.Count } };
for (int i = 0; i < labels.Count; i++)
strCache.AppendChild(new DC.StringPoint(new DC.NumericValue(labels[i])) { Index = (uint)i });
strRef.AppendChild(strCache);
catAxisData.AppendChild(strRef);
// 값 데이터
var valAxisData = new DC.Values();
var numRef = new DC.NumberReference { Formula = new DC.Formula("Sheet1!$B$2:$B$" + (values.Count + 1)) };
var numCache = new DC.NumberingCache
{
FormatCode = new DC.FormatCode("General"),
PointCount = new DC.PointCount { Val = (uint)values.Count }
};
for (int i = 0; i < values.Count; i++)
numCache.AppendChild(new DC.NumericPoint(new DC.NumericValue(values[i].ToString())) { Index = (uint)i });
numRef.AppendChild(numCache);
valAxisData.AppendChild(numRef);
// 시리즈 색상
var seriesFill = new A.SolidFill(new A.RgbColorModelHex { Val = accentHex.TrimStart('#') });
switch (chartType)
{
case "pie":
{
var pieChart = new DC.PieChart();
pieChart.AppendChild(new DC.VaryColors { Val = true });
var series = new DC.PieChartSeries
{
Index = new DC.Index { Val = 0 },
Order = new DC.Order { Val = 0 },
SeriesText = new DC.SeriesText(new DC.NumericValue(seriesName)),
};
series.AppendChild(catAxisData);
series.AppendChild(valAxisData);
pieChart.AppendChild(series);
plotArea.AppendChild(pieChart);
break;
}
case "line":
{
var lineChart = new DC.LineChart { Grouping = new DC.Grouping { Val = DC.GroupingValues.Standard } };
lineChart.AppendChild(new DC.VaryColors { Val = false });
var series = new DC.LineChartSeries
{
Index = new DC.Index { Val = 0 },
Order = new DC.Order { Val = 0 },
SeriesText = new DC.SeriesText(new DC.NumericValue(seriesName)),
};
series.AppendChild(new DC.ChartShapeProperties(new A.Outline(seriesFill.CloneNode(true))));
series.AppendChild(catAxisData);
series.AppendChild(valAxisData);
lineChart.AppendChild(series);
lineChart.AppendChild(new DC.AxisId { Val = 1 });
lineChart.AppendChild(new DC.AxisId { Val = 2 });
plotArea.AppendChild(lineChart);
// 축 추가
plotArea.AppendChild(CreateCategoryAxis(1, 2));
plotArea.AppendChild(CreateValueAxis(2, 1));
break;
}
default: // bar
{
var barChart = new DC.BarChart();
barChart.AppendChild(new DC.BarDirection { Val = DC.BarDirectionValues.Column });
barChart.AppendChild(new DC.BarGrouping { Val = DC.BarGroupingValues.Clustered });
barChart.AppendChild(new DC.VaryColors { Val = false });
var series = new DC.BarChartSeries
{
Index = new DC.Index { Val = 0 },
Order = new DC.Order { Val = 0 },
SeriesText = new DC.SeriesText(new DC.NumericValue(seriesName)),
};
series.AppendChild(new DC.ChartShapeProperties(seriesFill));
series.AppendChild(catAxisData);
series.AppendChild(valAxisData);
barChart.AppendChild(series);
barChart.AppendChild(new DC.AxisId { Val = 1 });
barChart.AppendChild(new DC.AxisId { Val = 2 });
plotArea.AppendChild(barChart);
// 축 추가
plotArea.AppendChild(CreateCategoryAxis(1, 2));
plotArea.AppendChild(CreateValueAxis(2, 1));
break;
}
}
chart.AppendChild(plotArea);
// 범례
var legend = new DC.Legend(
new DC.LegendPosition { Val = DC.LegendPositionValues.Bottom },
new DC.Overlay { Val = false });
chart.AppendChild(legend);
chart.AppendChild(new DC.PlotVisibleOnly { Val = true });
chartSpace.AppendChild(chart);
chartPart.ChartSpace = chartSpace;
}
/// 카테고리 축 (X축) 생성.
private static DC.CategoryAxis CreateCategoryAxis(uint axisId, uint crossAxisId)
{
var axis = new DC.CategoryAxis();
axis.AppendChild(new DC.AxisId { Val = axisId });
axis.AppendChild(new DC.Scaling(new DC.Orientation { Val = DC.OrientationValues.MinMax }));
axis.AppendChild(new DC.Delete { Val = false });
axis.AppendChild(new DC.AxisPosition { Val = DC.AxisPositionValues.Bottom });
axis.AppendChild(new DC.CrossingAxis { Val = crossAxisId });
axis.AppendChild(new DC.Crosses { Val = DC.CrossesValues.AutoZero });
axis.AppendChild(new DC.AutoLabeled { Val = true });
axis.AppendChild(new DC.LabelAlignment { Val = DC.LabelAlignmentValues.Center });
return axis;
}
/// 값 축 (Y축) 생성.
private static DC.ValueAxis CreateValueAxis(uint axisId, uint crossAxisId)
{
var axis = new DC.ValueAxis();
axis.AppendChild(new DC.AxisId { Val = axisId });
axis.AppendChild(new DC.Scaling(new DC.Orientation { Val = DC.OrientationValues.MinMax }));
axis.AppendChild(new DC.Delete { Val = false });
axis.AppendChild(new DC.AxisPosition { Val = DC.AxisPositionValues.Left });
axis.AppendChild(new DC.CrossingAxis { Val = crossAxisId });
axis.AppendChild(new DC.Crosses { Val = DC.CrossesValues.AutoZero });
return axis;
}
/// GraphicFrame으로 ChartPart를 슬라이드에 배치합니다.
private static void AddChartFrame(ShapeTree tree, ref uint id,
SlidePart slidePart, ChartPart chartPart,
long x, long y, long w, long h)
{
var relId = slidePart.GetIdOfPart(chartPart);
var graphicFrame = new P.GraphicFrame();
graphicFrame.NonVisualGraphicFrameProperties = new P.NonVisualGraphicFrameProperties(
new P.NonVisualDrawingProperties { Id = id++, Name = $"Chart{id}" },
new P.NonVisualGraphicFrameDrawingProperties(),
new ApplicationNonVisualDrawingProperties());
graphicFrame.Transform = new P.Transform(
new A.Offset { X = x, Y = y },
new A.Extents { Cx = w, Cy = h });
graphicFrame.Graphic = new A.Graphic(
new A.GraphicData(
new DC.ChartReference { Id = relId })
{ Uri = "http://schemas.openxmlformats.org/drawingml/2006/chart" });
tree.AppendChild(graphicFrame);
}
// ══════════════════════════════════════════════════════════════════════════
// Shape 헬퍼
// ══════════════════════════════════════════════════════════════════════════
/// 단색 채운 직사각형 (배경, 구분선, 액센트 바 등)
private static void AddRect(ShapeTree t, ref uint id, long x, long y, long w, long h, string hex)
{
var sp = new Shape();
sp.NonVisualShapeProperties = new P.NonVisualShapeProperties(
new P.NonVisualDrawingProperties { Id = id++, Name = $"Rect{id}" },
new P.NonVisualShapeDrawingProperties(),
new ApplicationNonVisualDrawingProperties());
sp.ShapeProperties = new ShapeProperties(
new A.Transform2D(new A.Offset { X = x, Y = y }, new A.Extents { Cx = w, Cy = h }),
new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle },
new A.SolidFill(new A.RgbColorModelHex { Val = hex.TrimStart('#') }),
new A.Outline(new A.NoFill()));
sp.TextBody = new TextBody(new A.BodyProperties(), new A.ListStyle(), new A.Paragraph());
t.AppendChild(sp);
}
/// 단색 채운 둥근 모서리 사각형 (카드 박스)
private static void AddRoundedRect(ShapeTree t, ref uint id, long x, long y, long w, long h, string hex)
{
var sp = new Shape();
sp.NonVisualShapeProperties = new P.NonVisualShapeProperties(
new P.NonVisualDrawingProperties { Id = id++, Name = $"RRect{id}" },
new P.NonVisualShapeDrawingProperties(),
new ApplicationNonVisualDrawingProperties());
// roundRect preset with adj (corner radius ~5%)
var geom = new A.PresetGeometry(
new A.AdjustValueList(new A.ShapeGuide { Name = "adj", Formula = "val 20000" }))
{ Preset = A.ShapeTypeValues.RoundRectangle };
sp.ShapeProperties = new ShapeProperties(
new A.Transform2D(new A.Offset { X = x, Y = y }, new A.Extents { Cx = w, Cy = h }),
geom,
new A.SolidFill(new A.RgbColorModelHex { Val = hex.TrimStart('#') }),
new A.Outline(new A.NoFill()));
sp.TextBody = new TextBody(new A.BodyProperties(), new A.ListStyle(), new A.Paragraph());
t.AppendChild(sp);
}
/// 단색 타원 (장식 원 등). alpha: 0~100 (불투명도 %)
private static void AddEllipse(ShapeTree t, ref uint id, long x, long y, long w, long h, string hex, int alpha = 100)
{
var sp = new Shape();
sp.NonVisualShapeProperties = new P.NonVisualShapeProperties(
new P.NonVisualDrawingProperties { Id = id++, Name = $"Ellipse{id}" },
new P.NonVisualShapeDrawingProperties(),
new ApplicationNonVisualDrawingProperties());
DocumentFormat.OpenXml.OpenXmlElement fill;
if (alpha < 100)
{
// 알파값 포함 (OpenXML alpha: 0=완전투명, 100000=완전불투명)
var rgbClr = new A.RgbColorModelHex { Val = hex.TrimStart('#') };
rgbClr.AppendChild(new A.Alpha { Val = alpha * 1000 });
fill = new A.SolidFill(rgbClr);
}
else
{
fill = new A.SolidFill(new A.RgbColorModelHex { Val = hex.TrimStart('#') });
}
sp.ShapeProperties = new ShapeProperties(
new A.Transform2D(new A.Offset { X = x, Y = y }, new A.Extents { Cx = w, Cy = h }),
new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Ellipse },
fill,
new A.Outline(new A.NoFill()));
sp.TextBody = new TextBody(new A.BodyProperties(), new A.ListStyle(), new A.Paragraph());
t.AppendChild(sp);
}
/// 텍스트 박스 (일반 텍스트, 줄바꿈 지원)
private static void AddText(ShapeTree t, ref uint id,
long x, long y, long w, long h,
string text, int fs100, string hex, bool bold, string align = "l")
=> AddTextEx(t, ref id, x, y, w, h, text, fs100, hex, bold, italic: false, align: align);
private static void AddTextEx(ShapeTree t, ref uint id,
long x, long y, long w, long h,
string text, int fs100, string hex, bool bold, bool italic, string align = "l")
{
var alignVal = align switch
{
"ctr" => A.TextAlignmentTypeValues.Center,
"r" => A.TextAlignmentTypeValues.Right,
_ => A.TextAlignmentTypeValues.Left,
};
var txBody = new TextBody(
new A.BodyProperties
{
Wrap = A.TextWrappingValues.Square,
LeftInset = 91440,
RightInset = 91440,
TopInset = 45720,
BottomInset = 45720,
Anchor = A.TextAnchoringTypeValues.Center,
},
new A.ListStyle());
foreach (var line in text.Split('\n'))
{
var para = new A.Paragraph(new A.ParagraphProperties { Alignment = alignVal });
var rPr = MakeRunProps(fs100, hex, bold, italic);
para.AppendChild(new A.Run(rPr, new A.Text { Text = line }));
txBody.AppendChild(para);
}
var sp = new Shape();
sp.NonVisualShapeProperties = new P.NonVisualShapeProperties(
new P.NonVisualDrawingProperties { Id = id++, Name = $"Txt{id}" },
new P.NonVisualShapeDrawingProperties(),
new ApplicationNonVisualDrawingProperties());
sp.ShapeProperties = new ShapeProperties(
new A.Transform2D(new A.Offset { X = x, Y = y }, new A.Extents { Cx = w, Cy = h }),
new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle },
new A.NoFill(),
new A.Outline(new A.NoFill()));
sp.TextBody = txBody;
t.AppendChild(sp);
}
/// 불릿 포인트 텍스트 박스 — 줄 단위로 불릿 적용, 들여쓰기 서브불릿 지원
private static void AddBulletBody(ShapeTree t, ref uint id,
long x, long y, long w, long h,
string text, int fs100, string textHex, string bulletHex)
{
var txBody = new TextBody(
new A.BodyProperties
{
Wrap = A.TextWrappingValues.Square,
LeftInset = 91440,
RightInset = 91440,
TopInset = 45720,
BottomInset = 45720,
Anchor = A.TextAnchoringTypeValues.Top,
},
new A.ListStyle());
foreach (var rawLine in text.Split('\n'))
{
if (string.IsNullOrWhiteSpace(rawLine))
{
txBody.AppendChild(new A.Paragraph());
continue;
}
var isSub = rawLine.StartsWith(" ") || rawLine.StartsWith("\t");
var trimmed = rawLine.TrimStart().TrimStart('-').TrimStart();
var bulletFs = isSub ? fs100 - 200 : fs100;
var bChar = isSub ? "–" : "•";
var marL = isSub ? 685800 : 457200;
var indent = isSub ? -228600 : -342900;
var pPr = new A.ParagraphProperties
{
LeftMargin = marL,
Indent = indent,
};
pPr.AppendChild(new A.SpaceBefore(new A.SpacingPercent { Val = isSub ? 100 : 140 }));
pPr.AppendChild(new A.SpaceAfter(new A.SpacingPoints { Val = 200 }));
pPr.AppendChild(new A.BulletColor(new A.RgbColorModelHex { Val = bulletHex.TrimStart('#') }));
pPr.AppendChild(new A.BulletFont { Typeface = FontEn });
pPr.AppendChild(new A.CharacterBullet { Char = bChar });
var para = new A.Paragraph(pPr, new A.Run(MakeRunProps(bulletFs, textHex, false, false), new A.Text { Text = trimmed }));
txBody.AppendChild(para);
}
var sp = new Shape();
sp.NonVisualShapeProperties = new P.NonVisualShapeProperties(
new P.NonVisualDrawingProperties { Id = id++, Name = $"Body{id}" },
new P.NonVisualShapeDrawingProperties(),
new ApplicationNonVisualDrawingProperties());
sp.ShapeProperties = new ShapeProperties(
new A.Transform2D(new A.Offset { X = x, Y = y }, new A.Extents { Cx = w, Cy = h }),
new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle },
new A.NoFill(),
new A.Outline(new A.NoFill()));
sp.TextBody = txBody;
t.AppendChild(sp);
}
/// 진짜 OpenXML 테이블 — 헤더 행 별도 색상, 짝수/홀수 행 교대 배경
private static void AddOpenXmlTable(ShapeTree t, ref uint id,
List headers, List> rows,
ThemeColors c, long x, long y, long w, long h)
{
int cols = headers.Count;
if (cols == 0) return;
long colW = w / cols;
long rowH = Math.Max(Math.Min(h / (rows.Count + 1), 650000), 400000);
A.TextBody MakeCell(string txt, string hexFg, bool bold)
{
var tb = new A.TextBody(
new A.BodyProperties { LeftInset = 91440, RightInset = 91440, TopInset = 45720, BottomInset = 45720 },
new A.ListStyle());
var para = new A.Paragraph(new A.ParagraphProperties());
para.AppendChild(new A.Run(MakeRunProps(1300, hexFg, bold, false), new A.Text { Text = txt }));
tb.AppendChild(para);
return tb;
}
var table = new A.Table();
var tblPr = new A.TableProperties { FirstRow = true, BandRow = true };
table.AppendChild(tblPr);
var tblGrid = new A.TableGrid();
for (int i = 0; i < cols; i++)
tblGrid.AppendChild(new A.GridColumn { Width = colW });
table.AppendChild(tblGrid);
// 헤더 행
var hRow = new A.TableRow { Height = rowH };
foreach (var hdr in headers)
{
var tc = new A.TableCell();
tc.AppendChild(MakeCell(hdr, c.HeaderText, bold: true));
var tcPr = new A.TableCellProperties();
tcPr.AppendChild(new A.SolidFill(new A.RgbColorModelHex { Val = c.HeaderBg.TrimStart('#') }));
tc.AppendChild(tcPr);
hRow.AppendChild(tc);
}
table.AppendChild(hRow);
// 데이터 행
for (int ri = 0; ri < rows.Count; ri++)
{
var rowData = rows[ri];
var rowBg = ri % 2 == 0 ? c.Bg : c.BgAlt;
var dRow = new A.TableRow { Height = rowH };
for (int ci = 0; ci < cols; ci++)
{
var cellTxt = ci < rowData.Count ? rowData[ci] : "";
var tc = new A.TableCell();
tc.AppendChild(MakeCell(cellTxt, c.TextDark, bold: false));
var tcPr = new A.TableCellProperties();
tcPr.AppendChild(new A.SolidFill(new A.RgbColorModelHex { Val = rowBg.TrimStart('#') }));
tc.AppendChild(tcPr);
dRow.AppendChild(tc);
}
table.AppendChild(dRow);
}
var gf = new P.GraphicFrame(
new P.NonVisualGraphicFrameProperties(
new P.NonVisualDrawingProperties { Id = id++, Name = "Table" },
new P.NonVisualGraphicFrameDrawingProperties(new A.GraphicFrameLocks()),
new ApplicationNonVisualDrawingProperties()),
new P.Transform(
new A.Offset { X = x, Y = y },
new A.Extents { Cx = w, Cy = rowH * (rows.Count + 1) }),
new A.Graphic(
new A.GraphicData(table)
{
Uri = "http://schemas.openxmlformats.org/drawingml/2006/table"
}));
t.AppendChild(gf);
}
// ══════════════════════════════════════════════════════════════════════════
// RunProperties 생성 헬퍼
// ══════════════════════════════════════════════════════════════════════════
private static A.RunProperties MakeRunProps(int fs100, string hex, bool bold, bool italic)
{
var rPr = new A.RunProperties
{
Language = "ko-KR",
AlternativeLanguage = "en-US",
FontSize = fs100,
Bold = bold,
Italic = italic,
Dirty = false,
};
rPr.AppendChild(new A.SolidFill(new A.RgbColorModelHex { Val = hex.TrimStart('#') }));
rPr.AppendChild(new A.LatinFont { Typeface = FontEn });
rPr.AppendChild(new A.EastAsianFont { Typeface = FontKo });
return rPr;
}
// ══════════════════════════════════════════════════════════════════════════
// 기타 헬퍼
// ══════════════════════════════════════════════════════════════════════════
private static string Str(JsonElement e, string key)
=> e.SafeTryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String
? v.SafeGetString() ?? ""
: "";
private static void AddNotesSlide(SlidePart slidePart, string notes)
{
var notesPart = slidePart.AddNewPart();
var nsSp = new Shape();
nsSp.NonVisualShapeProperties = new P.NonVisualShapeProperties(
new P.NonVisualDrawingProperties { Id = 2, Name = "Notes" },
new P.NonVisualShapeDrawingProperties(),
new ApplicationNonVisualDrawingProperties());
nsSp.ShapeProperties = new ShapeProperties(
new A.Transform2D(new A.Offset { X = 457200, Y = 1143000 }, new A.Extents { Cx = 5486400, Cy = 3886200 }),
new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle },
new A.NoFill());
var ntBody = new TextBody(
new A.BodyProperties { Wrap = A.TextWrappingValues.Square },
new A.ListStyle());
var ntPara = new A.Paragraph();
var ntRpr = new A.RunProperties { Language = "ko-KR", FontSize = 1200, Dirty = false };
ntRpr.AppendChild(new A.LatinFont { Typeface = FontEn });
ntRpr.AppendChild(new A.EastAsianFont { Typeface = FontKo });
ntPara.AppendChild(new A.Run(ntRpr, new A.Text { Text = notes }));
ntBody.AppendChild(ntPara);
nsSp.TextBody = ntBody;
notesPart.NotesSlide = new NotesSlide(
new CommonSlideData(new ShapeTree(
new P.NonVisualGroupShapeProperties(
new P.NonVisualDrawingProperties { Id = 1, Name = "" },
new P.NonVisualGroupShapeDrawingProperties(),
new ApplicationNonVisualDrawingProperties()),
new GroupShapeProperties(new A.TransformGroup()),
nsSp)));
notesPart.NotesSlide.Save();
}
// ── 고품질 템플릿에서 마스터/레이아웃/테마 복제 ──────────────────────────────
///
/// 기존 .pptx 템플릿에서 SlideMaster, SlideLayout, ThemePart를 통째로 복제합니다.
/// 복제 성공 시 layoutPart에 첫 번째 레이아웃을 반환하고 true.
/// 실패 시 false → 호출부에서 자체 마스터를 생성합니다 (색상 추출 폴백).
///
private static bool TryCloneMasterFromTemplate(
string templatePath,
PresentationPart targetPresPart,
out SlideLayoutPart? layoutPart)
{
layoutPart = null;
if (!File.Exists(templatePath)) return false;
try
{
using var srcDoc = PresentationDocument.Open(templatePath, isEditable: false);
var srcPresPart = srcDoc.PresentationPart;
if (srcPresPart == null) return false;
var srcMaster = srcPresPart.SlideMasterParts.FirstOrDefault();
if (srcMaster == null) return false;
// 1. SlideMasterPart 통째로 복제 (ThemePart, 이미지, 레이아웃 포함)
var clonedMaster = targetPresPart.AddPart(srcMaster);
// 2. SlideMasterIdList 설정
targetPresPart.Presentation.SlideMasterIdList = new SlideMasterIdList(
new SlideMasterId
{
Id = 2147483648U,
RelationshipId = targetPresPart.GetIdOfPart(clonedMaster)
});
// 3. 첫 번째 SlideLayout 선택 (Blank 우선, 없으면 첫 번째)
SlideLayoutPart? blankLayout = null;
SlideLayoutPart? firstLayout = null;
foreach (var lp in clonedMaster.SlideLayoutParts)
{
firstLayout ??= lp;
if (lp.SlideLayout?.Type?.Value == SlideLayoutValues.Blank)
{
blankLayout = lp;
break;
}
}
layoutPart = blankLayout ?? firstLayout;
if (layoutPart == null) return false;
// 4. 마스터 저장
clonedMaster.SlideMaster.Save();
return true;
}
catch (Exception ex)
{
Services.LogService.Warn($"템플릿 마스터 복제 실패 ({Path.GetFileName(templatePath)}): {ex.Message}");
return false;
}
}
// ══════════════════════════════════════════════════════════════════════════
// 슬라이드 페이지 복제 (방법 1 + 방법 2)
// ══════════════════════════════════════════════════════════════════════════
///
/// 방법 1: 원본 PPTX의 모든 슬라이드를 그대로 복제합니다.
/// 복제 후 globalReplacements의 텍스트를 일괄 교체합니다.
///
private static int CloneAllSlidesFromSource(
string sourcePath,
PresentationPart targetPresPart,
SlideLayoutPart layoutPart,
Dictionary? replacements,
ref uint slideId,
ref int slideCount)
{
if (!File.Exists(sourcePath)) return 0;
var cloned = 0;
try
{
using var srcDoc = PresentationDocument.Open(sourcePath, isEditable: false);
var srcPresPart = srcDoc.PresentationPart;
if (srcPresPart?.Presentation?.SlideIdList == null) return 0;
foreach (var srcSlideId in srcPresPart.Presentation.SlideIdList.Elements())
{
var relId = srcSlideId.RelationshipId?.Value;
if (string.IsNullOrEmpty(relId)) continue;
if (srcPresPart.GetPartById(relId) is not SlidePart srcSlidePart) continue;
// SlidePart 통째로 복제 (이미지, 차트 등 하위 파트 포함)
var newSlidePart = targetPresPart.AddPart(srcSlidePart);
// 레이아웃 연결 (타겟의 마스터 레이아웃으로 재연결)
try
{
if (newSlidePart.SlideLayoutPart != null)
newSlidePart.DeletePart(newSlidePart.SlideLayoutPart);
}
catch { /* 레이아웃 정리 실패는 무시 — 원본 레이아웃 유지 */ }
try { newSlidePart.AddPart(layoutPart); } catch { }
// 텍스트 교체 적용
if (replacements != null && replacements.Count > 0)
ApplyTextReplacements(newSlidePart, replacements);
newSlidePart.Slide.Save();
targetPresPart.Presentation.SlideIdList!.AppendChild(new SlideId
{
Id = slideId++,
RelationshipId = targetPresPart.GetIdOfPart(newSlidePart)
});
slideCount++;
cloned++;
}
}
catch (Exception ex)
{
Services.LogService.Warn($"슬라이드 전체 복제 실패: {ex.Message}");
}
return cloned;
}
///
/// 방법 2: 원본 PPTX의 특정 페이지(0-based index)를 복제하고 텍스트를 교체합니다.
/// slides 배열의 개별 항목에서 source_slide + replacements로 사용합니다.
///
private static bool CloneSingleSlideFromSource(
string sourcePath,
int sourceIndex,
PresentationPart targetPresPart,
SlideLayoutPart layoutPart,
JsonElement slideEl,
ref uint slideId,
ref int slideCount)
{
if (!File.Exists(sourcePath) || sourceIndex < 0) return false;
try
{
using var srcDoc = PresentationDocument.Open(sourcePath, isEditable: false);
var srcPresPart = srcDoc.PresentationPart;
if (srcPresPart?.Presentation?.SlideIdList == null) return false;
var srcSlideIds = srcPresPart.Presentation.SlideIdList.Elements().ToList();
if (sourceIndex >= srcSlideIds.Count) return false;
var relId = srcSlideIds[sourceIndex].RelationshipId?.Value;
if (string.IsNullOrEmpty(relId)) return false;
if (srcPresPart.GetPartById(relId) is not SlidePart srcSlidePart) return false;
var newSlidePart = targetPresPart.AddPart(srcSlidePart);
// 레이아웃 재연결
try
{
if (newSlidePart.SlideLayoutPart != null)
newSlidePart.DeletePart(newSlidePart.SlideLayoutPart);
}
catch { }
try { newSlidePart.AddPart(layoutPart); } catch { }
// 슬라이드별 replacements 적용
Dictionary? perSlideRepl = null;
if (slideEl.SafeTryGetProperty("replacements", out var replEl) && replEl.ValueKind == JsonValueKind.Object)
{
perSlideRepl = new Dictionary();
foreach (var prop in replEl.EnumerateObject())
{
if (prop.Value.ValueKind == JsonValueKind.String)
perSlideRepl[prop.Name] = prop.Value.SafeGetString() ?? "";
}
}
if (perSlideRepl != null && perSlideRepl.Count > 0)
ApplyTextReplacements(newSlidePart, perSlideRepl);
newSlidePart.Slide.Save();
targetPresPart.Presentation.SlideIdList!.AppendChild(new SlideId
{
Id = slideId++,
RelationshipId = targetPresPart.GetIdOfPart(newSlidePart)
});
slideCount++;
return true;
}
catch (Exception ex)
{
Services.LogService.Warn($"슬라이드 #{sourceIndex + 1} 복제 실패: {ex.Message}");
return false;
}
}
///
/// SlidePart 내의 모든 텍스트 Run을 순회하며 교체 맵을 적용합니다.
/// 하나의 A.Text 노드 안에 매칭되는 문자열이 있으면 교체합니다.
///
private static void ApplyTextReplacements(SlidePart slidePart, Dictionary replacements)
{
if (slidePart.Slide?.CommonSlideData?.ShapeTree == null) return;
// 모든 텍스트 노드를 순회 (Shape, GroupShape, Picture의 TextBody 모두 포함)
foreach (var textNode in slidePart.Slide.Descendants())
{
if (string.IsNullOrEmpty(textNode.Text)) continue;
var modified = textNode.Text;
foreach (var (find, replace) in replacements)
{
if (modified.Contains(find))
modified = modified.Replace(find, replace);
}
if (!string.Equals(modified, textNode.Text, StringComparison.Ordinal))
textNode.Text = modified;
}
}
// ── 기존 PPTX에서 테마 색상 추출 ────────────────────────────────────────────
///
/// 기존 .pptx 파일의 슬라이드 마스터 테마에서 색상을 읽어 ThemeColors로 반환합니다.
/// 추출할 수 없는 경우 null을 반환합니다.
///
private static ThemeColors? ExtractThemeFromPptx(string pptxPath)
{
if (!File.Exists(pptxPath)) return null;
try
{
using var doc = PresentationDocument.Open(pptxPath, isEditable: false);
var master = doc.PresentationPart?.SlideMasterParts?.FirstOrDefault();
var scheme = master?.ThemePart?.Theme?.ThemeElements?.ColorScheme;
if (scheme == null) return null;
static string? ColorHex(A.Color2Type? el)
{
if (el == null) return null;
var srgb = el.GetFirstChild();
if (srgb?.Val?.Value != null) return srgb.Val.Value;
var sys = el.GetFirstChild();
if (sys?.LastColor?.Value != null) return sys.LastColor.Value;
return null;
}
var dk1 = ColorHex(scheme.Dark1Color) ?? "1A1A2E";
var lt1 = ColorHex(scheme.Light1Color) ?? "FFFFFF";
var dk2 = ColorHex(scheme.Dark2Color) ?? "1A1A2E";
var lt2 = ColorHex(scheme.Light2Color) ?? "F2F7FC";
var acc1 = ColorHex(scheme.Accent1Color) ?? "2E75B6";
var isDarkBg = IsDarkColor(lt1);
return new ThemeColors(
Primary: dk2,
Accent: acc1,
TextDark: dk1,
TextLight: lt1,
Bg: lt1,
BgAlt: lt2,
HeaderBg: dk2,
HeaderText: isDarkBg ? dk1 : "FFFFFF");
}
catch
{
return null;
}
}
/// hex 색상이 어두운지 판단 (명도 기준).
private static bool IsDarkColor(string hex)
{
try
{
var h = hex.TrimStart('#');
if (h.Length < 6) return false;
var r = Convert.ToInt32(h[..2], 16);
var g = Convert.ToInt32(h[2..4], 16);
var b = Convert.ToInt32(h[4..6], 16);
var luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return luminance < 128;
}
catch { return false; }
}
// ══════════════════════════════════════════════════════════════════════════
// 이미지 삽입 헬퍼
// ══════════════════════════════════════════════════════════════════════════
/// 이미지 경로 확인 — 절대 경로 / 작업 폴더 상대 경로 모두 지원.
private static string? ResolveImagePath(string? rawPath, string workFolder)
{
if (string.IsNullOrWhiteSpace(rawPath)) return null;
var path = rawPath.Trim().Trim('"');
// 절대 경로
if (Path.IsPathRooted(path) && File.Exists(path))
return Path.GetFullPath(path);
// 작업 폴더 기준 상대 경로
var resolved = Path.GetFullPath(Path.Combine(workFolder, path));
if (File.Exists(resolved)) return resolved;
return null;
}
///
/// 이미지 파일을 SlidePart에 ImagePart로 추가하고 ShapeTree에 Picture 요소를 생성합니다.
/// OpenXML의 Picture (p:pic) 구조: NonVisualPicProps → BlipFill → ShapeProperties.
///
private static void AddImage(SlidePart slidePart, ShapeTree tree, ref uint id,
string imagePath, long x, long y, long w, long h)
{
var ext = Path.GetExtension(imagePath).ToLowerInvariant();
var contentType = ext switch
{
".png" => "image/png",
".jpg" => "image/jpeg",
".jpeg" => "image/jpeg",
".gif" => "image/gif",
".bmp" => "image/bmp",
".tiff" => "image/tiff",
".tif" => "image/tiff",
".webp" => "image/webp",
".svg" => "image/svg+xml",
_ => "image/png",
};
// ImagePart 추가
var imagePart = slidePart.AddImagePart(contentType);
using (var stream = File.OpenRead(imagePath))
{
imagePart.FeedData(stream);
}
var relId = slidePart.GetIdOfPart(imagePart);
// Picture 요소 생성
var pic = new P.Picture();
// NonVisualPictureProperties
pic.NonVisualPictureProperties = new P.NonVisualPictureProperties(
new P.NonVisualDrawingProperties { Id = id++, Name = $"Image{id}" },
new P.NonVisualPictureDrawingProperties(
new A.PictureLocks { NoChangeAspect = true }),
new ApplicationNonVisualDrawingProperties());
// BlipFill — 이미지 참조
pic.BlipFill = new P.BlipFill(
new A.Blip { Embed = relId },
new A.Stretch(new A.FillRectangle()));
// ShapeProperties — 위치/크기
pic.ShapeProperties = new ShapeProperties(
new A.Transform2D(
new A.Offset { X = x, Y = y },
new A.Extents { Cx = w, Cy = h }),
new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle });
tree.AppendChild(pic);
}
/// 이미지 배치 위치 계산. 가로세로 비율을 유지하면서 지정 위치에 배치.
private static (long x, long y, long w, long h) CalcImagePlacement(
string position, long slideW, long slideH, string imagePath)
{
// 이미지 원본 크기 읽기 (비율 계산용)
var (imgW, imgH) = GetImageDimensions(imagePath);
var ratio = imgW > 0 && imgH > 0 ? (double)imgW / imgH : 1.5;
return position switch
{
"full" => (0, 0, slideW, slideH),
"left_half" => CalcFitInRect(0, 0, slideW / 2, slideH, ratio),
"right_half" => CalcFitInRect(slideW / 2, 0, slideW / 2, slideH, ratio),
"top_right" => CalcFitInRect(slideW / 2, 200000, slideW / 2 - 300000, slideH / 2 - 300000, ratio),
"top_left" => CalcFitInRect(300000, 200000, slideW / 2 - 300000, slideH / 2 - 300000, ratio),
"bottom_right" => CalcFitInRect(slideW / 2, slideH / 2, slideW / 2 - 300000, slideH / 2 - 300000, ratio),
"bottom_left" => CalcFitInRect(300000, slideH / 2, slideW / 2 - 300000, slideH / 2 - 300000, ratio),
_ => CalcFitInRect(slideW / 4, slideH / 4, slideW / 2, slideH / 2, ratio), // center
};
}
/// 지정 영역 안에 비율 유지하며 피팅.
private static (long x, long y, long w, long h) CalcFitInRect(
long rx, long ry, long rw, long rh, double imgRatio)
{
var rectRatio = (double)rw / rh;
long fitW, fitH;
if (imgRatio > rectRatio)
{
fitW = rw;
fitH = (long)(rw / imgRatio);
}
else
{
fitH = rh;
fitW = (long)(rh * imgRatio);
}
var ox = rx + (rw - fitW) / 2;
var oy = ry + (rh - fitH) / 2;
return (ox, oy, fitW, fitH);
}
/// 이미지 파일의 가로/세로 픽셀 읽기 (헤더만 파싱, 전체 로드 안 함).
private static (int width, int height) GetImageDimensions(string path)
{
try
{
using var fs = File.OpenRead(path);
using var img = System.Drawing.Image.FromStream(fs, useEmbeddedColorManagement: false, validateImageData: false);
return (img.Width, img.Height);
}
catch
{
return (0, 0);
}
}
// ══════════════════════════════════════════════════════════════════════════
// 내장 아이콘/심볼 라이브러리
// ══════════════════════════════════════════════════════════════════════════
/// 아이콘 배치 좌표 계산.
private static (long x, long y) CalcIconPosition(
string position, long slideW, long slideH, long iconSize)
{
const long pad = 300000;
return position switch
{
"top_left" => (pad, pad),
"top_right" => (slideW - iconSize - pad, pad),
"bottom_left" => (pad, slideH - iconSize - pad),
"bottom_right" => (slideW - iconSize - pad, slideH - iconSize - pad),
"center" => ((slideW - iconSize) / 2, (slideH - iconSize) / 2),
_ => (slideW - iconSize - pad, pad), // default top_right
};
}
///
/// 내장 아이콘을 OpenXML 프리셋 도형으로 그립니다. 외부 파일 0바이트 — 100% 벡터.
/// OpenXML A.ShapeTypeValues에 정의된 프리셋 기하도형을 활용하며,
/// 프리셋이 없는 심볼은 유니코드 텍스트 기반으로 렌더링합니다.
///
private static void AddBuiltInIcon(ShapeTree tree, ref uint id,
string iconName, long x, long y, long size, string colorHex)
{
// OpenXML 프리셋 도형이 있는 아이콘
if (PresetIconMap.TryGetValue(iconName, out var preset))
{
AddPresetShape(tree, ref id, preset, x, y, size, size, colorHex);
return;
}
// 유니코드 심볼 기반 아이콘 (프리셋 도형이 없는 경우 — 공유 IconLibrary 사용)
if (IconLibrary.Contains(iconName))
{
AddSymbolIcon(tree, ref id, IconLibrary.Resolve(iconName), x, y, size, colorHex);
return;
}
// 알 수 없는 아이콘 → 물음표 심볼
AddSymbolIcon(tree, ref id, "❓", x, y, size, colorHex);
}
/// OpenXML 프리셋 도형 매핑 — 벡터 도형으로 렌더링되는 아이콘 (70종+).
private static readonly Dictionary PresetIconMap = new(StringComparer.OrdinalIgnoreCase)
{
// ── 별/장식 ──
["star"] = A.ShapeTypeValues.Star5,
["star4"] = A.ShapeTypeValues.Star4,
["star6"] = A.ShapeTypeValues.Star6,
["star8"] = A.ShapeTypeValues.Star8,
["star10"] = A.ShapeTypeValues.Star10,
["star12"] = A.ShapeTypeValues.Star12,
["star16"] = A.ShapeTypeValues.Star16,
["star24"] = A.ShapeTypeValues.Star24,
["star32"] = A.ShapeTypeValues.Star32,
["heart"] = A.ShapeTypeValues.Heart,
["lightning"] = A.ShapeTypeValues.LightningBolt,
["ribbon"] = A.ShapeTypeValues.Ribbon2,
["ribbon2"] = A.ShapeTypeValues.Ribbon,
["wave"] = A.ShapeTypeValues.Wave,
["double_wave"] = A.ShapeTypeValues.DoubleWave,
// ── 자연/날씨 ──
["cloud"] = A.ShapeTypeValues.Cloud,
["sun"] = A.ShapeTypeValues.Sun,
["moon"] = A.ShapeTypeValues.Moon,
// ── 화살표 ──
["arrow_right"] = A.ShapeTypeValues.RightArrow,
["arrow_left"] = A.ShapeTypeValues.LeftArrow,
["arrow_up"] = A.ShapeTypeValues.UpArrow,
["arrow_down"] = A.ShapeTypeValues.DownArrow,
["arrow_left_right"]= A.ShapeTypeValues.LeftRightArrow,
["arrow_up_down"] = A.ShapeTypeValues.UpDownArrow,
["arrow_quad"] = A.ShapeTypeValues.QuadArrow,
["arrow_circular"] = A.ShapeTypeValues.CircularArrow,
["arrow_curved_right"]= A.ShapeTypeValues.CurvedRightArrow,
["arrow_curved_left"]= A.ShapeTypeValues.CurvedLeftArrow,
["arrow_curved_up"] = A.ShapeTypeValues.CurvedUpArrow,
["arrow_curved_down"]= A.ShapeTypeValues.CurvedDownArrow,
["arrow_striped"] = A.ShapeTypeValues.StripedRightArrow,
["arrow_notched"] = A.ShapeTypeValues.NotchedRightArrow,
["arrow_chevron"] = A.ShapeTypeValues.Chevron,
["arrow_u_turn"] = A.ShapeTypeValues.UTurnArrow,
["arrow_bent"] = A.ShapeTypeValues.BentArrow,
["arrow_bent_up"] = A.ShapeTypeValues.BentUpArrow,
// ── 기본 도형 ──
["diamond"] = A.ShapeTypeValues.Diamond,
["pentagon"] = A.ShapeTypeValues.Pentagon,
["hexagon"] = A.ShapeTypeValues.Hexagon,
["octagon"] = A.ShapeTypeValues.Octagon,
["triangle"] = A.ShapeTypeValues.Triangle,
["right_triangle"] = A.ShapeTypeValues.RightTriangle,
["parallelogram"] = A.ShapeTypeValues.Parallelogram,
["trapezoid"] = A.ShapeTypeValues.Trapezoid,
["decagon"] = A.ShapeTypeValues.Decagon,
["dodecagon"] = A.ShapeTypeValues.Dodecagon,
["heptagon"] = A.ShapeTypeValues.Heptagon,
["round_rect"] = A.ShapeTypeValues.RoundRectangle,
["snip_rect"] = A.ShapeTypeValues.Snip1Rectangle,
["donut"] = A.ShapeTypeValues.Donut,
["block_arc"] = A.ShapeTypeValues.BlockArc,
["pie"] = A.ShapeTypeValues.Pie,
["pie_wedge"] = A.ShapeTypeValues.PieWedge,
["chord"] = A.ShapeTypeValues.Chord,
["teardrop"] = A.ShapeTypeValues.Teardrop,
["plaque"] = A.ShapeTypeValues.Plaque,
["frame"] = A.ShapeTypeValues.Frame,
["half_frame"] = A.ShapeTypeValues.HalfFrame,
["corner"] = A.ShapeTypeValues.Corner,
["l_shape"] = A.ShapeTypeValues.LeftBracket,
["brace_left"] = A.ShapeTypeValues.LeftBrace,
["brace_right"] = A.ShapeTypeValues.RightBrace,
["bracket_left"] = A.ShapeTypeValues.LeftBracket,
["bracket_right"] = A.ShapeTypeValues.RightBracket,
// ── 수학 ──
["cross"] = A.ShapeTypeValues.MathPlus,
["plus"] = A.ShapeTypeValues.MathPlus,
["minus"] = A.ShapeTypeValues.MathMinus,
["multiply"] = A.ShapeTypeValues.MathMultiply,
["divide"] = A.ShapeTypeValues.MathDivide,
["equal"] = A.ShapeTypeValues.MathEqual,
["not_equal"] = A.ShapeTypeValues.MathNotEqual,
// ── 이모티콘/기호 ──
["smiley"] = A.ShapeTypeValues.SmileyFace,
["frown"] = A.ShapeTypeValues.SmileyFace, // OpenXML에 frown 없음 → smiley로 대체
["no_symbol"] = A.ShapeTypeValues.NoSmoking,
["callout"] = A.ShapeTypeValues.WedgeRoundRectangleCallout,
["callout_cloud"] = A.ShapeTypeValues.CloudCallout,
["callout_rect"] = A.ShapeTypeValues.WedgeRectangleCallout,
["callout_oval"] = A.ShapeTypeValues.WedgeEllipseCallout,
// ── 기계/산업 ──
["gear"] = A.ShapeTypeValues.Gear6,
["gear9"] = A.ShapeTypeValues.Gear9,
["funnel"] = A.ShapeTypeValues.Funnel,
// ── 플로우차트 ──
["chart"] = A.ShapeTypeValues.FlowChartProcess,
["flowchart_decision"] = A.ShapeTypeValues.FlowChartDecision,
["flowchart_data"] = A.ShapeTypeValues.FlowChartInputOutput,
["flowchart_document"] = A.ShapeTypeValues.FlowChartDocument,
["flowchart_multi_doc"] = A.ShapeTypeValues.FlowChartMultidocument,
["flowchart_terminal"] = A.ShapeTypeValues.FlowChartTerminator,
["flowchart_preparation"]= A.ShapeTypeValues.FlowChartPreparation,
["flowchart_manual"] = A.ShapeTypeValues.FlowChartManualInput,
["flowchart_manual_op"] = A.ShapeTypeValues.FlowChartManualOperation,
["flowchart_connector"] = A.ShapeTypeValues.FlowChartConnector,
["flowchart_sort"] = A.ShapeTypeValues.FlowChartSort,
["flowchart_extract"] = A.ShapeTypeValues.FlowChartExtract,
["flowchart_merge"] = A.ShapeTypeValues.FlowChartMerge,
["flowchart_stored_data"]= A.ShapeTypeValues.FlowChartOnlineStorage,
["flowchart_delay"] = A.ShapeTypeValues.FlowChartDelay,
["flowchart_display"] = A.ShapeTypeValues.FlowChartDisplay,
// ── 배너/탭 ──
["home"] = A.ShapeTypeValues.HomePlate,
["flag"] = A.ShapeTypeValues.IrregularSeal1,
["explosion"] = A.ShapeTypeValues.IrregularSeal2,
["can"] = A.ShapeTypeValues.Can,
["cube"] = A.ShapeTypeValues.Cube,
["bevel"] = A.ShapeTypeValues.Bevel,
["fold_corner"] = A.ShapeTypeValues.FoldedCorner,
// ── 액션 버튼 ──
["action_back"] = A.ShapeTypeValues.ActionButtonBackPrevious,
["action_forward"] = A.ShapeTypeValues.ActionButtonForwardNext,
["action_beginning"]= A.ShapeTypeValues.ActionButtonBeginning,
["action_end"] = A.ShapeTypeValues.ActionButtonEnd,
["action_home"] = A.ShapeTypeValues.ActionButtonHome,
["action_info"] = A.ShapeTypeValues.ActionButtonInformation,
["action_return"] = A.ShapeTypeValues.ActionButtonReturn,
["action_document"] = A.ShapeTypeValues.ActionButtonDocument,
["action_help"] = A.ShapeTypeValues.ActionButtonHelp,
["action_movie"] = A.ShapeTypeValues.ActionButtonMovie,
["action_sound"] = A.ShapeTypeValues.ActionButtonSound,
["action_blank"] = A.ShapeTypeValues.ActionButtonBlank,
};
/// OpenXML 프리셋 도형을 추가합니다. 단색 채움, 테두리 없음.
private static void AddPresetShape(ShapeTree tree, ref uint id,
A.ShapeTypeValues preset, long x, long y, long w, long h, string hex)
{
var sp = new Shape();
sp.NonVisualShapeProperties = new P.NonVisualShapeProperties(
new P.NonVisualDrawingProperties { Id = id++, Name = $"Icon{id}" },
new P.NonVisualShapeDrawingProperties(),
new ApplicationNonVisualDrawingProperties());
sp.ShapeProperties = new ShapeProperties(
new A.Transform2D(new A.Offset { X = x, Y = y }, new A.Extents { Cx = w, Cy = h }),
new A.PresetGeometry(new A.AdjustValueList()) { Preset = preset },
new A.SolidFill(new A.RgbColorModelHex { Val = hex.TrimStart('#') }),
new A.Outline(new A.NoFill()));
sp.TextBody = new TextBody(new A.BodyProperties(), new A.ListStyle(), new A.Paragraph());
tree.AppendChild(sp);
}
/// 유니코드 심볼을 큰 텍스트 박스로 렌더링합니다.
private static void AddSymbolIcon(ShapeTree tree, ref uint id,
string symbol, long x, long y, long size, string hex)
{
var fontSize = (int)(size / 12700 * 80); // EMU → 100분의 1 pt (대략적 비율)
if (fontSize < 1200) fontSize = 1200;
if (fontSize > 9600) fontSize = 9600;
var sp = new Shape();
sp.NonVisualShapeProperties = new P.NonVisualShapeProperties(
new P.NonVisualDrawingProperties { Id = id++, Name = $"SymIcon{id}" },
new P.NonVisualShapeDrawingProperties(),
new ApplicationNonVisualDrawingProperties());
sp.ShapeProperties = new ShapeProperties(
new A.Transform2D(new A.Offset { X = x, Y = y }, new A.Extents { Cx = size, Cy = size }),
new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle },
new A.NoFill(),
new A.Outline(new A.NoFill()));
var rPr = new A.RunProperties
{
Language = "ko-KR",
FontSize = fontSize,
Dirty = false,
};
// 프리셋 도형 아이콘과 달리, 심볼은 텍스트 색상으로는 잘 안 먹히지만 설정해둠
rPr.AppendChild(new A.SolidFill(new A.RgbColorModelHex { Val = hex.TrimStart('#') }));
rPr.AppendChild(new A.LatinFont { Typeface = "Segoe UI Emoji" });
rPr.AppendChild(new A.EastAsianFont { Typeface = "Segoe UI Emoji" });
sp.TextBody = new TextBody(
new A.BodyProperties
{
Anchor = A.TextAnchoringTypeValues.Center,
LeftInset = 0, RightInset = 0, TopInset = 0, BottomInset = 0,
},
new A.ListStyle(),
new A.Paragraph(
new A.ParagraphProperties { Alignment = A.TextAlignmentTypeValues.Center },
new A.Run(rPr, new A.Text { Text = symbol })));
tree.AppendChild(sp);
}
}