别再让 AI 瞎编坐标:用布局树让 PPT 自动排得稳

一句话先说结论:AI 负责决定"哪些内容放一起、横着排还是竖着排、比例大概是多少";程序负责把这些意图稳定地换算成坐标。这样既保留 AI 的灵活性,又不把像素级几何计算交给概率模型。
文章目录
- [别再让 AI 瞎编坐标:用布局树让 PPT 自动排得稳](#别再让 AI 瞎编坐标:用布局树让 PPT 自动排得稳)
-
- [一、痛点场景:AI 生成的 PPT,为什么总有几页"看起来不对劲"?](#一、痛点场景:AI 生成的 PPT,为什么总有几页“看起来不对劲”?)
- 二、先说明白:这里的"灵活排版"是什么?
- 三、它是怎么演进过来的?从"坐标猜测"到"意图编译"
- 四、为什么不让模型直接给坐标?
- 五、动手:写一个最小可运行的布局树求解器
-
- [5.1 求解器到底做了什么?](#5.1 求解器到底做了什么?)
- [5.2 把布局树做成可校验的数据契约](#5.2 把布局树做成可校验的数据契约)
- 六、企业项目如何接入?一条可上线的处理链
-
- [6.1 统一不同布局模式的下游接口](#6.1 统一不同布局模式的下游接口)
- [6.2 处理内容适配,而不是只把框切好](#6.2 处理内容适配,而不是只把框切好)
- [6.3 编辑器交互、换排布与失败兜底](#6.3 编辑器交互、换排布与失败兜底)
- 七、企业级稳定性的重点:幂等、双端一致、可回归
- 八、和常见方案怎么选?优劣势对比
- 九、常见业务场景与设计边界
- 十、面试官高频问题
-
- [1. 为什么不让大模型直接生成 `x/y/w/h`?](#1. 为什么不让大模型直接生成
x/y/w/h?) - [2. `ratios` 和 `grow` 有什么区别?](#2.
ratios和grow有什么区别?) - [3. 为什么需要布局树,而不是平铺的列表?](#3. 为什么需要布局树,而不是平铺的列表?)
- [4. `normalize` 为什么需要幂等?](#4.
normalize为什么需要幂等?) - [5. 为什么让最后一个子块吸收浮点误差?](#5. 为什么让最后一个子块吸收浮点误差?)
- [6. 怎么保证前端 TypeScript 与后端 Python 结果一致?](#6. 怎么保证前端 TypeScript 与后端 Python 结果一致?)
- [7. 模型输出了不存在的 block ID,应该自动修复吗?](#7. 模型输出了不存在的 block ID,应该自动修复吗?)
- [8. 这种求解器的时间复杂度是多少?](#8. 这种求解器的时间复杂度是多少?)
- [1. 为什么不让大模型直接生成 `x/y/w/h`?](#1. 为什么不让大模型直接生成
- 十一、落地检查清单
- 转载声明
一、痛点场景:AI 生成的 PPT,为什么总有几页"看起来不对劲"?
想象一下,你在做一份季度经营复盘。输入"收入增长、区域对比、渠道贡献"后,AI 很快生成了十几页 PPT。标题、图表、结论都有了,但有几页图片和文字互相压住;有的左右边距不一致;还有一页只写了三条要点,却留下大块空白。你让模型"把左边图片缩小一点",它改完又把标题挤到了画布外。
这不是模型不会写内容,而是让它直接承担了不适合它的工作:精确计算每个元素的 x / y / width / height。大语言模型擅长从语义上组织内容,却不适合作为几何求解器。它一次给出的坐标看似合理,换一组文字、字体或图片后就可能失效。
另一个常见方案是给模型一批固定模板。它确实稳定,但模板一多就难维护;模板一少,页面就容易长得一样。三条要点和八条要点共用同一块区域,也会分别出现留白过大和内容溢出的情况。

本文结合上传教程中的 AI PPT 项目,拆解一套更稳的做法:使用 layout_tree 描述排版意图,再用 Python 的 flex_solve 确定性计算坐标。原教程页面可从这里查看:《9. 灵活排版》原始页面。1
二、先说明白:这里的"灵活排版"是什么?
这里讲的是 AI PPT 项目中的一套自定义布局树与坐标求解机制 ,不是把 CSS 的 display: flex 原封不动搬到 PowerPoint。它借鉴了"按行或按列排列、按权重分配空间"的思路,但布局树、权重规则、归一化、PPTX 导出和质量护栏都由项目自己定义。
可以把它理解成一张"排版施工图"。树节点只表达结构:容器是 row(横向)还是 column(纵向),孩子之间按什么比例分空间,间隙有多大;叶子节点对应真实内容块。求解器递归遍历这棵树,最后才输出每个块的矩形坐标。
下面是一个"左侧大图、右侧标题和三条要点"的结构。模型只需要给出这种结构关系,不需要直接猜坐标。

| 字段 | 大白话含义 | 常见用途 |
|---|---|---|
row / column |
孩子横排还是竖排 | 先分左右,再把右边内容上下排列 |
ratios |
横向各列的宽度权重 | [38, 62] 表示左侧约 38%、右侧约 62% |
grow |
纵向孩子分配剩余高度的权重 | 标题 0.4、正文 1.0,正文获得更多高度 |
gap_pt |
子块之间的间距 | 统一控制卡片或内容块留白 |
bleed |
允许叶子越过安全边距延伸到画布边缘 | 全幅背景图、通栏大图 |
专业一点说,树是布局的中间表示(Intermediate Representation,IR)。模型生成的是语义结构,求解器把结构编译成几何数据。这个分层的关键价值,是让模型输出的不确定性止步于"结构提案",而不直接污染渲染坐标。
三、它是怎么演进过来的?从"坐标猜测"到"意图编译"
在上传教程描述的项目设计里,方案大致经历了三步。第一步是让模型直接输出坐标;很快遇到重叠、边距不齐和内容变化后失效的问题。第二步改用固定槽位模板:layout_id 选择一套预定义矩形,再把内容填进去;它稳定,但难覆盖所有内容组合,重复感也明显。第三步采用嵌套布局树:保留固定布局用于特殊页面,同时新增 layout_mode = "flex",由 layout_tree + flex_solve 处理可变布局。1

这不是说固定模板过时了。企业系统里常见的好设计是混合模式 :品牌封面、法律声明页、强约束的财务页继续用固定模板;内容组合多、需要快速换排布的普通页使用布局树。教程中的实现也把两种模式汇总为统一的 PlacedBlock 几何接口,下游渲染和导出不必关心坐标来自模板还是树。1
W3C 的 CSS Flexbox 标准同样面向沿主轴排列、分配剩余空间和对齐内容的需求;它的标准化历史从 2009 年的工作草案一路演进到后续候选推荐草案。2 3 这里仅把 CSS Flexbox 当作"声明排列意图、由布局算法计算尺寸"的类比。不要把 CSS Flexbox 标准日期误当成本文自定义布局树的项目历史。
四、为什么不让模型直接给坐标?
坐标是几何结果,不是语义意图。模型可以较容易判断"图片应在左,结论应突出",却很难持续保证 20 多个坐标同时满足不重叠、留白统一、字号可读和内容完整等约束。尤其是在用户拖拽、模型重新生成、内容字数变化后,坐标之间的约束关系会越来越复杂。
布局树把职责拆开后,模型只回答"怎么组织",确定性算法回答"放在哪儿"。同一棵树、同一张画布、同一套规则,求解结果就可以稳定复现。结构化输出可以约束模型返回的数据形状;OpenAI 的 Structured Outputs 文档说明,该能力可令返回内容符合给定 JSON Schema。不过,"符合 JSON Schema"不等于"满足业务语义",块 ID 是否完整、树是否过深、权重是否合理仍要由服务端检查。5

五、动手:写一个最小可运行的布局树求解器
下面用纯 Python 实现一个足够小、但具备核心机制的版本:支持 row 和 column 递归切分、ratios 和 grow 权重、以点数表达的 gap_pt,输出 0 到 1 之间的归一化矩形。代码不依赖第三方库,可用于理解算法;生产环境还要补上完整的树结构校验、内容测量、bleed 边缘扩展和图元质量检测。
先准备运行环境。这里不需要安装依赖;如果要继续使用下文的 Pydantic 校验示例,再安装 pydantic。
bash
mkdir flex-layout-demo
cd flex-layout-demo
python -m venv .venv
source .venv/bin/activate
# Windows PowerShell 可用:.venv\Scripts\Activate.ps1
python --version
把下面代码保存为 flex_demo.py,然后执行 python flex_demo.py。矩形使用归一化坐标,因此 [0, 1] 的位置和尺寸可以映射到不同大小的画布。代码中的 ratios=[38, 62] 是权重,求解时会自动归一化;grow 只在纵向 column 容器中分配高度。
python
from __future__ import annotations
from dataclasses import dataclass, field
from math import isfinite
from typing import Literal, TypeAlias
@dataclass(frozen=True)
class Rect:
x: float
y: float
w: float
h: float
@dataclass
class Leaf:
block_id: str
grow: float = 1.0
bleed: bool = False
@dataclass
class Container:
direction: Literal["row", "column"]
children: list[Node] = field(default_factory=list)
gap_pt: float = 16.0
ratios: list[float] | None = None
grow: float = 1.0
Node: TypeAlias = Leaf | Container
@dataclass(frozen=True)
class PlacedBlock:
block_id: str
rect: Rect
bleed: bool
def _weights(values: list[float], expected: int) -> list[float]:
if len(values) != expected:
raise ValueError(f"期望 {expected} 个权重,实际收到 {len(values)} 个")
if any(not isfinite(value) or value <= 0 for value in values):
raise ValueError("布局权重必须是有限的正数")
total = sum(values)
return [value / total for value in values]
def solve(
root: Node,
*,
canvas_w_pt: float = 960.0,
canvas_h_pt: float = 540.0,
padding_pt: float = 32.0,
) -> list[PlacedBlock]:
if canvas_w_pt <= 0 or canvas_h_pt <= 0:
raise ValueError("画布宽高必须大于 0")
if padding_pt < 0 or 2 * padding_pt >= min(canvas_w_pt, canvas_h_pt):
raise ValueError("padding_pt 必须非负且小于画布短边的一半")
safe = Rect(
padding_pt / canvas_w_pt,
padding_pt / canvas_h_pt,
1 - 2 * padding_pt / canvas_w_pt,
1 - 2 * padding_pt / canvas_h_pt,
)
placed: list[PlacedBlock] = []
def visit(node: Node, area: Rect, depth: int = 0) -> None:
if depth > 16:
raise ValueError("布局树超过最大深度 16")
if isinstance(node, Leaf):
placed.append(PlacedBlock(node.block_id, area, node.bleed))
return
if node.gap_pt < 0 or not isfinite(node.gap_pt):
raise ValueError("gap_pt 必须是有限的非负数")
count = len(node.children)
if count == 0:
return
horizontal = node.direction == "row"
start = area.x if horizontal else area.y
extent = area.w if horizontal else area.h
canvas_extent = canvas_w_pt if horizontal else canvas_h_pt
gap = node.gap_pt / canvas_extent
total_gap = gap * max(count - 1, 0)
if total_gap > extent and count > 1:
gap = extent / (count - 1)
total_gap = extent
available = max(extent - total_gap, 0.0)
if horizontal:
weights = (
_weights(node.ratios, count)
if node.ratios is not None
else [1 / count] * count
)
else:
weights = _weights([child.grow for child in node.children], count)
cursor = start
end = start + extent
for index, (child, weight) in enumerate(zip(node.children, weights)):
# 最后一个孩子吸收浮点残差,确保贴齐父容器右边或下边。
size = end - cursor if index == count - 1 else available * weight
if horizontal:
child_area = Rect(cursor, area.y, max(size, 0.0), area.h)
else:
child_area = Rect(area.x, cursor, area.w, max(size, 0.0))
visit(child, child_area, depth + 1)
cursor += size + gap
visit(root, safe)
return placed
def example_tree() -> Container:
return Container(
direction="row",
ratios=[38, 62],
children=[
Leaf("hero-image", bleed=True),
Container(
direction="column",
children=[
Leaf("title", grow=0.4),
Container(
direction="row",
ratios=[1, 1, 1],
children=[
Leaf("point-1"),
Leaf("point-2"),
Leaf("point-3"),
],
),
],
),
],
)
if __name__ == "__main__":
blocks = solve(example_tree())
assert [block.block_id for block in blocks] == [
"hero-image", "title", "point-1", "point-2", "point-3"
]
for block in blocks:
r = block.rect
assert r.w >= 0 and r.h >= 0
print(
f"{block.block_id:10s} "
f"x={r.x:.4f} y={r.y:.4f} "
f"w={r.w:.4f} h={r.h:.4f} bleed={block.bleed}"
)
命令行运行后,每个内容块都会打印一行归一化矩形。x/y 是左上角的位置,w/h 是宽高。将归一化矩形乘以画布宽高,就能获得渲染器需要的实际尺寸。示例保留了 bleed 标记,但没有在求解器内执行出血扩展;生产实现应在坐标切分之后,仅对本来就贴近画布安全区边缘的块扩展对应方向,避免中间图片意外撑大。1
5.1 求解器到底做了什么?
遇到 row,算法先把间隙从总宽度中扣除,再按 ratios 权重切分可用宽度;遇到 column,则按孩子各自的 grow 权重分配高度。切出的矩形继续递归,直到叶子节点。总复杂度与树节点数成正比,遍历一遍就是 O(N)。
最后一个孩子的尺寸不要只用"总可用空间 × 最后一个权重"计算。更稳妥的做法是用父容器的边界减去当前游标,把累计浮点误差留给最后一块吸收。这样右边缘或下边缘能与父区域对齐,不容易出现细小缝隙。1

5.2 把布局树做成可校验的数据契约
实际项目里,树会经过模型、API、编辑器和渲染器多个环节,建议用 Pydantic 定义数据结构。它会依据类型注解和约束解析输入,并让验证后的实例符合字段类型;但树深度、块覆盖集合等业务条件仍应显式编写。6
下面示例使用 Pydantic v2。先安装依赖:
bash
python -m pip install "pydantic>=2"
python
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
class LayoutNode(BaseModel):
model_config = ConfigDict(extra="forbid")
type: Literal["row", "column", "block"]
id: str
block_id: str | None = None
children: list["LayoutNode"] = Field(default_factory=list)
ratios: list[float] | None = None
grow: float = 1.0
gap_pt: float = 16.0
bleed: bool = False
@model_validator(mode="after")
def check_shape(self):
if self.type == "block":
if not self.block_id or self.children:
raise ValueError("block 必须有 block_id,且不能包含 children")
else:
if self.block_id is not None or not self.children:
raise ValueError("row/column 必须有 children,且不能绑定 block_id")
if self.gap_pt < 0 or self.grow <= 0:
raise ValueError("gap_pt 必须非负,grow 必须为正数")
if self.ratios is not None:
if self.type != "row" or len(self.ratios) != len(self.children):
raise ValueError("ratios 只能用于 row,且必须与孩子数量一致")
if any(value <= 0 for value in self.ratios):
raise ValueError("ratios 中的每个值都必须大于 0")
return self
LayoutNode.model_rebuild()
def leaf_ids(node: LayoutNode) -> list[str]:
if node.type == "block":
return [node.block_id]
return [block_id for child in node.children for block_id in leaf_ids(child)]
def validate_block_coverage(root: LayoutNode, expected_ids: set[str]) -> None:
actual = leaf_ids(root)
if len(actual) != len(set(actual)):
raise ValueError("布局树中出现重复 block_id")
if set(actual) != expected_ids:
missing = expected_ids - set(actual)
extra = set(actual) - expected_ids
raise ValueError(f"block 集合不匹配:missing={missing}, extra={extra}")
# 用真实项目时,raw_json 来自模型响应,expected_ids 来自页面内容块。
# 这里用一个最小样例验证:左侧图片块 + 右侧标题块。
raw_json = '''
{
"type": "row",
"id": "root",
"ratios": [38, 62],
"children": [
{"type": "block", "id": "leaf-image", "block_id": "image"},
{"type": "block", "id": "leaf-title", "block_id": "title"}
]
}
'''
expected_ids = {"image", "title"}
root = LayoutNode.model_validate_json(raw_json)
validate_block_coverage(root, expected_ids)
JSON Schema 负责形状约束,Pydantic 负责解析与服务端校验,布局求解器负责计算几何。不要只靠提示词写"请保证格式正确",也不要把模型响应当成可信输入。对于不可恢复的树错误,应拒绝这个候选并走预设布局或确定性种子树,而不是悄悄删掉丢失的内容块。
六、企业项目如何接入?一条可上线的处理链
一个常见的服务端流程可以拆成:内容块建模、模型提议布局树、结构化解析、业务校验、规范化、内容适配、坐标求解、质量检查、统一渲染与导出。模型输出树只是"提议",不应成为最终坐标真相。

6.1 统一不同布局模式的下游接口
可以像编译器一样,把固定模板和灵活树都编译到统一的 PlacedBlock 列表。渲染器、质量检查器和 PPTX 导出器只消费几何结果,无须各自实现一遍布局分支。教程里采用 layout_mode 区分固定模式与 flex 模式,再让统一入口解析几何结果。1
下面是一个简化的接口示例:
python
from dataclasses import dataclass
@dataclass(frozen=True)
class Rect:
x: float
y: float
w: float
h: float
@dataclass(frozen=True)
class PlacedBlock:
block_id: str
rect: Rect
def resolve_slide_geometry(slide) -> list[PlacedBlock]:
if slide.layout_mode == "flex":
return solve(slide.layout_tree)
return resolve_fixed_slots(slide.layout_id, slide.blocks)
导出 PPTX 时,把 0 到 1 的归一化坐标转换为 PowerPoint 实际画布尺寸。例如使用 python-pptx,形状添加接口接收左、上、宽、高;这与"求解器只管几何,导出器负责单位转换"的分层很匹配。7
安装依赖:
bash
python -m pip install python-pptx
python
from pptx import Presentation
from flex_demo import example_tree, solve
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[6])
slide_w = prs.slide_width
slide_h = prs.slide_height
# 示例从 demo 求解器取一个归一化矩形;项目里替换成对应 block 的 rect。
rect = solve(example_tree())[0].rect
x = int(rect.x * slide_w)
y = int(rect.y * slide_h)
w = int(rect.w * slide_w)
h = int(rect.h * slide_h)
shape = slide.shapes.add_textbox(x, y, w, h)
shape.text_frame.text = "由统一几何接口放置的文本块"
prs.save("demo.pptx")
注意:生产代码应直接使用 pptx 库中的 slide_width、slide_height 和 EMU 整数,不要把上面导入的 Inches 与 EMU 混用。此示例故意使用画布尺寸做单位转换;实际值须与生成归一化矩形时采用的安全区约定保持一致。
6.2 处理内容适配,而不是只把框切好
相同高度的文本框不代表相同的排版效果。三条要点与八条要点若分到同样高度,短内容会显得空,长内容则可能溢出。项目级实现可以先按字体度量估算文字自然高度,再按换行后的行数调整 grow;图片通常更适合吸收富余空间。教程中也把宽度拟合和内容高度拟合拆成两个阶段:先确定可读宽度,宽度决定折行;再按文字度量回推高度权重。1
还要区分"几何不越界"和"内容可读"。前者是坐标算法的职责;字号下限、行数上限、文本是否溢出、图片裁剪比例、对比度等属于内容与质量护栏。企业项目应在渲染后跑自动检查,并为无法自动修复的页面提供编辑器预览,而不是把"矩形都在画布内"当作版式合格。
6.3 编辑器交互、换排布与失败兜底
用户拖动分割线时,前端更新的是 ratios 或 grow,而不是手写每个块的坐标。增加、删除或移动内容块时,也应通过树操作函数同步更新树与块集合。跨容器拖动如果需要保留原区域,可用 spacer(占位节点)保留空间,避免其他块忽然变大。
"换排布"可以同时准备两类候选:模型根据当前内容提出新树;预设库根据块类型与数量匹配结构。候选进入页面前,统一经过 normalize、叶子块 ID 集合校验和去重。若模型候选都无效,就回退到预设或种子树,确保用户不会拿到一张无法渲染的页面。1

七、企业级稳定性的重点:幂等、双端一致、可回归
如果浏览器用 TypeScript 预览,后端用 Python 保存和导出,就会出现一个真实痛点:用户拖到满意的位置,松开鼠标后,后端重新算出的比例略有不同,页面"跳一下"。要避免这种体验,前后端必须共享算法约定、常量和边界规则,并用同一组 golden JSON 样本回归。
另一个关键概念是 normalize 幂等:
python
normalize(normalize(tree)) == normalize(tree)
用大白话说,第一次规范化之后,再跑一次不能继续改变结果。比如把比例吸附到有限的设计值 {33, 38, 50, 62, 67},就要保证吸附后的值再跑时不会被吸到另一个值。grow 可以设置明确上下限;树深度、每行子节点数和权重总量也应设置边界。教程采用黄金样本同时验证前端和后端,并强调新增规则时先补样本,减少只改一端的风险。1

实际测试建议至少覆盖这些类别:空容器和单孩子、默认均分、权重和不为 100 的比例、超大间距、极窄画布、深层嵌套、重复或缺失 block ID、bleed 只贴边扩展、浮点边界、normalize 重复调用。对于 Python 与 TypeScript 的几何一致性,比较结果时应先约定浮点容差;如业务要求逐字节一致,则统一数值量化和舍入规则。
八、和常见方案怎么选?优劣势对比
下面比较的是解决同一类排版问题时可能采用的方案,不是说它们都是可以直接互换的库。CSS Flexbox / Grid 是浏览器里的 CSS 布局机制;布局树求解器则是可被 AI、编辑器和多个渲染目标共同调用的应用层组件。
| 方案 | 优势 | 短板 | 更适合的情况 |
|---|---|---|---|
| 模型直接输出绝对坐标 | 上手快,表达自由 | 坐标容易重叠、漂移;很难稳定满足边界约束 | 一次性草图或人工复核的概念稿 |
| 固定模板槽位 | 输出稳定、测试简单 | 组合有限、重复明显;新内容类型要补模板 | 品牌固定、页型少、版式不允许自由变化 |
| CSS Flexbox | 浏览器原生;擅长单主轴排列、分配剩余空间和对齐 | 本身不是 PPT 布局数据契约;导出到原生可编辑形状需额外映射 | Web 页面一行/一列组件布局 |
| CSS Grid | 行列二维关系清晰,适合对齐严格的网格 | 对单行/单列流式内容可能显得偏重;非浏览器导出仍需桥接 | 仪表盘、卡片墙、固定行列结构 |
| 自定义布局树 + 确定性求解器 | 意图与坐标分离;跨端复用;容易做验证、编辑和回归 | 需要自行维护 schema、求解器、边界规则和内容适配 | AI PPT、可视化编辑器、多渲染目标的企业项目 |

MDN 对 CSS Flexbox 与 Grid 的选择建议很实用:如果主要控制一行或一列,优先考虑 Flexbox;如果要同时控制行和列,Grid 更直接。4 但在 AI PPT 系统里,问题还包括模型如何提议结构、服务端如何验证、编辑器如何保存、PPTX 如何输出,所以应用层布局树解决的是比 CSS 属性选择更完整的一条链路。
九、常见业务场景与设计边界
AI 生成 PPT 或报告:每页内容块数和类型变化大,布局树可以为同一批内容快速生成多个结构候选。对于强品牌约束的首页或财务页,保留固定模板更稳。
在线页面编辑器:拖拽操作调整权重,插入或删除块后更新树。前后端共享数据协议和 golden cases,能降低"本地预览正常、保存后变形"的概率。
同一内容输出多端:相同布局意图可先转为统一几何,再分别交给 Web SVG/Canvas 渲染、图片渲染或 PPTX 导出器。需要确认各输出端的字体、文本测量和裁剪语义一致,否则几何一样不一定视觉一样。
不适用或需要扩展的场景 :需要大量元素自由重叠、复杂锚点、绝对定位、曲线和任意形状的海报,不适合只靠 row/column 二分切分。可以用固定模板处理特殊页;若确实要支持重叠,再显式增加 overlay 一类节点和对应的约束测试,不要把自由坐标偷偷塞进模型输出。

十、面试官高频问题

1. 为什么不让大模型直接生成 x/y/w/h?
因为坐标是一组互相约束的数值。模型可能生成局部看起来合理的值,但难以稳定保证整体不重叠、间距一致、内容不溢出。更可靠的做法是模型生成有边界的结构意图,由确定性算法计算几何。
2. ratios 和 grow 有什么区别?
在本文约定中,ratios 描述 row 中各列的横向权重;grow 描述 column 中各行的纵向权重。权重只是相对值,例如 [38, 62] 与 [19, 31] 归一化后分配比例相同。
3. 为什么需要布局树,而不是平铺的列表?
平铺列表只能表达顺序或单层行列。嵌套树能表达"先把页面切为左右两区,再把右区切成标题和要点",复杂结构可以通过递归组合,同时每个节点都有明确的局部上下文。
4. normalize 为什么需要幂等?
树通常会多次经过保存、编辑、重试和回放。如果每次规范化都会改变比例,页面就会不断漂移。幂等可以让重复处理不产生额外变化,也让重试安全、测试稳定。
5. 为什么让最后一个子块吸收浮点误差?
如果每一块都独立乘比例,浮点小数相加后可能与父容器总宽度差一点。最后一块直接使用"父边界减去当前游标",能把累计残差集中吸收,避免边缘缝隙。
6. 怎么保证前端 TypeScript 与后端 Python 结果一致?
共享规范、常量、黄金样本和边界规则;按相同输入比较输出;为浮点值制定容差或固定量化规则;每次新增特性先更新 golden cases,再分别运行两端测试。
7. 模型输出了不存在的 block ID,应该自动修复吗?
通常不应静默修复。它代表模型对当前页面内容的理解已经错位。拒绝候选并回退到预设或确定性种子树,可以避免静默漏内容,并且更容易排查问题。
8. 这种求解器的时间复杂度是多少?
若每个节点只遍历一次,每个节点的孩子只处理一次,求解是 O(N),其中 N 是布局树节点数。树深度还要限制,避免异常或恶意输入造成递归过深。
十一、落地检查清单
上线前,至少确认这几件事:模型只输出布局树而非最终坐标;输入经过 JSON Schema 与服务端业务校验;每个内容块恰好对应一个叶子;权重、深度、节点数、间距都有上限;布局求解是纯函数;normalize 幂等;Python 与 TypeScript 共享黄金样本;异常候选能回退;渲染后还检查文本溢出、重叠和越界;导出器使用与求解器一致的画布和安全区定义。

如果只记住一个原则,请记住:树描述意图,求解器计算坐标,质量护栏决定结果能否交付。 这套拆分并不会让每张幻灯片自动变成设计师作品,但它能显著减少模型随机性直接变成几何错误的机会,并让系统更容易测试、编辑和扩展。
转载声明
本文为原创文章,如需转载,请联系作者获得授权,并注明出处。