Initial commit to new repository
This commit is contained in:
270
src/AxCopilot/Services/Agent/PptxSkill.cs
Normal file
270
src/AxCopilot/Services/Agent/PptxSkill.cs
Normal file
@@ -0,0 +1,270 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using DocumentFormat.OpenXml;
|
||||
using DocumentFormat.OpenXml.Packaging;
|
||||
using DocumentFormat.OpenXml.Presentation;
|
||||
using P = DocumentFormat.OpenXml.Presentation;
|
||||
using A = DocumentFormat.OpenXml.Drawing;
|
||||
|
||||
namespace AxCopilot.Services.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// PowerPoint (.pptx) 프레젠테이션을 네이티브 생성하는 스킬.
|
||||
/// OpenXML SDK를 사용하여 Python/Node 의존 없이 PPTX를 생성합니다.
|
||||
/// </summary>
|
||||
public class PptxSkill : IAgentTool
|
||||
{
|
||||
public string Name => "pptx_create";
|
||||
public string Description =>
|
||||
"Create a PowerPoint (.pptx) presentation. " +
|
||||
"Supports slide layouts: title (title+subtitle), content (title+body text), " +
|
||||
"two_column (title+left+right), table (title+headers+rows), blank. " +
|
||||
"No external runtime required (native OpenXML).";
|
||||
|
||||
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 (used on first slide if no explicit title slide)." },
|
||||
["slides"] = new()
|
||||
{
|
||||
Type = "array",
|
||||
Description = "Array of slide objects. Each slide: " +
|
||||
"{\"layout\": \"title|content|two_column|table|blank\", " +
|
||||
"\"title\": \"Slide Title\", " +
|
||||
"\"subtitle\": \"...\", " + // title layout
|
||||
"\"body\": \"...\", " + // content layout
|
||||
"\"left\": \"...\", \"right\": \"...\", " + // two_column
|
||||
"\"headers\": [...], \"rows\": [[...]], " + // table
|
||||
"\"notes\": \"Speaker notes\"}",
|
||||
Items = new() { Type = "object" }
|
||||
},
|
||||
["theme"] = new()
|
||||
{
|
||||
Type = "string",
|
||||
Description = "Color theme: professional (blue), modern (teal), dark (dark gray), minimal (light). Default: professional",
|
||||
Enum = ["professional", "modern", "dark", "minimal"]
|
||||
},
|
||||
},
|
||||
Required = ["path", "slides"]
|
||||
};
|
||||
|
||||
// 테마별 색상 정의
|
||||
private static readonly Dictionary<string, (string Primary, string Accent, string TextDark, string TextLight, string Bg)> Themes = new()
|
||||
{
|
||||
["professional"] = ("2B579A", "4B5EFC", "1A1A2E", "FFFFFF", "FFFFFF"),
|
||||
["modern"] = ("0D9488", "06B6D4", "1A1A2E", "FFFFFF", "FFFFFF"),
|
||||
["dark"] = ("374151", "6366F1", "F9FAFB", "FFFFFF", "1F2937"),
|
||||
["minimal"] = ("6B7280", "3B82F6", "111827", "FFFFFF", "FAFAFA"),
|
||||
};
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct)
|
||||
{
|
||||
var path = args.GetProperty("path").GetString() ?? "";
|
||||
var presTitle = args.TryGetProperty("title", out var tt) ? tt.GetString() ?? "Presentation" : "Presentation";
|
||||
var theme = args.TryGetProperty("theme", out var th) ? th.GetString() ?? "professional" : "professional";
|
||||
|
||||
if (!args.TryGetProperty("slides", out var slidesEl) || slidesEl.ValueKind != JsonValueKind.Array)
|
||||
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);
|
||||
|
||||
if (!Themes.TryGetValue(theme, out var colors))
|
||||
colors = Themes["professional"];
|
||||
|
||||
try
|
||||
{
|
||||
using var pres = PresentationDocument.Create(fullPath, PresentationDocumentType.Presentation);
|
||||
var presPart = pres.AddPresentationPart();
|
||||
presPart.Presentation = new P.Presentation();
|
||||
presPart.Presentation.SlideIdList = new SlideIdList();
|
||||
presPart.Presentation.SlideSize = new SlideSize
|
||||
{
|
||||
Cx = 12192000, // 10 inches
|
||||
Cy = 6858000, // 7.5 inches
|
||||
};
|
||||
presPart.Presentation.NotesSize = new NotesSize { Cx = 6858000, Cy = 9144000 };
|
||||
|
||||
uint slideId = 256;
|
||||
int slideCount = 0;
|
||||
|
||||
foreach (var slideEl in slidesEl.EnumerateArray())
|
||||
{
|
||||
var layout = slideEl.TryGetProperty("layout", out var lay) ? lay.GetString() ?? "content" : "content";
|
||||
var slideTitle = slideEl.TryGetProperty("title", out var st) ? st.GetString() ?? "" : "";
|
||||
var subtitle = slideEl.TryGetProperty("subtitle", out var sub) ? sub.GetString() ?? "" : "";
|
||||
var body = slideEl.TryGetProperty("body", out var bd) ? bd.GetString() ?? "" : "";
|
||||
var left = slideEl.TryGetProperty("left", out var lf) ? lf.GetString() ?? "" : "";
|
||||
var right = slideEl.TryGetProperty("right", out var rt) ? rt.GetString() ?? "" : "";
|
||||
var notes = slideEl.TryGetProperty("notes", out var nt) ? nt.GetString() ?? "" : "";
|
||||
|
||||
var slidePart = presPart.AddNewPart<SlidePart>();
|
||||
slidePart.Slide = new Slide(new CommonSlideData(new ShapeTree(
|
||||
new P.NonVisualGroupShapeProperties(
|
||||
new P.NonVisualDrawingProperties { Id = 1, Name = "" },
|
||||
new P.NonVisualGroupShapeDrawingProperties(),
|
||||
new ApplicationNonVisualDrawingProperties()),
|
||||
new GroupShapeProperties(new A.TransformGroup())
|
||||
)));
|
||||
|
||||
var shapeTree = slidePart.Slide.CommonSlideData!.ShapeTree!;
|
||||
uint shapeId = 2;
|
||||
|
||||
switch (layout)
|
||||
{
|
||||
case "title":
|
||||
// 배경 색상 박스
|
||||
AddRectangle(shapeTree, ref shapeId, 0, 0, 12192000, 6858000, colors.Primary);
|
||||
// 타이틀
|
||||
AddTextBox(shapeTree, ref shapeId, 600000, 2000000, 10992000, 1400000,
|
||||
slideTitle, 3600, colors.TextLight, true);
|
||||
// 서브타이틀
|
||||
if (!string.IsNullOrEmpty(subtitle))
|
||||
AddTextBox(shapeTree, ref shapeId, 600000, 3600000, 10992000, 800000,
|
||||
subtitle, 2000, colors.TextLight, false);
|
||||
break;
|
||||
|
||||
case "two_column":
|
||||
AddTextBox(shapeTree, ref shapeId, 600000, 300000, 10992000, 800000,
|
||||
slideTitle, 2800, colors.Primary, true);
|
||||
// 왼쪽 컬럼
|
||||
AddTextBox(shapeTree, ref shapeId, 600000, 1300000, 5200000, 5000000,
|
||||
left, 1600, colors.TextDark, false);
|
||||
// 오른쪽 컬럼
|
||||
AddTextBox(shapeTree, ref shapeId, 6400000, 1300000, 5200000, 5000000,
|
||||
right, 1600, colors.TextDark, false);
|
||||
break;
|
||||
|
||||
case "table":
|
||||
AddTextBox(shapeTree, ref shapeId, 600000, 300000, 10992000, 800000,
|
||||
slideTitle, 2800, colors.Primary, true);
|
||||
// 테이블은 텍스트로 시뮬레이션 (OpenXML 테이블은 매우 복잡)
|
||||
var tableText = FormatTableAsText(slideEl, colors);
|
||||
AddTextBox(shapeTree, ref shapeId, 600000, 1300000, 10992000, 5000000,
|
||||
tableText, 1400, colors.TextDark, false);
|
||||
break;
|
||||
|
||||
case "blank":
|
||||
// 빈 슬라이드
|
||||
break;
|
||||
|
||||
default: // content
|
||||
AddTextBox(shapeTree, ref shapeId, 600000, 300000, 10992000, 800000,
|
||||
slideTitle, 2800, colors.Primary, true);
|
||||
AddTextBox(shapeTree, ref shapeId, 600000, 1300000, 10992000, 5000000,
|
||||
body, 1600, colors.TextDark, false);
|
||||
break;
|
||||
}
|
||||
|
||||
// 슬라이드 등록
|
||||
presPart.Presentation.SlideIdList.AppendChild(new SlideId
|
||||
{
|
||||
Id = slideId++,
|
||||
RelationshipId = presPart.GetIdOfPart(slidePart)
|
||||
});
|
||||
slideCount++;
|
||||
}
|
||||
|
||||
presPart.Presentation.Save();
|
||||
|
||||
return ToolResult.Ok(
|
||||
$"✅ PPTX 생성 완료: {Path.GetFileName(fullPath)} ({slideCount}슬라이드, 테마: {theme})",
|
||||
fullPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ToolResult.Fail($"PPTX 생성 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddTextBox(ShapeTree tree, ref uint id, long x, long y, long cx, long cy,
|
||||
string text, int fontSize, string color, bool bold)
|
||||
{
|
||||
var shape = new Shape();
|
||||
shape.NonVisualShapeProperties = new P.NonVisualShapeProperties(
|
||||
new P.NonVisualDrawingProperties { Id = id++, Name = $"TextBox{id}" },
|
||||
new P.NonVisualShapeDrawingProperties(new A.ShapeLocks { NoGrouping = true }),
|
||||
new ApplicationNonVisualDrawingProperties());
|
||||
shape.ShapeProperties = new ShapeProperties(
|
||||
new A.Transform2D(
|
||||
new A.Offset { X = x, Y = y },
|
||||
new A.Extents { Cx = cx, Cy = cy }),
|
||||
new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle });
|
||||
|
||||
var txBody = new TextBody(
|
||||
new A.BodyProperties { Wrap = A.TextWrappingValues.Square },
|
||||
new A.ListStyle());
|
||||
|
||||
// 텍스트를 줄 단위로 분리
|
||||
var lines = text.Split('\n');
|
||||
foreach (var line in lines)
|
||||
{
|
||||
var para = new A.Paragraph();
|
||||
var run = new A.Run();
|
||||
var runProps = new A.RunProperties { Language = "ko-KR", FontSize = fontSize, Dirty = false };
|
||||
runProps.AppendChild(new A.SolidFill(new A.RgbColorModelHex { Val = color }));
|
||||
if (bold) runProps.Bold = true;
|
||||
run.AppendChild(runProps);
|
||||
run.AppendChild(new A.Text(line));
|
||||
para.AppendChild(run);
|
||||
txBody.AppendChild(para);
|
||||
}
|
||||
|
||||
shape.TextBody = txBody;
|
||||
tree.AppendChild(shape);
|
||||
}
|
||||
|
||||
private static void AddRectangle(ShapeTree tree, ref uint id, long x, long y, long cx, long cy, string fillColor)
|
||||
{
|
||||
var shape = new Shape();
|
||||
shape.NonVisualShapeProperties = new P.NonVisualShapeProperties(
|
||||
new P.NonVisualDrawingProperties { Id = id++, Name = $"Rect{id}" },
|
||||
new P.NonVisualShapeDrawingProperties(),
|
||||
new ApplicationNonVisualDrawingProperties());
|
||||
shape.ShapeProperties = new ShapeProperties(
|
||||
new A.Transform2D(
|
||||
new A.Offset { X = x, Y = y },
|
||||
new A.Extents { Cx = cx, Cy = cy }),
|
||||
new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle },
|
||||
new A.SolidFill(new A.RgbColorModelHex { Val = fillColor }));
|
||||
tree.AppendChild(shape);
|
||||
}
|
||||
|
||||
private static string FormatTableAsText(JsonElement slideEl, (string Primary, string Accent, string TextDark, string TextLight, string Bg) colors)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder();
|
||||
if (slideEl.TryGetProperty("headers", out var headers))
|
||||
{
|
||||
var headerTexts = new List<string>();
|
||||
foreach (var h in headers.EnumerateArray())
|
||||
headerTexts.Add(h.GetString() ?? "");
|
||||
sb.AppendLine(string.Join(" | ", headerTexts));
|
||||
sb.AppendLine(new string('─', headerTexts.Sum(h => h.Length + 5)));
|
||||
}
|
||||
|
||||
if (slideEl.TryGetProperty("rows", out var rows))
|
||||
{
|
||||
foreach (var row in rows.EnumerateArray())
|
||||
{
|
||||
var cells = new List<string>();
|
||||
foreach (var cell in row.EnumerateArray())
|
||||
cells.Add(cell.GetString() ?? cell.ToString());
|
||||
sb.AppendLine(string.Join(" | ", cells));
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user