核心理念:让模型直接看结果
大语言模型在排版时天然缺少实际渲染排版的结果预期。比如字体度量信息它不知道一段文本在某个宽度下会折成几行、实际占据的高度是多少。我们的思路简单直接不让模型猜而是提供一个精确的测量助手。模型用 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) {