- AgentCommandQueue를 도입해 실행 중 추가 입력을 우선순위와 인터럽트 여부까지 포함해 처리하도록 정리함 - AgentToolResultBudget와 AgentQueryContextBuilder에 tool result preview 캐시를 연결해 긴 세션에서 축약 결과 재사용을 안정화함 - CodeLanguageCatalog를 추가해 코드 탭의 내장 언어 지원, 인덱싱 확장자, 시스템 프롬프트 언어 가이드, LSP 언어 판정을 한 카탈로그로 통합함 - 설정의 코드 탭에 지원 언어(LSP)와 코드 탭 기본 지원 언어를 명시적으로 표시하도록 보강함 - DocumentPlannerTool의 presentation 구조를 컨설팅형 스토리라인으로 정리하고, PptxSkill에 executive_summary/recommendation/roadmap/comparison/kpi_dashboard 레이아웃을 추가함 - pptx-creator 스킬을 AX native pptx_create 중심으로 재작성하고, 관련 회귀 테스트를 추가했으며 WorkspaceContextGeneratorTests의 nullable 경고도 정리함 검증 결과 - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_impl\\ -p:IntermediateOutputPath=obj\\verify_impl\\ - dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "CodeLanguageCatalogTests|AgentCommandQueueTests|AgentToolResultBudgetTests|DocumentPlannerPresentationTests|PptxSkillConsultingDeckTests" -p:OutputPath=bin\\verify_impl_tests\\ -p:IntermediateOutputPath=obj\\verify_impl_tests\\
2995 lines
154 KiB
C#
2995 lines
154 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// 고품질 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가지 테마 (각각 고유한 색상 + 레이아웃 조합)
|
||
/// </summary>
|
||
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<string, string> 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",
|
||
};
|
||
|
||
/// <summary>
|
||
/// 템플릿 이름으로 .pptx 파일 전체 경로를 반환합니다.
|
||
/// 설치파일 용량 문제로 템플릿은 빌드에 포함하지 않으며,
|
||
/// 여러 알려진 경로를 순서대로 탐색합니다.
|
||
/// </summary>
|
||
private static string? ResolveTemplatePath(string templateName)
|
||
{
|
||
if (!TemplateRegistry.TryGetValue(templateName, out var fileName))
|
||
return null;
|
||
|
||
var candidates = new List<string>();
|
||
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<string, FullTheme> 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<ToolResult> 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<string, string>? globalReplacements = null;
|
||
if (args.SafeTryGetProperty("replacements", out var replEl) && replEl.ValueKind == JsonValueKind.Object)
|
||
{
|
||
globalReplacements = new Dictionary<string, string>();
|
||
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<SlideMasterPart>();
|
||
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<SlideLayoutPart>();
|
||
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<DocumentFormat.OpenXml.Packaging.ThemePart>();
|
||
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>();
|
||
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가 <Default Extension="xml"> 에 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 버그 우회
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
|
||
/// <summary>
|
||
/// OpenXML SDK가 <Default Extension="xml">에 presentation.main 타입을 등록하는
|
||
/// 버그를 수정합니다. PowerPoint은 이 잘못된 Default 때문에 파일을 열지 못합니다.
|
||
/// 수정: Default xml → application/xml, presentation.xml에 Override 추가.
|
||
/// </summary>
|
||
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. <Default Extension="xml"> 의 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("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
|
||
// 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<string>();
|
||
var rows = s.SafeTryGetProperty("rows", out var rEl)
|
||
? rEl.EnumerateArray().Select(r => r.EnumerateArray().Select(c2 => c2.SafeGetString() ?? "").ToList()).ToList()
|
||
: new List<List<string>>();
|
||
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)
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
|
||
/// <summary>
|
||
/// 네이티브 OpenXML 차트를 포함하는 슬라이드를 생성합니다.
|
||
/// bar(세로 막대), line(꺾은선), pie(원형) 차트를 지원합니다.
|
||
/// ChartPart에 내장 스프레드시트 데이터를 포함하므로 PowerPoint에서 편집 가능합니다.
|
||
/// </summary>
|
||
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<string> 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<PresentationCardItem> GetStructuredItems(JsonElement source, string propertyName)
|
||
{
|
||
var items = new List<PresentationCardItem>();
|
||
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<string>();
|
||
var values = new List<double>();
|
||
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<ChartPart>();
|
||
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);
|
||
}
|
||
|
||
/// <summary>ChartPart의 XML 콘텐츠를 생성합니다.</summary>
|
||
private static void GenerateChartPartContent(ChartPart chartPart, string chartType,
|
||
List<string> labels, List<double> 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;
|
||
}
|
||
|
||
/// <summary>카테고리 축 (X축) 생성.</summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>값 축 (Y축) 생성.</summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>GraphicFrame으로 ChartPart를 슬라이드에 배치합니다.</summary>
|
||
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 헬퍼
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
|
||
/// <summary>단색 채운 직사각형 (배경, 구분선, 액센트 바 등)</summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>단색 채운 둥근 모서리 사각형 (카드 박스)</summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>단색 타원 (장식 원 등). alpha: 0~100 (불투명도 %)</summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>텍스트 박스 (일반 텍스트, 줄바꿈 지원)</summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>불릿 포인트 텍스트 박스 — 줄 단위로 불릿 적용, 들여쓰기 서브불릿 지원</summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>진짜 OpenXML 테이블 — 헤더 행 별도 색상, 짝수/홀수 행 교대 배경</summary>
|
||
private static void AddOpenXmlTable(ShapeTree t, ref uint id,
|
||
List<string> headers, List<List<string>> 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<NotesSlidePart>();
|
||
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();
|
||
}
|
||
|
||
// ── 고품질 템플릿에서 마스터/레이아웃/테마 복제 ──────────────────────────────
|
||
/// <summary>
|
||
/// 기존 .pptx 템플릿에서 SlideMaster, SlideLayout, ThemePart를 통째로 복제합니다.
|
||
/// 복제 성공 시 layoutPart에 첫 번째 레이아웃을 반환하고 true.
|
||
/// 실패 시 false → 호출부에서 자체 마스터를 생성합니다 (색상 추출 폴백).
|
||
/// </summary>
|
||
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)
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
|
||
/// <summary>
|
||
/// 방법 1: 원본 PPTX의 모든 슬라이드를 그대로 복제합니다.
|
||
/// 복제 후 globalReplacements의 텍스트를 일괄 교체합니다.
|
||
/// </summary>
|
||
private static int CloneAllSlidesFromSource(
|
||
string sourcePath,
|
||
PresentationPart targetPresPart,
|
||
SlideLayoutPart layoutPart,
|
||
Dictionary<string, string>? 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<SlideId>())
|
||
{
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 방법 2: 원본 PPTX의 특정 페이지(0-based index)를 복제하고 텍스트를 교체합니다.
|
||
/// slides 배열의 개별 항목에서 source_slide + replacements로 사용합니다.
|
||
/// </summary>
|
||
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<SlideId>().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<string, string>? perSlideRepl = null;
|
||
if (slideEl.SafeTryGetProperty("replacements", out var replEl) && replEl.ValueKind == JsonValueKind.Object)
|
||
{
|
||
perSlideRepl = new Dictionary<string, string>();
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// SlidePart 내의 모든 텍스트 Run을 순회하며 교체 맵을 적용합니다.
|
||
/// 하나의 A.Text 노드 안에 매칭되는 문자열이 있으면 교체합니다.
|
||
/// </summary>
|
||
private static void ApplyTextReplacements(SlidePart slidePart, Dictionary<string, string> replacements)
|
||
{
|
||
if (slidePart.Slide?.CommonSlideData?.ShapeTree == null) return;
|
||
|
||
// 모든 텍스트 노드를 순회 (Shape, GroupShape, Picture의 TextBody 모두 포함)
|
||
foreach (var textNode in slidePart.Slide.Descendants<A.Text>())
|
||
{
|
||
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에서 테마 색상 추출 ────────────────────────────────────────────
|
||
/// <summary>
|
||
/// 기존 .pptx 파일의 슬라이드 마스터 테마에서 색상을 읽어 ThemeColors로 반환합니다.
|
||
/// 추출할 수 없는 경우 null을 반환합니다.
|
||
/// </summary>
|
||
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<A.RgbColorModelHex>();
|
||
if (srgb?.Val?.Value != null) return srgb.Val.Value;
|
||
var sys = el.GetFirstChild<A.SystemColor>();
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>hex 색상이 어두운지 판단 (명도 기준).</summary>
|
||
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; }
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// 이미지 삽입 헬퍼
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
|
||
/// <summary>이미지 경로 확인 — 절대 경로 / 작업 폴더 상대 경로 모두 지원.</summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 이미지 파일을 SlidePart에 ImagePart로 추가하고 ShapeTree에 Picture 요소를 생성합니다.
|
||
/// OpenXML의 Picture (p:pic) 구조: NonVisualPicProps → BlipFill → ShapeProperties.
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>이미지 배치 위치 계산. 가로세로 비율을 유지하면서 지정 위치에 배치.</summary>
|
||
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
|
||
};
|
||
}
|
||
|
||
/// <summary>지정 영역 안에 비율 유지하며 피팅.</summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>이미지 파일의 가로/세로 픽셀 읽기 (헤더만 파싱, 전체 로드 안 함).</summary>
|
||
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);
|
||
}
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// 내장 아이콘/심볼 라이브러리
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
|
||
/// <summary>아이콘 배치 좌표 계산.</summary>
|
||
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
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 내장 아이콘을 OpenXML 프리셋 도형으로 그립니다. 외부 파일 0바이트 — 100% 벡터.
|
||
/// OpenXML A.ShapeTypeValues에 정의된 프리셋 기하도형을 활용하며,
|
||
/// 프리셋이 없는 심볼은 유니코드 텍스트 기반으로 렌더링합니다.
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>OpenXML 프리셋 도형 매핑 — 벡터 도형으로 렌더링되는 아이콘 (70종+).</summary>
|
||
private static readonly Dictionary<string, A.ShapeTypeValues> 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,
|
||
};
|
||
|
||
/// <summary>OpenXML 프리셋 도형을 추가합니다. 단색 채움, 테두리 없음.</summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>유니코드 심볼을 큰 텍스트 박스로 렌더링합니다.</summary>
|
||
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);
|
||
}
|
||
}
|