拉取后替换为自己的模型就能直接跑起来看效果如果你对此感兴趣却没有模型可以实验的话也可以发邮件私聊我借账号本文内容由人类主导 AI 辅助编写核心理念让模型直接看结果大语言模型在排版时天然缺少实际渲染排版的结果预期。比如字体度量信息它不知道一段文本在某个宽度下会折成几行、实际占据的高度是多少。我们的思路简单直接不让模型猜而是提供一个精确的测量助手。模型用 SlideML 描述页面内容。可以只给定一部分约束剩余元素信息依靠布局和渲染引擎进行信息填充比如对于文本可以只写Width约束宽度高度不写然后依靠排版引擎回填具体的文本排版高度确定性渲染引擎拿到描述后用真实的字体和字号对文本进行排版得到实际行数和像素高度。引擎把ActualWidth、ActualHeight、ActualLineCount这些真实值填回 XML 里返回给模型。返回给到模型时还会包含可能存在的警告信息比如溢出画布等情况模型看到反馈数据发现溢出了下一轮就可以把字号改小或者把容器高度加大。模型只管设计意图引擎负责告诉它精确结果。如果模型支持多模态甚至可以将渲染截图一起送回连“间距不太协调”这类主观感觉也能被纠正。我的想法是不要追求模型一次性将事情做对而是要进行一轮轮迭代。迭代过程中还可以有人类参与人类可以看着渲染出来的结果进行反馈重复地让模型进行优化SlideML 的极简元素为了模型能轻松掌握而不产生幻觉SlideML 只保留幻灯片排版最核心的几种元素刻意压低了概念数量总共 20 个左右属性。大概一份 SlideML 的界面的代码如下Page Background#F5F5F5 Panel Idtop-bar X0 Y0 Width1280 Height80 Background#1A1A2E Padding32 TextElement Idlogo X0 Y20 TextSlideML FontNameArial FontSize24 Foreground#FFFFFF / /Panel TextElement Idmain-title X80 Y140 Width1120 Text让大语言模型生成幻灯片 FontSize48 Foreground#1A1A2E TextAlignmentCenter / Panel Idcards-row X80 Y260 Width1120 Height320 Rect Idcard1 X0 Y0 Width340 Height320 Fill#FFFFFF CornerRadius12 Stroke#E8E8E8 StrokeThickness1 / TextElement Idcard1-title X24 Y24 Width292 Text定义标签 FontSize22 Foreground#333 / !-- 其余卡片类似此处省略 -- /Panel /PagePage 画布根元素画布固定 1280×720。Page Background#FFFFFF ... /PagePanel 容器用于分组和嵌套子元素相对于它的左上角定位。Panel Idheader X0 Y0 Width1280 Height120 Padding24 Background#1A1A2E ... /PanelRect 矩形绘制卡片、色块等几何形状支持圆角和描边。Rect Idcard X40 Y160 Width380 Height280 Fill#FFFFFF Stroke#E0E0E0 StrokeThickness1 CornerRadius8 Opacity1.0 /TextElement 文本核心元素Text属性必填。一旦指定了Width引擎会在此宽度内自动换行并返回真实的尺寸数据。TextElement Idtitle X60 Y180 Width340 Text一段可能会换行的文本 FontNameMicrosoft YaHei FontSize29 Foreground#1A1A2E LineHeight1.4 /Image 图片通过Source给出资源 ID 而非实际路径。图片来源由上游系统如 RAG 检索、图库等在生成后解决不干扰 XML 结构。Image Idhero X800 Y160 Width400 Height400 Sourceimg_hero_001 StretchUniform /实现解析实现部分使用 C# 编写基于 Avalonia 做出简洁的预览界面和渲染引擎并通过 Microsoft.Agents.AI.OpenAI 连接大模型。整体流程是用户提出需求 → 模型输出 SlideML → 解析器转换成元素树 → 渲染器布局、绘制并回填数据 → 模型根据反馈再次修改 XML。下图是运行时的界面包含渲染预览和展示回填后的 XML 和警告信息。提示词怎么让模型学会 SlideML要让模型稳定输出符合规范的 XML需要非常细致的指令。提示词分成两部分系统提示词规则手册和用户提示词当前任务。系统提示词完整定义了所有标签、属性、排版规则和禁止事项。下面摘录部分内容足以看清其结构你是一个专业的幻灯片排版引擎。根据用户需求生成一份 SlideML 格式的 XML 文档。 ## SlideML 基本规则 - 画布尺寸固定为 1280x720 像素坐标原点在左上角 - 所有尺寸单位为 px不写单位颜色格式为 #RRGGBB 或 #AARRGGBB - 标签必须严格遵守定义不要创造新标签或新属性 ## 标签与属性 ### Page 属性: Background背景色可选默认 #FFFFFF ### Panel 属性: X, Y, Width, Height均可选, Padding可选默认 0, Background可选 ### Rect 属性: X, Y, Width, Height均可选, Fill, Stroke, StrokeThickness, CornerRadius, ... ### TextElement 属性: X, Y, Width, Height均可选, Text必填, FontName, FontSize, ... ### Image 属性: X, Y, Width, Height均可选, Source必填图片资源ID, Stretch, ... ## 禁止事项 - 不要写 ActualWidth、ActualHeight、ActualLineCount 属性 - 不要创造未定义的标签或属性 - 不要使用 XAML、CSS、HTML 等其他语法用户提示词根据场景动态构建。初次生成时将用户需求嵌入模板要求模型输出浅色主题、层级清晰、留白充足的单页private static string BuildInitialUserPrompt(string userPrompt) { return $ 请根据以下需求生成单页 SlideML {userPrompt} 要求 1. 尽量使用浅色主题视觉清爽 2. 标题、副标题、正文层级明显 3. 页面内容要适合 1280x720 4. 如果需要图片可以使用占位资源 ID如 image_001 5. 只输出 XML ; }当需要迭代时用户提示词会把原始需求、当前 XML 以及新的修改意见一起灌入让模型重新输出完整文档private static string BuildContinuationPrompt(string originalPrompt, string currentSlideXml, string userMessage) { return $ 这是一个正在迭代中的 SlideML 单页实验。 原始需求{originalPrompt} 当前版本 XML{currentSlideXml} 用户新的修改意见{userMessage} 请综合原始需求和新的修改意见输出一份完整的、可直接渲染的新版 SlideML XML。只输出 XML。 ; }解析器从 XML 到结构化数据解析器SlideMlParser是整个链条的第一步它不关心布局只把模型输出的 XML 字符串转成强类型的元素对象树。入口方法Parse收到一段 XML 后先做基本校验必须能正确解析根元素必须是Page。随后取出Background属性缺省用白色再遍历根元素下的所有子节点逐一交给ParseElement处理。public SlidePage Parse(string xml) { var document XDocument.Parse(xml); var root document.Root; var page new SlidePage { Background GetOptionalString(root, Background) ?? #FFFFFF, }; foreach (var child in root.Elements()) { page.Children.Add(ParseElement(child)); } return page; }ParseElement是一个分发方法根据标签名调用对应的构造逻辑。同时它会自动为没有Id的元素生成一个唯一标识格式为elem_001这种便于后续追踪。private SlideElement ParseElement(XElement element) { var id GetOptionalString(element, Id) ?? $elem_{_nextId:000}; return element.Name.LocalName switch { Panel ParsePanel(element, id), Rect ParseRect(element, id), TextElement ParseTextElement(element, id), Image ParseImageElement(element, id), _ throw new InvalidOperationException($不支持的标签: {element.Name.LocalName}) }; }以TextElement为例解析时会逐项提取属性。Text为必填缺失则直接报错。其他可选属性都有合理的默认值例如字体默认为Microsoft YaHei字号默认16行高默认1.2颜色默认黑色等。这种容错设计让模型即使偶尔漏写一些属性引擎也能顺利工作。private SlideTextElement ParseTextElement(XElement element, string id) { var text GetOptionalString(element, Text); if (string.IsNullOrWhiteSpace(text)) throw new InvalidOperationException($TextElement({id}) 必须包含 Text 属性。); return new SlideTextElement { Id id, X GetOptionalDouble(element, X), Y GetOptionalDouble(element, Y), Width GetOptionalDouble(element, Width), Height GetOptionalDouble(element, Height), Text text, FontName GetOptionalString(element, FontName) ?? Microsoft YaHei, FontSize GetOptionalDouble(element, FontSize) ?? 16, Foreground GetOptionalString(element, Foreground) ?? #000000, TextAlignment GetOptionalTextAlignment(element) ?? SlideTextAlignment.Left, LineHeight GetOptionalDouble(element, LineHeight) ?? 1.2, Opacity GetOptionalDouble(element, Opacity) ?? 1, }; }ParsePanel稍有不同它在设置完自身属性后会递归调用ParseElement来处理其内部的所有子元素从而构建出树的任意深度嵌套。其他如ParseRect、ParseImage的模式类似都是利用辅助方法GetOptionalString、GetOptionalDouble以及一系列GetOptionalXXXAlignment来完成属性读取使得整个解析器结构工整、容易扩展。渲染器测量、绘制与反馈SlideRenderer是确定性渲染引擎的核心负责将解析后的元素树在 1280×720 画布上精确布局、绘制并将实际测量到的尺寸回填供大模型下一轮迭代参考。解析器输出的是一棵由SlideElement派生类组成的树。SlideElement是所有元素的基类它携带了Id、X、Y、Width、Height、Opacity以及HorizontalAlignment/VerticalAlignment等可选属性。布局阶段不会修改这些构造属性只会填充四个运行时字段LocalBounds元素在自身坐标系中的区域左上角通常为(0,0)。LayoutBounds元素在父容器坐标系中的最终位置和大小。ActualWidth、ActualHeight布局后实际占用的像素尺寸。具体派生关系如下SlidePage是根节点含背景色和子元素列表。SlidePanelElement增加Padding、背景色以及自己的子元素列表。SlideRectElement带有填充、描边和圆角。SlideTextElement除了字体、字号、行高等文本属性外还有一个引擎写入的ActualLineCount实际行数和一个TextLayout对象。SlideImageElement有图片源和拉伸模式。渲染结果被封装进SlideRenderResult它包含原始输入 XML、回填了实际尺寸的输出 XML、警告列表和预览位图。渲染入口RenderAsync整个渲染流程在RenderAsync中编排其步骤为清洗 XML → 解析为元素树 → 布局 → 绘制 → 回填实际数据。public async TaskSlideRenderResult RenderAsync(string slideXml, CancellationToken ct) { var normalizedXml SlideXmlUtilities.NormalizeXml(SlideXmlUtilities.ExtractXml(slideXml)); var page _parser.Parse(normalizedXml); var warnings new Liststring(); var previewBitmap await Dispatcher.UIThread.InvokeAsync(() { LayoutChildren(page.Children, page.LayoutBounds, warnings, Page, clipToParent: false); var bitmap new RenderTargetBitmap(new PixelSize(CanvasWidth, CanvasHeight)); using (var ctx bitmap.CreateDrawingContext()) { ctx.FillRectangle(CreateBrush(page.Background, Colors.White), new Rect(0, 0, CanvasWidth, CanvasHeight)); DrawElements(ctx, page.Children, warnings); } return bitmap; }); var renderedXml SlideXmlUtilities.FormatRenderedXml(normalizedXml, id FindMetrics(page, id)); return new SlideRenderResult { InputXml normalizedXml, OutputXml renderedXml, Warnings warnings, PreviewBitmap previewBitmap, }; }布局引擎两遍测量与自动包裹布局由LayoutChildren发起它对每个子元素按类型分发到LayoutPanel、LayoutRect、LayoutText或LayoutImage。Panel自动尺寸与对齐Panel 的布局是最复杂的部分因为它需要根据子元素的内容自动决定自己的尺寸。我把整个过程拆成五个步骤来解释。第一步确定初猜的内容区域。如果 Panel 显式指定了Width或Height就直接使用它们否则使用父容器可用空间减去Padding作为初猜尺寸。第二步用初猜区域对子元素做一次预备布局。这步的目的是让所有子元素先自己计算一遍从而得到它们实际占据的范围。第三步收集子元素的边界算出 Panel 的真实宽高。遍历所有子元素的LocalBounds找出最大的Right和最下的Bottom再加上Padding就得到了 Panel 应有的ActualWidth和ActualHeight。第四步根据真实尺寸确定 Panel 在父容器中的位置。这里使用统一的ResolveOrigin方法它同时处理显式坐标X/Y和对齐关键字HorizontalAlignment/VerticalAlignment。第五步用真实的最终内容区域对子元素进行第二次正式布局。这保证了子元素拿到的父容器坐标系是准确的。关键代码片段——ResolveOrigin的实现非常简洁private static double ResolveOrigin(double parentOrigin, double parentSize, double elementSize, double? explicitOffset, SlideHorizontalAlignment? alignment) { if (explicitOffset is double x) return parentOrigin x; return alignment switch { SlideHorizontalAlignment.Center parentOrigin Math.Max(0, (parentSize - elementSize) / 2), SlideHorizontalAlignment.Right parentOrigin Math.Max(0, parentSize - elementSize), _ parentOrigin, }; }完整的LayoutPanel方法会在本小节的末尾贴出方便需要时对照。文本测量真实排版反馈LayoutText是闭环运转的核心它也遵循类似的步骤。第一步创建 Avalonia 的TextLayout对象。这里会根据文本的字体、字号、约束宽度等参数构造一个真正的排版对象。如果文本指定了Width则换行模式设为TextWrapping.Wrap否则为NoWrap。第二步从排版结果读取真实尺寸。TextLayout的WidthIncludingTrailingWhitespace和Height给出了精确的像素值。同时TextLines.Count就是实际的行数。这些值直接回填到元素上。第三步定位元素并处理溢出警告。如果模型在 XML 中指定了固定的Height但文本实际排版的高度超出了它引擎会根据平均行高算出当前容器最多能容纳多少行然后生成一条清晰的警告。这个过程中最核心的是TextLayout的创建和测量其余定位逻辑和 Panel 一样使用ResolveOrigin。// 创建排版对象的关键代码 var textLayout new TextLayout( text.Text, typeface, text.FontSize, foreground, MapTextAlignment(text.TextAlignment), text.Width is null ? TextWrapping.NoWrap : TextWrapping.Wrap, TextTrimming.None, null, FlowDirection.LeftToRight, maxWidth, maxHeight, lineHeight, 0, 0);布局阶段完整代码参考以下是LayoutPanel和LayoutText的完整实现读者可以结合上面的分解说明对照阅读。private static void LayoutPanel(SlidePanelElement panel, Rect parentBounds, Liststring warnings, string parentId, bool clipToParent) { var provisionalWidth panel.Width ?? Math.Max(0, parentBounds.Width - panel.Padding * 2); var provisionalHeight panel.Height ?? Math.Max(0, parentBounds.Height - panel.Padding * 2); var initialOrigin new Point(parentBounds.X (panel.X ?? 0) panel.Padding, parentBounds.Y (panel.Y ?? 0) panel.Padding); var provisionalBounds new Rect(initialOrigin.X, initialOrigin.Y, provisionalWidth, provisionalHeight); LayoutChildren(panel.Children, provisionalBounds, warnings, panel.Id, clipToParent: true); double contentRight 0, contentBottom 0; foreach (var child in panel.Children) { contentRight Math.Max(contentRight, child.LocalBounds.Right); contentBottom Math.Max(contentBottom, child.LocalBounds.Bottom); } var actualWidth panel.Width ?? (contentRight panel.Padding * 2); var actualHeight panel.Height ?? (contentBottom panel.Padding * 2); var originX ResolveOrigin(parentBounds.X, parentBounds.Width, actualWidth, panel.X, panel.HorizontalAlignment); var originY ResolveOrigin(parentBounds.Y, parentBounds.Height, actualHeight, panel.Y, panel.VerticalAlignment); panel.LocalBounds new Rect(0, 0, actualWidth, actualHeight); panel.LayoutBounds new Rect(originX, originY, actualWidth, actualHeight); panel.ActualWidth actualWidth; panel.ActualHeight actualHeight; var finalContentBounds new Rect(originX panel.Padding, originY panel.Padding, Math.Max(0, actualWidth - panel.Padding * 2), Math.Max(0, actualHeight - panel.Padding * 2)); LayoutChildren(panel.Children, finalContentBounds, warnings, panel.Id, clipToParent: true); ValidateBounds(panel, parentBounds, warnings, parentId, clipToParent); } private static void LayoutText(SlideTextElement text, Rect parentBounds, Liststring warnings, string parentId, bool clipToParent) { var foreground CreateBrush(text.Foreground, Colors.Black); var typeface new Typeface(new FontFamily(text.FontName)); var maxWidth text.Width ?? 10000; var maxHeight text.Height ?? 10000; var lineHeight text.FontSize * text.LineHeight; var textLayout new TextLayout( text.Text, typeface, text.FontSize, foreground, MapTextAlignment(text.TextAlignment), text.Width is null ? TextWrapping.NoWrap : TextWrapping.Wrap, TextTrimming.None, null, FlowDirection.LeftToRight, maxWidth, maxHeight, lineHeight, 0, 0); var measuredWidth text.Width ?? textLayout.WidthIncludingTrailingWhitespace; var measuredHeight text.Height ?? textLayout.Height; text.TextLayout textLayout; text.ActualLineCount textLayout.TextLines.Count; text.LocalBounds new Rect(text.X ?? 0, text.Y ?? 0, measuredWidth, measuredHeight); var originX ResolveOrigin(parentBounds.X, parentBounds.Width, measuredWidth, text.X, text.HorizontalAlignment); var originY ResolveOrigin(parentBounds.Y, parentBounds.Height, measuredHeight, text.Y, text.VerticalAlignment); text.LayoutBounds new Rect(originX, originY, measuredWidth, measuredHeight); text.ActualWidth measuredWidth; text.ActualHeight measuredHeight; if (text.Height is double fixedHeight textLayout.Height fixedHeight 0.1) { var averageLineHeight textLayout.TextLines.Count 0 ? lineHeight : textLayout.Height / textLayout.TextLines.Count; var visibleLineCount averageLineHeight 0 ? 0 : Math.Max(0, (int)Math.Floor(fixedHeight / averageLineHeight)); warnings.Add($[Warning] {text.Id}: ActualLineCount{text.ActualLineCount} $超出容器高度当前高度仅容纳 {visibleLineCount} 行); } ValidateBounds(text, parentBounds, warnings, parentId, clipToParent); }你可能已经注意到LayoutPanel中LayoutChildren被调用了两次。第一次调用使用的是预先猜测的provisionalBounds目的是让每一个子元素先自由布局一遍引擎借此收集所有子元素实际占据的内容边界最大Right和Bottom。第二次调用使用的是 Panel 自身尺寸最终确定后的finalContentBounds此时子元素拿到的父容器坐标系才是精确的这样才能保证后续的定位、对齐和裁剪完全准确。这种“先测量内容、再确定自身、最后正式布局”的两遍机制正是 Panel 能够根据内容自动调整大小的核心也让模型不用操心容器的确切高度只需声明设计意图引擎就会回填真实的度量数据。绘制顺序遍历与分派布局完成后DrawElements遍历所有元素根据类型调用对应的绘制方法。整个过程非常简单——没有深度重排完全按照元素在树中的顺序绘制。需要注意的一点是每个元素在绘制前都会用PushOpacity包装以支持透明度。private static void DrawElements(DrawingContext context, IReadOnlyListSlideElement elements, Liststring warnings) { foreach (var element in elements) { DrawElement(context, element, warnings); } } private static void DrawElement(DrawingContext context, SlideElement element, Liststring warnings) { using var opacity context.PushOpacity(ClampOpacity(element.Opacity)); switch (element) { case SlidePanelElement panel: DrawPanel(context, panel, warnings); break; case SlideRectElement rect: DrawRect(context, rect); break; case SlideTextElement text: DrawText(context, text); break; case SlideImageElement image: DrawImage(context, image); break; } }下面分别说明每种元素的绘制细节。PanelPanel 首先绘制自己的背景色如果有然后用PushClip将绘制区域裁剪为自身的LayoutBounds再递归绘制内部的子元素。这就实现了“超出部分不可见”的效果。private static void DrawPanel(DrawingContext context, SlidePanelElement panel, Liststring warnings) { if (!string.IsNullOrWhiteSpace(panel.Background)) { context.DrawRectangle(CreateBrush(panel.Background, Colors.Transparent), null, panel.LayoutBounds); } using var clip context.PushClip(panel.LayoutBounds); DrawElements(context, panel.Children, warnings); }Rect矩形支持圆角、填充和描边。CornerRadius大于 0 时会用RoundedRect来绘制。private static void DrawRect(DrawingContext context, SlideRectElement rect) { var fill string.IsNullOrWhiteSpace(rect.Fill) ? null : CreateBrush(rect.Fill, Colors.Transparent); var pen string.IsNullOrWhiteSpace(rect.Stroke) || rect.StrokeThickness 0 ? null : new Pen(CreateBrush(rect.Stroke, Colors.Transparent), rect.StrokeThickness); if (rect.CornerRadius 0) { context.DrawRectangle(fill, pen, new RoundedRect(rect.LayoutBounds, rect.CornerRadius)); } else { context.DrawRectangle(fill, pen, rect.LayoutBounds); } }Text文本直接用布局阶段已经创建好的TextLayout进行绘制。如果文本指定了固定高度而实际高度超过了它绘制时会先用PushClip裁剪避免文本越界。private static void DrawText(DrawingContext context, SlideTextElement text) { if (text.TextLayout is null) return; if (text.Height is double fixedHeight) { using var clip context.PushClip(new Rect( text.LayoutBounds.X, text.LayoutBounds.Y, text.LayoutBounds.Width, fixedHeight)); text.TextLayout.Draw(context, text.LayoutBounds.TopLeft); } else { text.TextLayout.Draw(context, text.LayoutBounds.TopLeft); } }Image图片绘制分为两种情况成功加载的图片会根据Stretch属性计算目标矩形加载失败的图片则绘制一个带边框的占位框并显示图片的资源 ID 作为提示。private static void DrawImage(DrawingContext context, SlideImageElement image) { var bounds image.LayoutBounds; if (image.Bitmap is { } bitmap) { var sourceSize bitmap.Size; var sourceRect new Rect(0, 0, sourceSize.Width, sourceSize.Height); var destRect CalculateImageDestination(bounds, sourceRect, image.Stretch); context.DrawImage(bitmap, sourceRect, destRect); return; } // 加载失败时绘制占位框 context.DrawRectangle( new SolidColorBrush(Color.Parse(#FFF8FAFC)), new Pen(new SolidColorBrush(Color.Parse(#FFCBD5E1)), 1), new RoundedRect(bounds, 12)); // 在占位框内绘制资源 ID var titleLayout new TextLayout( Image, new Typeface(new FontFamily(Microsoft YaHei)), 22, new SolidColorBrush(Color.Parse(#FF64748B)), TextAlignment.Center, TextWrapping.NoWrap, TextTrimming.None, null, FlowDirection.LeftToRight, bounds.Width, 48, 28, 0, 1); var sourceLayout new TextLayout( image.Source, new Typeface(new FontFamily(Microsoft YaHei)), 14, new SolidColorBrush(Color.Parse(#FF94A3B8)), TextAlignment.Center, TextWrapping.Wrap, TextTrimming.CharacterEllipsis, null, FlowDirection.LeftToRight, Math.Max(0, bounds.Width - 32), Math.Max(0, bounds.Height - 80), 18, 0, 2); titleLayout.Draw(context, new Point(bounds.X, bounds.Y Math.Max(16, bounds.Height * 0.32))); sourceLayout.Draw(context, new Point(bounds.X 16, bounds.Y Math.Max(48, bounds.Height * 0.32 36))); }边界校验把问题说得明明白白每个元素布局完成后ValidateBounds会检查LayoutBounds是否超出 1280×720 画布以及是否溢出父容器当clipToParent为 true 时。每一条警告都带有元素 Id 和精确的像素值方便大模型直接定位修正。private static void ValidateBounds(SlideElement element, Rect parentBounds, Liststring warnings, string parentId, bool clipToParent) { var bounds element.LayoutBounds; if (bounds.Right CanvasWidth) warnings.Add($[Warning] {element.Id}: 元素右边界 X{bounds.Right:F2} 超出画布宽度 {CanvasWidth}); if (bounds.Bottom CanvasHeight) warnings.Add($[Warning] {element.Id}: 元素下边界 Y{bounds.Bottom:F2} 超出画布高度 {CanvasHeight}); if (bounds.X 0) warnings.Add($[Warning] {element.Id}: 元素左边界 X{bounds.X:F2} 超出画布左侧 0); if (bounds.Y 0) warnings.Add($[Warning] {element.Id}: 元素上边界 Y{bounds.Y:F2} 超出画布顶部 0); if (clipToParent !parentBounds.Contains(bounds)) warnings.Add($[Warning] {element.Id}: 元素超出父容器 {parentId}超出部分将被裁剪); }回填实际尺寸到 XML所有绘制和警告收集完毕后FindMetrics递归遍历元素树根据Id取出ActualWidth、ActualHeight和ActualLineCount再由SlideXmlUtilities.FormatRenderedXml将它们作为新属性插回原始 XML。最终返回给模型的OutputXml类似这样TextElement Idtitle X60 Y180 Width340 Text... ActualWidth340 ActualHeight87 ActualLineCount2 /配合带精确数值的警告列表大模型可以在下一轮中精准地调整布局参数。具体做法是在 SlideXmlUtilities 里面重新解析原始文档遍历所有带有Id的元素从metricsProvider中取出对应的度量然后通过SetAttributeValue精确追加ActualWidth等属性。核心代码如下internal static class SlideXmlUtilities { public static string FormatRenderedXml(string xml, Funcstring, SlideRenderedMetrics? metricsProvider) { var document XDocument.Parse(xml, LoadOptions.PreserveWhitespace); var root document.Root; root.SetAttributeValue(ActualWidth, FormatNumber(SlideRenderer.CanvasWidth)); root.SetAttributeValue(ActualHeight, FormatNumber(SlideRenderer.CanvasHeight)); foreach (var element in root.DescendantsAndSelf().Where( t t.Name.LocalName is Page or Panel or Rect or TextElement or Image)) { var id (string?)element.Attribute(Id); if (string.IsNullOrWhiteSpace(id)) continue; var metrics metricsProvider(id); if (metrics is null) continue; element.SetAttributeValue(ActualWidth, FormatNumber(metrics.ActualWidth)); element.SetAttributeValue(ActualHeight, FormatNumber(metrics.ActualHeight)); if (metrics.ActualLineCount is not null) element.SetAttributeValue(ActualLineCount, metrics.ActualLineCount.Value); else element.Attribute(ActualLineCount)?.Remove(); } return document.ToString(); } }完整示例下面是一份完整的 SlideML 单页包含顶栏、主标题和三张卡片直观展示了它的结构和表现力。Page Background#F5F9FF TextElement X0 Y60 Width1280 TextSlideML 幻灯片排版引擎 FontSize36 Foreground#1A365D TextAlignmentCenter / TextElement X0 Y110 Width1280 Text轻量 · 规范 · 高效的幻灯片描述标准 FontSize20 Foreground#4A6FA5 TextAlignmentCenter / Panel X60 Y170 Width350 Height400 Rect X0 Y0 Width350 Height400 Fill#FFFFFF Stroke#E0E9F8 StrokeThickness1 CornerRadius16 / Rect X143 Y30 Width64 Height64 Fill#E1F0FF CornerRadius32 / TextElement X143 Y30 Width64 Height64 Text规 FontSize32 Foreground#4080FF TextAlignmentCenter VerticalAlignmentCenter / TextElement X0 Y110 Width350 Text规范语法 FontSize24 Foreground#1A365D TextAlignmentCenter / TextElement X30 Y160 Width290 Text严格遵循XML语法规范标签属性定义清晰无自定义扩展内容确保跨引擎渲染结果高度一致。 FontSize16 Foreground#555555 LineHeight1.5 TextAlignmentCenter / /Panel Panel X465 Y170 Width350 Height400 Rect X0 Y0 Width350 Height400 Fill#FFFFFF Stroke#E0E9F8 StrokeThickness1 CornerRadius16 / Rect X143 Y30 Width64 Height64 Fill#E6FFFA CornerRadius32 / TextElement X143 Y30 Width64 Height64 Text活 FontSize32 Foreground#00B42A TextAlignmentCenter VerticalAlignmentCenter / TextElement X0 Y110 Width350 Text灵活排版 FontSize24 Foreground#1A365D TextAlignmentCenter / TextElement X30 Y160 Width290 Text支持绝对定位与智能对齐属性自动适配内容尺寸多层级容器嵌套可满足各类复杂布局需求。 FontSize16 Foreground#555555 LineHeight1.5 TextAlignmentCenter / /Panel Panel X870 Y170 Width350 Height400 Rect X0 Y0 Width350 Height400 Fill#FFFFFF Stroke#E0E9F8 StrokeThickness1 CornerRadius16 / Rect X143 Y30 Width64 Height64 Fill#F9F0FF CornerRadius32 / TextElement X143 Y30 Width64 Height64 Text效 FontSize32 Foreground#722ED1 TextAlignmentCenter VerticalAlignmentCenter / TextElement X0 Y110 Width350 Text高效产出 FontSize24 Foreground#1A365D TextAlignmentCenter / TextElement X30 Y160 Width290 Text结构化描述方式易读易写可通过代码批量生成大幅提升批量幻灯片内容的生产效率。 FontSize16 Foreground#555555 LineHeight1.5 TextAlignmentCenter / /Panel TextElement X60 Y600 Width1160 TextSlideML 致力于打造标准化的幻灯片内容协议打通设计、开发、自动化生成全链路为大规模演示内容生产提供可靠的底层支持。 FontSize18 Foreground#333333 TextAlignmentCenter LineHeight1.5 / /Page注 明明 DSL 里面定义是没有圆形的为什么能画出圆形的图形出来其实这只是巧妙地利用了圆角矩形的特点当圆角半径设置得足够大达到矩形宽度或高度的一半时矩形本身就会被圆角完全“吃掉”视觉上自然就是一个完美的圆形代码本博客的完整源代码放在 github 和 gitee 上。我的整个代码仓库较大你可以使用如下命令只拉取这部分内容速度比较快。先创建一个空文件夹用命令行 cd 进入然后执行git init git remote add origin https://gitee.com/lindexi/lindexi_gd.git git pull origin 95309d0c3d86822c27310910333b0e8aec62b655如果 gitee 无法访问请切换到 github 源git remote remove origin git remote add origin https://github.com/lindexi/lindexi_gd.git git pull origin 95309d0c3d86822c27310910333b0e8aec62b655
探索用 SlideML 让大模型生成 PPT 的实验方法
发布时间:2026/6/29 19:21:34
拉取后替换为自己的模型就能直接跑起来看效果如果你对此感兴趣却没有模型可以实验的话也可以发邮件私聊我借账号本文内容由人类主导 AI 辅助编写核心理念让模型直接看结果大语言模型在排版时天然缺少实际渲染排版的结果预期。比如字体度量信息它不知道一段文本在某个宽度下会折成几行、实际占据的高度是多少。我们的思路简单直接不让模型猜而是提供一个精确的测量助手。模型用 SlideML 描述页面内容。可以只给定一部分约束剩余元素信息依靠布局和渲染引擎进行信息填充比如对于文本可以只写Width约束宽度高度不写然后依靠排版引擎回填具体的文本排版高度确定性渲染引擎拿到描述后用真实的字体和字号对文本进行排版得到实际行数和像素高度。引擎把ActualWidth、ActualHeight、ActualLineCount这些真实值填回 XML 里返回给模型。返回给到模型时还会包含可能存在的警告信息比如溢出画布等情况模型看到反馈数据发现溢出了下一轮就可以把字号改小或者把容器高度加大。模型只管设计意图引擎负责告诉它精确结果。如果模型支持多模态甚至可以将渲染截图一起送回连“间距不太协调”这类主观感觉也能被纠正。我的想法是不要追求模型一次性将事情做对而是要进行一轮轮迭代。迭代过程中还可以有人类参与人类可以看着渲染出来的结果进行反馈重复地让模型进行优化SlideML 的极简元素为了模型能轻松掌握而不产生幻觉SlideML 只保留幻灯片排版最核心的几种元素刻意压低了概念数量总共 20 个左右属性。大概一份 SlideML 的界面的代码如下Page Background#F5F5F5 Panel Idtop-bar X0 Y0 Width1280 Height80 Background#1A1A2E Padding32 TextElement Idlogo X0 Y20 TextSlideML FontNameArial FontSize24 Foreground#FFFFFF / /Panel TextElement Idmain-title X80 Y140 Width1120 Text让大语言模型生成幻灯片 FontSize48 Foreground#1A1A2E TextAlignmentCenter / Panel Idcards-row X80 Y260 Width1120 Height320 Rect Idcard1 X0 Y0 Width340 Height320 Fill#FFFFFF CornerRadius12 Stroke#E8E8E8 StrokeThickness1 / TextElement Idcard1-title X24 Y24 Width292 Text定义标签 FontSize22 Foreground#333 / !-- 其余卡片类似此处省略 -- /Panel /PagePage 画布根元素画布固定 1280×720。Page Background#FFFFFF ... /PagePanel 容器用于分组和嵌套子元素相对于它的左上角定位。Panel Idheader X0 Y0 Width1280 Height120 Padding24 Background#1A1A2E ... /PanelRect 矩形绘制卡片、色块等几何形状支持圆角和描边。Rect Idcard X40 Y160 Width380 Height280 Fill#FFFFFF Stroke#E0E0E0 StrokeThickness1 CornerRadius8 Opacity1.0 /TextElement 文本核心元素Text属性必填。一旦指定了Width引擎会在此宽度内自动换行并返回真实的尺寸数据。TextElement Idtitle X60 Y180 Width340 Text一段可能会换行的文本 FontNameMicrosoft YaHei FontSize29 Foreground#1A1A2E LineHeight1.4 /Image 图片通过Source给出资源 ID 而非实际路径。图片来源由上游系统如 RAG 检索、图库等在生成后解决不干扰 XML 结构。Image Idhero X800 Y160 Width400 Height400 Sourceimg_hero_001 StretchUniform /实现解析实现部分使用 C# 编写基于 Avalonia 做出简洁的预览界面和渲染引擎并通过 Microsoft.Agents.AI.OpenAI 连接大模型。整体流程是用户提出需求 → 模型输出 SlideML → 解析器转换成元素树 → 渲染器布局、绘制并回填数据 → 模型根据反馈再次修改 XML。下图是运行时的界面包含渲染预览和展示回填后的 XML 和警告信息。提示词怎么让模型学会 SlideML要让模型稳定输出符合规范的 XML需要非常细致的指令。提示词分成两部分系统提示词规则手册和用户提示词当前任务。系统提示词完整定义了所有标签、属性、排版规则和禁止事项。下面摘录部分内容足以看清其结构你是一个专业的幻灯片排版引擎。根据用户需求生成一份 SlideML 格式的 XML 文档。 ## SlideML 基本规则 - 画布尺寸固定为 1280x720 像素坐标原点在左上角 - 所有尺寸单位为 px不写单位颜色格式为 #RRGGBB 或 #AARRGGBB - 标签必须严格遵守定义不要创造新标签或新属性 ## 标签与属性 ### Page 属性: Background背景色可选默认 #FFFFFF ### Panel 属性: X, Y, Width, Height均可选, Padding可选默认 0, Background可选 ### Rect 属性: X, Y, Width, Height均可选, Fill, Stroke, StrokeThickness, CornerRadius, ... ### TextElement 属性: X, Y, Width, Height均可选, Text必填, FontName, FontSize, ... ### Image 属性: X, Y, Width, Height均可选, Source必填图片资源ID, Stretch, ... ## 禁止事项 - 不要写 ActualWidth、ActualHeight、ActualLineCount 属性 - 不要创造未定义的标签或属性 - 不要使用 XAML、CSS、HTML 等其他语法用户提示词根据场景动态构建。初次生成时将用户需求嵌入模板要求模型输出浅色主题、层级清晰、留白充足的单页private static string BuildInitialUserPrompt(string userPrompt) { return $ 请根据以下需求生成单页 SlideML {userPrompt} 要求 1. 尽量使用浅色主题视觉清爽 2. 标题、副标题、正文层级明显 3. 页面内容要适合 1280x720 4. 如果需要图片可以使用占位资源 ID如 image_001 5. 只输出 XML ; }当需要迭代时用户提示词会把原始需求、当前 XML 以及新的修改意见一起灌入让模型重新输出完整文档private static string BuildContinuationPrompt(string originalPrompt, string currentSlideXml, string userMessage) { return $ 这是一个正在迭代中的 SlideML 单页实验。 原始需求{originalPrompt} 当前版本 XML{currentSlideXml} 用户新的修改意见{userMessage} 请综合原始需求和新的修改意见输出一份完整的、可直接渲染的新版 SlideML XML。只输出 XML。 ; }解析器从 XML 到结构化数据解析器SlideMlParser是整个链条的第一步它不关心布局只把模型输出的 XML 字符串转成强类型的元素对象树。入口方法Parse收到一段 XML 后先做基本校验必须能正确解析根元素必须是Page。随后取出Background属性缺省用白色再遍历根元素下的所有子节点逐一交给ParseElement处理。public SlidePage Parse(string xml) { var document XDocument.Parse(xml); var root document.Root; var page new SlidePage { Background GetOptionalString(root, Background) ?? #FFFFFF, }; foreach (var child in root.Elements()) { page.Children.Add(ParseElement(child)); } return page; }ParseElement是一个分发方法根据标签名调用对应的构造逻辑。同时它会自动为没有Id的元素生成一个唯一标识格式为elem_001这种便于后续追踪。private SlideElement ParseElement(XElement element) { var id GetOptionalString(element, Id) ?? $elem_{_nextId:000}; return element.Name.LocalName switch { Panel ParsePanel(element, id), Rect ParseRect(element, id), TextElement ParseTextElement(element, id), Image ParseImageElement(element, id), _ throw new InvalidOperationException($不支持的标签: {element.Name.LocalName}) }; }以TextElement为例解析时会逐项提取属性。Text为必填缺失则直接报错。其他可选属性都有合理的默认值例如字体默认为Microsoft YaHei字号默认16行高默认1.2颜色默认黑色等。这种容错设计让模型即使偶尔漏写一些属性引擎也能顺利工作。private SlideTextElement ParseTextElement(XElement element, string id) { var text GetOptionalString(element, Text); if (string.IsNullOrWhiteSpace(text)) throw new InvalidOperationException($TextElement({id}) 必须包含 Text 属性。); return new SlideTextElement { Id id, X GetOptionalDouble(element, X), Y GetOptionalDouble(element, Y), Width GetOptionalDouble(element, Width), Height GetOptionalDouble(element, Height), Text text, FontName GetOptionalString(element, FontName) ?? Microsoft YaHei, FontSize GetOptionalDouble(element, FontSize) ?? 16, Foreground GetOptionalString(element, Foreground) ?? #000000, TextAlignment GetOptionalTextAlignment(element) ?? SlideTextAlignment.Left, LineHeight GetOptionalDouble(element, LineHeight) ?? 1.2, Opacity GetOptionalDouble(element, Opacity) ?? 1, }; }ParsePanel稍有不同它在设置完自身属性后会递归调用ParseElement来处理其内部的所有子元素从而构建出树的任意深度嵌套。其他如ParseRect、ParseImage的模式类似都是利用辅助方法GetOptionalString、GetOptionalDouble以及一系列GetOptionalXXXAlignment来完成属性读取使得整个解析器结构工整、容易扩展。渲染器测量、绘制与反馈SlideRenderer是确定性渲染引擎的核心负责将解析后的元素树在 1280×720 画布上精确布局、绘制并将实际测量到的尺寸回填供大模型下一轮迭代参考。解析器输出的是一棵由SlideElement派生类组成的树。SlideElement是所有元素的基类它携带了Id、X、Y、Width、Height、Opacity以及HorizontalAlignment/VerticalAlignment等可选属性。布局阶段不会修改这些构造属性只会填充四个运行时字段LocalBounds元素在自身坐标系中的区域左上角通常为(0,0)。LayoutBounds元素在父容器坐标系中的最终位置和大小。ActualWidth、ActualHeight布局后实际占用的像素尺寸。具体派生关系如下SlidePage是根节点含背景色和子元素列表。SlidePanelElement增加Padding、背景色以及自己的子元素列表。SlideRectElement带有填充、描边和圆角。SlideTextElement除了字体、字号、行高等文本属性外还有一个引擎写入的ActualLineCount实际行数和一个TextLayout对象。SlideImageElement有图片源和拉伸模式。渲染结果被封装进SlideRenderResult它包含原始输入 XML、回填了实际尺寸的输出 XML、警告列表和预览位图。渲染入口RenderAsync整个渲染流程在RenderAsync中编排其步骤为清洗 XML → 解析为元素树 → 布局 → 绘制 → 回填实际数据。public async TaskSlideRenderResult RenderAsync(string slideXml, CancellationToken ct) { var normalizedXml SlideXmlUtilities.NormalizeXml(SlideXmlUtilities.ExtractXml(slideXml)); var page _parser.Parse(normalizedXml); var warnings new Liststring(); var previewBitmap await Dispatcher.UIThread.InvokeAsync(() { LayoutChildren(page.Children, page.LayoutBounds, warnings, Page, clipToParent: false); var bitmap new RenderTargetBitmap(new PixelSize(CanvasWidth, CanvasHeight)); using (var ctx bitmap.CreateDrawingContext()) { ctx.FillRectangle(CreateBrush(page.Background, Colors.White), new Rect(0, 0, CanvasWidth, CanvasHeight)); DrawElements(ctx, page.Children, warnings); } return bitmap; }); var renderedXml SlideXmlUtilities.FormatRenderedXml(normalizedXml, id FindMetrics(page, id)); return new SlideRenderResult { InputXml normalizedXml, OutputXml renderedXml, Warnings warnings, PreviewBitmap previewBitmap, }; }布局引擎两遍测量与自动包裹布局由LayoutChildren发起它对每个子元素按类型分发到LayoutPanel、LayoutRect、LayoutText或LayoutImage。Panel自动尺寸与对齐Panel 的布局是最复杂的部分因为它需要根据子元素的内容自动决定自己的尺寸。我把整个过程拆成五个步骤来解释。第一步确定初猜的内容区域。如果 Panel 显式指定了Width或Height就直接使用它们否则使用父容器可用空间减去Padding作为初猜尺寸。第二步用初猜区域对子元素做一次预备布局。这步的目的是让所有子元素先自己计算一遍从而得到它们实际占据的范围。第三步收集子元素的边界算出 Panel 的真实宽高。遍历所有子元素的LocalBounds找出最大的Right和最下的Bottom再加上Padding就得到了 Panel 应有的ActualWidth和ActualHeight。第四步根据真实尺寸确定 Panel 在父容器中的位置。这里使用统一的ResolveOrigin方法它同时处理显式坐标X/Y和对齐关键字HorizontalAlignment/VerticalAlignment。第五步用真实的最终内容区域对子元素进行第二次正式布局。这保证了子元素拿到的父容器坐标系是准确的。关键代码片段——ResolveOrigin的实现非常简洁private static double ResolveOrigin(double parentOrigin, double parentSize, double elementSize, double? explicitOffset, SlideHorizontalAlignment? alignment) { if (explicitOffset is double x) return parentOrigin x; return alignment switch { SlideHorizontalAlignment.Center parentOrigin Math.Max(0, (parentSize - elementSize) / 2), SlideHorizontalAlignment.Right parentOrigin Math.Max(0, parentSize - elementSize), _ parentOrigin, }; }完整的LayoutPanel方法会在本小节的末尾贴出方便需要时对照。文本测量真实排版反馈LayoutText是闭环运转的核心它也遵循类似的步骤。第一步创建 Avalonia 的TextLayout对象。这里会根据文本的字体、字号、约束宽度等参数构造一个真正的排版对象。如果文本指定了Width则换行模式设为TextWrapping.Wrap否则为NoWrap。第二步从排版结果读取真实尺寸。TextLayout的WidthIncludingTrailingWhitespace和Height给出了精确的像素值。同时TextLines.Count就是实际的行数。这些值直接回填到元素上。第三步定位元素并处理溢出警告。如果模型在 XML 中指定了固定的Height但文本实际排版的高度超出了它引擎会根据平均行高算出当前容器最多能容纳多少行然后生成一条清晰的警告。这个过程中最核心的是TextLayout的创建和测量其余定位逻辑和 Panel 一样使用ResolveOrigin。// 创建排版对象的关键代码 var textLayout new TextLayout( text.Text, typeface, text.FontSize, foreground, MapTextAlignment(text.TextAlignment), text.Width is null ? TextWrapping.NoWrap : TextWrapping.Wrap, TextTrimming.None, null, FlowDirection.LeftToRight, maxWidth, maxHeight, lineHeight, 0, 0);布局阶段完整代码参考以下是LayoutPanel和LayoutText的完整实现读者可以结合上面的分解说明对照阅读。private static void LayoutPanel(SlidePanelElement panel, Rect parentBounds, Liststring warnings, string parentId, bool clipToParent) { var provisionalWidth panel.Width ?? Math.Max(0, parentBounds.Width - panel.Padding * 2); var provisionalHeight panel.Height ?? Math.Max(0, parentBounds.Height - panel.Padding * 2); var initialOrigin new Point(parentBounds.X (panel.X ?? 0) panel.Padding, parentBounds.Y (panel.Y ?? 0) panel.Padding); var provisionalBounds new Rect(initialOrigin.X, initialOrigin.Y, provisionalWidth, provisionalHeight); LayoutChildren(panel.Children, provisionalBounds, warnings, panel.Id, clipToParent: true); double contentRight 0, contentBottom 0; foreach (var child in panel.Children) { contentRight Math.Max(contentRight, child.LocalBounds.Right); contentBottom Math.Max(contentBottom, child.LocalBounds.Bottom); } var actualWidth panel.Width ?? (contentRight panel.Padding * 2); var actualHeight panel.Height ?? (contentBottom panel.Padding * 2); var originX ResolveOrigin(parentBounds.X, parentBounds.Width, actualWidth, panel.X, panel.HorizontalAlignment); var originY ResolveOrigin(parentBounds.Y, parentBounds.Height, actualHeight, panel.Y, panel.VerticalAlignment); panel.LocalBounds new Rect(0, 0, actualWidth, actualHeight); panel.LayoutBounds new Rect(originX, originY, actualWidth, actualHeight); panel.ActualWidth actualWidth; panel.ActualHeight actualHeight; var finalContentBounds new Rect(originX panel.Padding, originY panel.Padding, Math.Max(0, actualWidth - panel.Padding * 2), Math.Max(0, actualHeight - panel.Padding * 2)); LayoutChildren(panel.Children, finalContentBounds, warnings, panel.Id, clipToParent: true); ValidateBounds(panel, parentBounds, warnings, parentId, clipToParent); } private static void LayoutText(SlideTextElement text, Rect parentBounds, Liststring warnings, string parentId, bool clipToParent) { var foreground CreateBrush(text.Foreground, Colors.Black); var typeface new Typeface(new FontFamily(text.FontName)); var maxWidth text.Width ?? 10000; var maxHeight text.Height ?? 10000; var lineHeight text.FontSize * text.LineHeight; var textLayout new TextLayout( text.Text, typeface, text.FontSize, foreground, MapTextAlignment(text.TextAlignment), text.Width is null ? TextWrapping.NoWrap : TextWrapping.Wrap, TextTrimming.None, null, FlowDirection.LeftToRight, maxWidth, maxHeight, lineHeight, 0, 0); var measuredWidth text.Width ?? textLayout.WidthIncludingTrailingWhitespace; var measuredHeight text.Height ?? textLayout.Height; text.TextLayout textLayout; text.ActualLineCount textLayout.TextLines.Count; text.LocalBounds new Rect(text.X ?? 0, text.Y ?? 0, measuredWidth, measuredHeight); var originX ResolveOrigin(parentBounds.X, parentBounds.Width, measuredWidth, text.X, text.HorizontalAlignment); var originY ResolveOrigin(parentBounds.Y, parentBounds.Height, measuredHeight, text.Y, text.VerticalAlignment); text.LayoutBounds new Rect(originX, originY, measuredWidth, measuredHeight); text.ActualWidth measuredWidth; text.ActualHeight measuredHeight; if (text.Height is double fixedHeight textLayout.Height fixedHeight 0.1) { var averageLineHeight textLayout.TextLines.Count 0 ? lineHeight : textLayout.Height / textLayout.TextLines.Count; var visibleLineCount averageLineHeight 0 ? 0 : Math.Max(0, (int)Math.Floor(fixedHeight / averageLineHeight)); warnings.Add($[Warning] {text.Id}: ActualLineCount{text.ActualLineCount} $超出容器高度当前高度仅容纳 {visibleLineCount} 行); } ValidateBounds(text, parentBounds, warnings, parentId, clipToParent); }你可能已经注意到LayoutPanel中LayoutChildren被调用了两次。第一次调用使用的是预先猜测的provisionalBounds目的是让每一个子元素先自由布局一遍引擎借此收集所有子元素实际占据的内容边界最大Right和Bottom。第二次调用使用的是 Panel 自身尺寸最终确定后的finalContentBounds此时子元素拿到的父容器坐标系才是精确的这样才能保证后续的定位、对齐和裁剪完全准确。这种“先测量内容、再确定自身、最后正式布局”的两遍机制正是 Panel 能够根据内容自动调整大小的核心也让模型不用操心容器的确切高度只需声明设计意图引擎就会回填真实的度量数据。绘制顺序遍历与分派布局完成后DrawElements遍历所有元素根据类型调用对应的绘制方法。整个过程非常简单——没有深度重排完全按照元素在树中的顺序绘制。需要注意的一点是每个元素在绘制前都会用PushOpacity包装以支持透明度。private static void DrawElements(DrawingContext context, IReadOnlyListSlideElement elements, Liststring warnings) { foreach (var element in elements) { DrawElement(context, element, warnings); } } private static void DrawElement(DrawingContext context, SlideElement element, Liststring warnings) { using var opacity context.PushOpacity(ClampOpacity(element.Opacity)); switch (element) { case SlidePanelElement panel: DrawPanel(context, panel, warnings); break; case SlideRectElement rect: DrawRect(context, rect); break; case SlideTextElement text: DrawText(context, text); break; case SlideImageElement image: DrawImage(context, image); break; } }下面分别说明每种元素的绘制细节。PanelPanel 首先绘制自己的背景色如果有然后用PushClip将绘制区域裁剪为自身的LayoutBounds再递归绘制内部的子元素。这就实现了“超出部分不可见”的效果。private static void DrawPanel(DrawingContext context, SlidePanelElement panel, Liststring warnings) { if (!string.IsNullOrWhiteSpace(panel.Background)) { context.DrawRectangle(CreateBrush(panel.Background, Colors.Transparent), null, panel.LayoutBounds); } using var clip context.PushClip(panel.LayoutBounds); DrawElements(context, panel.Children, warnings); }Rect矩形支持圆角、填充和描边。CornerRadius大于 0 时会用RoundedRect来绘制。private static void DrawRect(DrawingContext context, SlideRectElement rect) { var fill string.IsNullOrWhiteSpace(rect.Fill) ? null : CreateBrush(rect.Fill, Colors.Transparent); var pen string.IsNullOrWhiteSpace(rect.Stroke) || rect.StrokeThickness 0 ? null : new Pen(CreateBrush(rect.Stroke, Colors.Transparent), rect.StrokeThickness); if (rect.CornerRadius 0) { context.DrawRectangle(fill, pen, new RoundedRect(rect.LayoutBounds, rect.CornerRadius)); } else { context.DrawRectangle(fill, pen, rect.LayoutBounds); } }Text文本直接用布局阶段已经创建好的TextLayout进行绘制。如果文本指定了固定高度而实际高度超过了它绘制时会先用PushClip裁剪避免文本越界。private static void DrawText(DrawingContext context, SlideTextElement text) { if (text.TextLayout is null) return; if (text.Height is double fixedHeight) { using var clip context.PushClip(new Rect( text.LayoutBounds.X, text.LayoutBounds.Y, text.LayoutBounds.Width, fixedHeight)); text.TextLayout.Draw(context, text.LayoutBounds.TopLeft); } else { text.TextLayout.Draw(context, text.LayoutBounds.TopLeft); } }Image图片绘制分为两种情况成功加载的图片会根据Stretch属性计算目标矩形加载失败的图片则绘制一个带边框的占位框并显示图片的资源 ID 作为提示。private static void DrawImage(DrawingContext context, SlideImageElement image) { var bounds image.LayoutBounds; if (image.Bitmap is { } bitmap) { var sourceSize bitmap.Size; var sourceRect new Rect(0, 0, sourceSize.Width, sourceSize.Height); var destRect CalculateImageDestination(bounds, sourceRect, image.Stretch); context.DrawImage(bitmap, sourceRect, destRect); return; } // 加载失败时绘制占位框 context.DrawRectangle( new SolidColorBrush(Color.Parse(#FFF8FAFC)), new Pen(new SolidColorBrush(Color.Parse(#FFCBD5E1)), 1), new RoundedRect(bounds, 12)); // 在占位框内绘制资源 ID var titleLayout new TextLayout( Image, new Typeface(new FontFamily(Microsoft YaHei)), 22, new SolidColorBrush(Color.Parse(#FF64748B)), TextAlignment.Center, TextWrapping.NoWrap, TextTrimming.None, null, FlowDirection.LeftToRight, bounds.Width, 48, 28, 0, 1); var sourceLayout new TextLayout( image.Source, new Typeface(new FontFamily(Microsoft YaHei)), 14, new SolidColorBrush(Color.Parse(#FF94A3B8)), TextAlignment.Center, TextWrapping.Wrap, TextTrimming.CharacterEllipsis, null, FlowDirection.LeftToRight, Math.Max(0, bounds.Width - 32), Math.Max(0, bounds.Height - 80), 18, 0, 2); titleLayout.Draw(context, new Point(bounds.X, bounds.Y Math.Max(16, bounds.Height * 0.32))); sourceLayout.Draw(context, new Point(bounds.X 16, bounds.Y Math.Max(48, bounds.Height * 0.32 36))); }边界校验把问题说得明明白白每个元素布局完成后ValidateBounds会检查LayoutBounds是否超出 1280×720 画布以及是否溢出父容器当clipToParent为 true 时。每一条警告都带有元素 Id 和精确的像素值方便大模型直接定位修正。private static void ValidateBounds(SlideElement element, Rect parentBounds, Liststring warnings, string parentId, bool clipToParent) { var bounds element.LayoutBounds; if (bounds.Right CanvasWidth) warnings.Add($[Warning] {element.Id}: 元素右边界 X{bounds.Right:F2} 超出画布宽度 {CanvasWidth}); if (bounds.Bottom CanvasHeight) warnings.Add($[Warning] {element.Id}: 元素下边界 Y{bounds.Bottom:F2} 超出画布高度 {CanvasHeight}); if (bounds.X 0) warnings.Add($[Warning] {element.Id}: 元素左边界 X{bounds.X:F2} 超出画布左侧 0); if (bounds.Y 0) warnings.Add($[Warning] {element.Id}: 元素上边界 Y{bounds.Y:F2} 超出画布顶部 0); if (clipToParent !parentBounds.Contains(bounds)) warnings.Add($[Warning] {element.Id}: 元素超出父容器 {parentId}超出部分将被裁剪); }回填实际尺寸到 XML所有绘制和警告收集完毕后FindMetrics递归遍历元素树根据Id取出ActualWidth、ActualHeight和ActualLineCount再由SlideXmlUtilities.FormatRenderedXml将它们作为新属性插回原始 XML。最终返回给模型的OutputXml类似这样TextElement Idtitle X60 Y180 Width340 Text... ActualWidth340 ActualHeight87 ActualLineCount2 /配合带精确数值的警告列表大模型可以在下一轮中精准地调整布局参数。具体做法是在 SlideXmlUtilities 里面重新解析原始文档遍历所有带有Id的元素从metricsProvider中取出对应的度量然后通过SetAttributeValue精确追加ActualWidth等属性。核心代码如下internal static class SlideXmlUtilities { public static string FormatRenderedXml(string xml, Funcstring, SlideRenderedMetrics? metricsProvider) { var document XDocument.Parse(xml, LoadOptions.PreserveWhitespace); var root document.Root; root.SetAttributeValue(ActualWidth, FormatNumber(SlideRenderer.CanvasWidth)); root.SetAttributeValue(ActualHeight, FormatNumber(SlideRenderer.CanvasHeight)); foreach (var element in root.DescendantsAndSelf().Where( t t.Name.LocalName is Page or Panel or Rect or TextElement or Image)) { var id (string?)element.Attribute(Id); if (string.IsNullOrWhiteSpace(id)) continue; var metrics metricsProvider(id); if (metrics is null) continue; element.SetAttributeValue(ActualWidth, FormatNumber(metrics.ActualWidth)); element.SetAttributeValue(ActualHeight, FormatNumber(metrics.ActualHeight)); if (metrics.ActualLineCount is not null) element.SetAttributeValue(ActualLineCount, metrics.ActualLineCount.Value); else element.Attribute(ActualLineCount)?.Remove(); } return document.ToString(); } }完整示例下面是一份完整的 SlideML 单页包含顶栏、主标题和三张卡片直观展示了它的结构和表现力。Page Background#F5F9FF TextElement X0 Y60 Width1280 TextSlideML 幻灯片排版引擎 FontSize36 Foreground#1A365D TextAlignmentCenter / TextElement X0 Y110 Width1280 Text轻量 · 规范 · 高效的幻灯片描述标准 FontSize20 Foreground#4A6FA5 TextAlignmentCenter / Panel X60 Y170 Width350 Height400 Rect X0 Y0 Width350 Height400 Fill#FFFFFF Stroke#E0E9F8 StrokeThickness1 CornerRadius16 / Rect X143 Y30 Width64 Height64 Fill#E1F0FF CornerRadius32 / TextElement X143 Y30 Width64 Height64 Text规 FontSize32 Foreground#4080FF TextAlignmentCenter VerticalAlignmentCenter / TextElement X0 Y110 Width350 Text规范语法 FontSize24 Foreground#1A365D TextAlignmentCenter / TextElement X30 Y160 Width290 Text严格遵循XML语法规范标签属性定义清晰无自定义扩展内容确保跨引擎渲染结果高度一致。 FontSize16 Foreground#555555 LineHeight1.5 TextAlignmentCenter / /Panel Panel X465 Y170 Width350 Height400 Rect X0 Y0 Width350 Height400 Fill#FFFFFF Stroke#E0E9F8 StrokeThickness1 CornerRadius16 / Rect X143 Y30 Width64 Height64 Fill#E6FFFA CornerRadius32 / TextElement X143 Y30 Width64 Height64 Text活 FontSize32 Foreground#00B42A TextAlignmentCenter VerticalAlignmentCenter / TextElement X0 Y110 Width350 Text灵活排版 FontSize24 Foreground#1A365D TextAlignmentCenter / TextElement X30 Y160 Width290 Text支持绝对定位与智能对齐属性自动适配内容尺寸多层级容器嵌套可满足各类复杂布局需求。 FontSize16 Foreground#555555 LineHeight1.5 TextAlignmentCenter / /Panel Panel X870 Y170 Width350 Height400 Rect X0 Y0 Width350 Height400 Fill#FFFFFF Stroke#E0E9F8 StrokeThickness1 CornerRadius16 / Rect X143 Y30 Width64 Height64 Fill#F9F0FF CornerRadius32 / TextElement X143 Y30 Width64 Height64 Text效 FontSize32 Foreground#722ED1 TextAlignmentCenter VerticalAlignmentCenter / TextElement X0 Y110 Width350 Text高效产出 FontSize24 Foreground#1A365D TextAlignmentCenter / TextElement X30 Y160 Width290 Text结构化描述方式易读易写可通过代码批量生成大幅提升批量幻灯片内容的生产效率。 FontSize16 Foreground#555555 LineHeight1.5 TextAlignmentCenter / /Panel TextElement X60 Y600 Width1160 TextSlideML 致力于打造标准化的幻灯片内容协议打通设计、开发、自动化生成全链路为大规模演示内容生产提供可靠的底层支持。 FontSize18 Foreground#333333 TextAlignmentCenter LineHeight1.5 / /Page注 明明 DSL 里面定义是没有圆形的为什么能画出圆形的图形出来其实这只是巧妙地利用了圆角矩形的特点当圆角半径设置得足够大达到矩形宽度或高度的一半时矩形本身就会被圆角完全“吃掉”视觉上自然就是一个完美的圆形代码本博客的完整源代码放在 github 和 gitee 上。我的整个代码仓库较大你可以使用如下命令只拉取这部分内容速度比较快。先创建一个空文件夹用命令行 cd 进入然后执行git init git remote add origin https://gitee.com/lindexi/lindexi_gd.git git pull origin 95309d0c3d86822c27310910333b0e8aec62b655如果 gitee 无法访问请切换到 github 源git remote remove origin git remote add origin https://github.com/lindexi/lindexi_gd.git git pull origin 95309d0c3d86822c27310910333b0e8aec62b655