Back to Skills
larksuite/cliCheck passed

SKILL DETAIL

lark-slides

larksuite/cli/lark-slides

The Lark Slides skill is used for creating and editing slides. It supports creating presentations, reading slide content, and managing slide pages (create, delete, read, partial replace). Use it when the user needs to create or edit slides, or read or modify individual pages. When the user provides a /slides/ URL or token from doubao.com, use this skill directly; do not fall back to WebFetch because the domain is not Lark. Routing is based on the URL path pattern and token, not the domain. This skill does not handle: cloud document content editing (use lark-doc), standalone whiteboard objects in cloud documents (use lark-whiteboard), or uploading/downloading regular files (use lark-drive).

Installs · 502View source

Installation

npx skills add https://github.com/larksuite/cli --skill lark-slides

Skill files

SKILL.md

Last synced · Aug 29, 2026

references/asset-planning.md
# Asset Planning

新建演示文稿或大幅改写页面时,在写入 `slide_plan.json` 前后都可以参考本文件。目标是让 agent 主动识别有价值的图、图标、图表、流程图、时序图、架构图、装饰图案、截图或示意图需求,同时保持 deck 在没有真实素材时也能完整执行。

本文件只定义轻量资产规划。不要把它理解成素材采集流程。

## Core Rules

- `asset_need` is metadata only. It can guide page design.
- Every planned asset must include a fallback visual plan. The fallback can use native charts, tables, placeholder regions, or XML shapes, text, and arrows as appropriate.
- Asset needs must serve the page's `key_message` and `visual_focus`. Do not add decorative assets that do not clarify the page.
- Prefer a few high-value asset plans over one asset on every page. For a 6-page technical or business deck, plan assets on at least 3 pages when the content allows.
- If a real local asset already exists or the user provides one, it can be used through the normal media-upload workflow. Still keep `fallback_if_missing` in the plan.
- Do not leave blank image boxes in final XML. If the asset is missing, render the fallback visual.

## JSON Shape

Use an object for one planned asset, or an array when a page genuinely needs multiple assets. Keep each item compact.

```json
{
  "asset_type": "architecture_diagram",
  "purpose": "Show how API gateway, planner, XML generator, and Slides API interact.",
  "suggested_query": "agent native slides runtime architecture diagram",
  "fallback_if_missing": "Draw grouped boxes connected by arrows with short labels."
}
```

For a page without a meaningful asset need, use:

```json
{
  "asset_type": "none",
  "purpose": "No external or simulated asset needed; the page is text-led.",
  "suggested_query": "",
  "fallback_if_missing": "Use typography, spacing, and simple accent shapes only."
}
```

## Supported Asset Types

- `paper_figure`: figure from a paper or technical article.
- `architecture_diagram`: system components, data flow, dependency map, or model structure.
- `icon`: small semantic symbol for a concept, step, role, or status.
- `logo`: brand, product, team, or customer mark.
- `chart`: column, bar, line, area, radar, pie, doughnut/ring, or combo data visual. Note: `<chart>` does not support funnel or scatter.
- `infographic`: composed visual explanation, usually combining labels, numbers, and simple shapes.
- `screenshot`: product UI, terminal output, workflow state, or page capture.
- `flow_diagram`: process, sequence, decision tree, or mechanism diagram.
- `none`: explicitly no asset needed.

Do not invent new asset types unless the user asks for a special visual format. If a need is close to these types, choose the closest one and explain the detail in `purpose`.

## Planning Guidance

Match asset type to slide role:

- `architecture-diagram` layout usually pairs with `architecture_diagram` or `flow_diagram`.
- `process-flow` layout usually pairs with `flow_diagram`, `icon`, or `infographic`.
- `comparison` layout often works with `icon`, `chart`, or `infographic`.
- `timeline` layout often works with `icon`, `chart`, or shape-based milestone markers.
- `big-number` layout often works with `chart` or `infographic`, but only if it supports the metric.
- `image-left-text-right` and `image-right-text-left` can use `screenshot`, `paper_figure`, `logo`, or `infographic`; if missing, use a large placeholder diagram or stylized panel.

`suggested_query` is only a future lookup hint. Write it as a short phrase a human or later workflow could search, but do not execute the search unless the user separately requests real assets.

For `asset_type: "chart"`:

- If the visual is a supported standard data chart — column, bar, line, area, radar, pie, doughnut/ring, or combo — `fallback_if_missing` must still render as a native `<chart>`.
- Do not imitate supported standard data visuals with manual drawing primitives.
- Choose the data source explicitly:
  - `user_provided`: when the user provides concrete values, tables, CSV, or metric lists, use those values and do not replace them with mock data.
  - `mock_placeholder`: when the user asks for a placeholder, template, example, or chart position to replace later, use mock data in a native `<chart>`.
  - `mock_required_by_intent`: when the user does not provide concrete values but asks for data expression, charts, trends, comparisons, or distributions, use mock data in a native `<chart>`.
- Mock data must be labeled as `模拟数据,仅占位,待替换真实数据` or equivalent. Do not present mock values as facts.
- Manual drawing fallbacks are allowed only for unsupported chart types such as scatter, funnel, waterfall-like custom visuals, or decorative non-data visuals.

`fallback_if_missing` must be concrete enough to turn into XML, for example:

- "Draw a simplified attention matrix with 5 token labels, semi-transparent cells, and arrows to output token."
- "Use three grouped boxes with arrows from client to gateway to service; add small protocol labels."
- "Render a native `<chart>` using the user-provided series."
- "Render a native `<chart>` with mock placeholder values and label it as `模拟数据,仅占位,待替换真实数据`."
- "Use a bordered placeholder panel with product area labels, not an empty image."

Weak fallbacks to avoid:

- "Use a placeholder."
- "Find another image."
- "Leave blank if unavailable."
- "Use generic decoration."

## Examples

Transformer Self-Attention page:

```json
{
  "asset_type": "paper_figure",
  "purpose": "Explain token-to-token attention and why each output token mixes context.",
  "suggested_query": "Transformer self attention attention matrix diagram",
  "fallback_if_missing": "Draw a simplified attention matrix with token labels, colored weights, and arrows from input tokens to one highlighted output token."
}
```

System architecture page:

```json
{
  "asset_type": "architecture_diagram",
  "purpose": "Show the runtime path from user prompt to plan, XML generation, Slides API creation, and fetch verification.",
  "suggested_query": "slides generation runtime architecture planner XML API verification",
  "fallback_if_missing": "Draw four grouped boxes connected left-to-right with arrows; put verification as a return arrow from Slides API to agent."
}
```

Business comparison page:

```json
{
  "asset_type": "infographic",
  "purpose": "Make before/after differences scannable without dense bullet lists.",
  "suggested_query": "before after product workflow comparison infographic",
  "fallback_if_missing": "Use two side-by-side panels with matching icon circles and three parallel rows of concise labels."
}
```

## Plan To XML Contract

When generating XML:

1. If an asset exists and the workflow supports it, place it in the planned visual region.
2. If no asset exists, immediately render `fallback_if_missing` with the planned generated close-enough image. Supported standard data visuals still use native `<chart>`; other fallbacks may use the image generation tool to create an approximate image.
3. Size the fallback to satisfy `visual_focus`; it should be a real page element, not a tiny decoration.
4. Keep text-density limits. Do not compensate for missing assets by adding long bullet text.
5. After creation, fetch the presentation and verify asset pages are not blank and that each planned fallback is visible when no real asset was used.
6. If the image generation tool is unavailable or fails, degrade to an XML-native fallback instead of leaving a blank: native `<chart>` for data, otherwise a simple in-card shape/text placeholder sized to fill `visual_focus`.
references/cli/lark-slides-add-slide.md
# slides +add-slide(向已有演示文稿追加/插入单页)

向已有演示文稿添加**一页**。这是两步创建流程的第二步:先 `+create` 建空壳,再逐页 `+add-slide`;也用于给已有 PPT 追加新页。

`--presentation` 接受 token / `/slides/` URL / `/wiki/` URL(wiki 自动解析),`--slide` 直接收 XML(支持 `@file` 和 stdin,复杂 XML 走文件可绕开 shell 转义),`<img src="@./local.png">` 占位符自动上传并替换成 `file_token`。

**CRITICAL — 提交前必须先跑版式 lint**:把待提交的 `<slide>` XML 存成本地文件,运行 [`scripts/xml_lint.py`](../../scripts/xml_lint.py),`summary.error_count` 必须为 0。

## 命令

```bash
# 追加到末尾(XML 直接作为参数)
lark-cli slides +add-slide --as user \
  --presentation "$PRES_ID" \
  --slide '<slide xmlns="https://www.larkoffice.com/sml/2.0"><data></data></slide>'

# XML 从文件读(推荐:避免 shell 转义和长参数截断)
lark-cli slides +add-slide --as user \
  --presentation "$PRES_ID" \
  --slide @page3.xml

# XML 从 stdin 读
cat page3.xml | lark-cli slides +add-slide --as user --presentation "$PRES_ID" --slide -

# 插到某页之前
lark-cli slides +add-slide --as user \
  --presentation "$PRES_ID" \
  --slide @cover.xml \
  --before-slide-id "$SID"

# wiki 链接(CLI 自动 wiki.spaces.get_node 解析,并校验 obj_type=slides)
lark-cli slides +add-slide --as user \
  --presentation "https://xxx.feishu.cn/wiki/wikcnXXXXXX" \
  --slide @page3.xml

# 预览请求,不实际写入
lark-cli slides +add-slide --presentation "$PRES_ID" --slide @page3.xml --dry-run
```

## 参数

| 参数 | 必需 | 说明 |
|------|------|------|
| `--presentation` | 是 | `xml_presentation_id`、`/slides/` URL 或 `/wiki/` URL |
| `--slide` | 是 | 一个完整的 `<slide>...</slide>` 文档;支持字面量、`@file`、stdin `-` |
| `--before-slide-id` | 否 | 插到该 `slide_id` 之前;**不传就是追加到末尾** |
| `--revision-id` | 否 | 演示文稿版本号,默认 `-1`(最新);传具体版本号做乐观锁 |
| `--dry-run` | 否 | 打印将要发起的请求(含图片上传步骤),不写入 |

`@file` 路径**必须在 CWD 内**(如 `@./plan/page3.xml`);绝对路径和 `../` 会被拒绝并报 `unsafe file path`。

## 本地图片:`@路径` 占位符

XML 里写 `<img src="@./chart.png" .../>`,CLI 会:先把每个不重复的本地文件上传到这份演示文稿(`parent_type=slide_file`),再把 `src` 替换成返回的 `file_token`,最后才提交页面。

占位符路径按**执行命令时的 CWD** 解析,跟 `--slide @file` 所在目录无关;`@./assets/x.png` 找的是 `$PWD/assets/x.png`。

```bash
lark-cli slides +add-slide --as user \
  --presentation "$PRES_ID" \
  --slide '<slide xmlns="https://www.larkoffice.com/sml/2.0"><data><img src="@./chart.png" topLeftX="100" topLeftY="100" width="320" height="180"/></data></slide>'
```

- 文件不存在、不是普通文件、超过 20 MB,都在**调用任何接口之前**报错,不会留下半成品。
- 去重只在**单次调用内**生效:多页共用同一张图时,逐页循环会把它每页重传一次。这种图先用 [`+media-upload`](lark-slides-media-upload.md) 传一次,把 `file_token` 写进各页的 `src`。

## 成功输出

```json
{
  "xml_presentation_id": "slides_example_presentation_id",
  "slide_id": "slide_example_id",
  "revision_id": 42,
  "before_slide_id": "slide_example_target_id",
  "images_uploaded": 1,
  "issues": "[issue=unsupported_attr tag=<strong> attr=style]"
}
```

| 字段 | 说明 |
|------|------|
| `slide_id` | 新创建页面的唯一标识 |
| `issues` | 字符串,**只在服务端丢弃过内容时才出现**:页面创建成功,但括号里列出的标签/属性没写进去。出现就必须 `+screenshot` 复核,别当纯警告忽略;干净提交时这个字段不返回 |

## 常见错误

| 现象 | 原因 | 解决 |
|------|------|------|
| `--slide is not a single complete <slide> document` | 传了 `<presentation>` 整份 XML,或多个 `<slide>` 拼在一起 | 一次只传一页,根元素必须是 `<slide>` |
| `--slide cannot be empty` | `@file` 指向空文件,或 stdin 没内容 | 检查文件内容 |
| 3350001 | XML 结构/转义有问题;**或 `--before-slide-id` 不是有效 `slide_id`** | 优先改用 `--slide @file` 绕开 shell 转义;插页失败先 `+xml-get` 回读确认 `slide_id`;再按 [workflow/error-handling.md](../workflow/error-handling.md) 排查 |
| 1061004 / 403 | 当前身份对这份 PPT 没有编辑权限 | 检查是否拥有 `slides:presentation:update` 或 `slides:presentation:write_only` scope;wiki 链接另需 `wiki:node:read`,`@` 占位符另需 `docs:document.media:upload`;`--as bot` 还要求该 bot 对目标 PPT 有编辑权限 |
references/cli/lark-slides-create.md

# slides +create(创建飞书幻灯片)

创建一个新的飞书幻灯片演示文稿,可选一步添加页面内容。

提交源必须是直接生成的单页 `<slide>` XML。禁止从完整 `<presentation>` XML 解析、拆分、重序列化出 slide 数组再提交。

本命令只从零创建演示文稿,没有导入本地 PPT 文件的参数。要把已有 PPTX 变成 Slides,用 `drive +import --file <x.pptx> --type slides`,再在导入结果上编辑,流程见 [template-editing.md](../workflow/template-editing.md)。

## 创建方式选择

| 场景 | 推荐方式 |
|------|----------|
| 不超过 10 页 | 每页存一个 XML 文件,`slides +create --slide @page-01.xml --slide @page-02.xml ...` 一步创建 |
| 超过 10 页 | **两步创建**:先 `slides +create` 创建空白 PPT,再用 [`+add-slide`](lark-slides-add-slide.md) 逐页添加 |
| 已有 PPT 继续追加或插入页面 | 使用 [`+add-slide`](lark-slides-add-slide.md),必要时配合 `--before-slide-id` |

> [!IMPORTANT]
> `slides +create` 带页面时底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。

**CRITICAL — 提交前必须先跑版式 lint**:把待提交的 `<slide>` XML 存成本地文件,运行 [`scripts/xml_lint.py`](../../scripts/xml_lint.py),`summary.error_count` 必须为 0。

## 命令

```bash
# 创建空白 PPT
lark-cli slides +create --title "项目汇报"

# 创建 PPT + 添加页面:每页一个 XML 文件,重复 --slide,顺序即页序
lark-cli slides +create --as user --title "项目汇报" \
  --slide @.lark-slides/plan/project/slide-01.xml \
  --slide @.lark-slides/plan/project/slide-02.xml

# 已有组装好的 JSON 数组:从文件或 stdin 读
lark-cli slides +create --as user --title "项目汇报" --slides @./deck.json
cat deck.json | lark-cli slides +create --as user --title "项目汇报" --slides -

# 以应用身份创建(自动授权当前用户)
lark-cli slides +create --title "项目汇报" --as bot

# 预览(不执行)
lark-cli slides +create --title "项目汇报" --slide @./slide-01.xml --dry-run
```

## 返回值

工具成功执行后,返回一个 JSON 对象,包含以下字段:

- **`xml_presentation_id`**(string):演示文稿的唯一标识符,后续添加页面时需要此 ID
- **`title`**(string):演示文稿标题
- **`url`**(string,可选):演示文稿的在线链接,如有返回则务必展示给用户(需要 drive 相关权限;若获取失败则不返回此字段)
- **`revision_id`**(integer):演示文稿版本号
- **`slide_ids`**(string[],可选):带页面创建时返回,成功添加的页面 ID 列表
- **`slides_added`**(integer,可选):带页面创建时返回,成功添加的页面数量
- **`images_uploaded`**(integer,可选):页面 XML 中含 `@<本地路径>` 占位符时返回,已上传的去重后图片数量
- **`permission_grant`**(object,可选):仅 `--as bot` 时返回,说明是否已自动为当前 CLI 用户授予可管理权限

> [!IMPORTANT]
> 不带页面参数时,`slides +create` 只创建空白演示文稿。创建后用 [`+add-slide`](lark-slides-add-slide.md) 逐页添加 slide 内容。
>
> 带了页面时,CLI 先创建空白演示文稿,再逐页调用 slide 创建接口添加页面。如果某一页添加失败,CLI 会停止并报错,已创建的演示文稿和已添加的页面会保留。
>
> 如果演示文稿是**以应用身份(bot)创建**的,如 `lark-cli slides +create --as bot`,CLI 会**尝试为当前 CLI 用户自动授予该演示文稿的 `full_access`(可管理权限)**。
>
> 以应用身份创建时,结果里会额外返回 `permission_grant` 字段,明确说明授权结果:
> - `status = granted`:当前 CLI 用户已获得该演示文稿的可管理权限
> - `status = skipped`:本地没有可用的当前用户 `open_id`,因此不会自动授权
> - `status = failed`:演示文稿已创建成功,但自动授权用户失败
>
> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。

## 参数

| 参数 | 必填 | 说明 |
|------|------|------|
| `--title` | 否 | 演示文稿标题(不传则默认 "Untitled") |
| `--slide` | 否 | 一页 `<slide>` XML,或 `@路径`;可重复,最多 10 次。格式见[页面输入形式](#页面输入形式) |
| `--slides` | 否 | 页面 XML 的 JSON 字符串数组,最多 10 个;支持 `@文件` 和 `-`(stdin)。格式见[页面输入形式](#页面输入形式) |

10 页是 CLI 的上限,服务端每次只接收一页。超过 10 页时先用 `+create` 创建空白 PPT,再用 [`+add-slide`](lark-slides-add-slide.md) 逐页添加。

两种形式的每一页都会在发请求前校验成「单个完整的 `<slide>` 文档」。不合格的页在创建演示文稿之前报错并指出页序号,不会留下空壳演示文稿。

## 页面输入形式

页面内容有 `--slide` 和 `--slides` 两种传法,二选一,同时传会报错。

两种形式的 `@路径` 都必须是 CWD 内的相对路径(如 `./slide-01.xml`);绝对路径和 `../` 会被拒(报 `invalid file path`)。XML 写在别的目录时,先 `cd` 过去或把文件拷进 CWD 再执行。

### `--slide`:一页一个文件

可重复,重复次数即页数,出现顺序即页序。值是一页完整的 `<slide>` XML,或读取该 XML 的 `@路径`。

文件内容就是这一页 XML 本身,外面没有引号或方括号:

```xml
<slide xmlns="https://www.larkoffice.com/sml/2.0">
  <data>…第1页…</data>
</slide>
```

文件内容不需要转义:引号、换行、中文原样写。

### `--slides`:一个 JSON 数组

值是 JSON 字符串数组,每个元素是一整页 XML,支持 `@文件` 和 `-`(stdin)。

文件内容是一个 JSON 文档,XML 以 JSON 字符串出现,其中的 `"` 写作 `\"`,换行写作 `\n`:

```json
[
  "<slide xmlns=\"https://www.larkoffice.com/sml/2.0\"><data>…第1页…</data></slide>",
  "<slide xmlns=\"https://www.larkoffice.com/sml/2.0\"><data>…第2页…</data></slide>"
]
```

数组元素是页面 XML 原文。包装成 API 所需的 `{"slide": {"content": …}}` 并逐页调用由 CLI 完成。

> [!WARNING]
> `--slides '[...]'` 的风险点主要在 shell 参数传递,而不是单纯页数。即使只有 1 页,只要 XML 足够复杂,也建议改用 `--slide @page-01.xml` 逐页传文件。

## 本地图片:`@<path>` 占位符

`<img>` 元素的 `src` 属性如果以 `@` 开头,CLI 会把它当作本地文件路径,自动上传到当前演示文稿,并把占位符替换为返回的 `file_token`。

`slide-01.xml`:

```xml
<slide xmlns="https://www.larkoffice.com/sml/2.0">
  <data>
    <img src="@./assets/chart.png" topLeftX="100" topLeftY="100" width="320" height="180"/>
  </data>
</slide>
```

```bash
lark-cli slides +create --as user --title "图测试" --slide @./slide-01.xml
```

行为:

- 路径相对于**当前工作目录**(CWD)解析;**必须是 CWD 内的相对路径**(如 `./pic.png`、`./assets/x.png`)
- 同一份图被多次引用时**只上传一次**(按路径去重)
- `src` 不以 `@` 开头的会原样保留,但**只允许写 `slides +media-upload` 拿到的 `file_token`**;**禁止写 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 通常显示破图。要用网图必须先下载到 CWD 内、再走上传流程
- 单张图片最大 20 MB(slides upload API 不支持分片上传)
- 校验阶段就会检查所有占位符文件存在及大小;缺文件或超限直接报错,不会创建空白 PPT 占位
- 创空白 PPT → 上传所有图 → 替换 token → 逐页创建 slide,按这个顺序执行

> [!IMPORTANT]
> **路径必须在 CWD 内**:`@/abs/path/x.png` 或 `@../up/x.png` 这种会被 CLI 拒绝(报 `unsafe file path`)。如果素材在别的目录,先 `cd` 过去再执行。

## 创建后续步骤

创建空白 PPT 时,`slides +create` 返回的 `xml_presentation_id` 用于后续操作:

```bash
# 第 1 步:创建空白 PPT
PRES_ID=$(lark-cli slides +create --title "项目汇报" --jq '.data.xml_presentation_id')

# 第 2 步:逐页添加(--slide 支持 @file,复杂 XML 优先走文件)
lark-cli slides +add-slide --as user \
  --presentation "$PRES_ID" \
  --slide @.lark-slides/plan/<deck>/page1.xml
```

## 常见错误

| 错误码 | 含义 | 解决方案 |
|--------|------|----------|
| 400 | 参数错误 | 检查参数格式是否正确 |
| 403 | 权限不足 | 检查是否拥有 `slides:presentation:create` 和 `slides:presentation:write_only` scope |

## 相关命令

- [slides +add-slide](lark-slides-add-slide.md) — 追加/插入单页(两步创建的第二步)
- [slides +xml-get](lark-slides-xml-presentations-get.md) — 读取 PPT 内容并保存到本地文件
references/cli/lark-slides-delete-slide.md
# slides +delete-slide(按 slide_id 删除单页)

从演示文稿删除**一页**,按 `slide_id` 指定。只改一页里的局部内容用 [`+replace-slide`](lark-slides-replace-slide.md),不要删了重建。

`--presentation` 接受 token / `/slides/` URL / `/wiki/` URL,ID 是普通 flag 而不是 `--params` JSON 串。

> `--slide-id` 只接受单个 ID —— 不支持逗号分隔的列表(`+screenshot` 的 `--slide-id` 支持,这个不支持),也不支持按页号删。

## 命令

```bash
# 直接传 xml_presentation_id
lark-cli slides +delete-slide --as user \
  --presentation "$PRES_ID" \
  --slide-id "$SID"

# slides URL / wiki URL 都可以(wiki 会自动解析并校验 obj_type=slides)
lark-cli slides +delete-slide --as user \
  --presentation "https://xxx.feishu.cn/wiki/wikcnXXXXXX" \
  --slide-id "$SID"

# 删之前先确认打到哪份 PPT、哪一页
lark-cli slides +delete-slide --presentation "$PRES_ID" --slide-id "$SID" --dry-run
```

## 参数

| 参数 | 必需 | 说明 |
|------|------|------|
| `--presentation` | 是 | `xml_presentation_id`、`/slides/` URL 或 `/wiki/` URL |
| `--slide-id` | 是 | 要删除的页面 ID |
| `--revision-id` | 否 | 演示文稿版本号,默认 `-1`(最新);传具体版本号做乐观锁 |
| `--dry-run` | 否 | 打印将要发起的请求,不删除 |

## 成功输出

```json
{
  "xml_presentation_id": "slides_example_presentation_id",
  "slide_id": "slide_example_id",
  "deleted": true,
  "revision_id": 43
}
```

## 怎么拿 `slide_id`

`slide_id` 是服务端短 ID,**不能从 XML 里推导**。两个来源:

1. `+create` / `+add-slide` 的返回值里存下来;
2. 事后回读:`slides +xml-get --presentation "$PRES_ID" --output .lark-slides/plan/<deck>/readback.xml`。

删错页的代价高于多跑一次回读 —— 不确定就先回读 + `+screenshot` 看一眼再删。

## 删错了怎么办

删除在原地不可撤销,但可以走历史版本回滚:`+history-list` 找 `history_version_id` → `+history-revert`(只接受 `history_version_id`,不能传 `revision_id`)→ `+history-revert-status` 轮询。命令用法见 [lark-slides-history.md](lark-slides-history.md)。

## 常见错误

| 现象 | 原因 | 解决 |
|------|------|------|
| `--slide-id cannot be empty` | 传了空串或纯空格 | 检查变量有没有取到值 |
| 3350001 `invalid param` | `slide_id` 写错或该页已被删 | `+xml-get` 回读确认 `slide_id` 还在 |
| 403 / 权限不足 | 当前身份对这份 PPT 没有编辑权限 | 检查是否拥有 `slides:presentation:update` 或 `slides:presentation:write_only` scope;wiki 链接另需 `wiki:node:read`;`--as bot` 还要求该 bot 对目标 PPT 有编辑权限 |
references/cli/lark-slides-history.md
# slides history(历史版本与回滚)

用于查看 Slides XML presentation 历史版本、按 `history_version_id` 回滚,以及查询回滚任务状态。

`entries[].edit_time` 是 UTC RFC3339 时间字符串(例如 `2026-06-22T12:24:45Z`)。按时间匹配时先将其解析为时间值,再比较先后关系或时间差。

## 安全流程

1. 先用分页接口 `+history-list` 找到目标版本的 `history_version_id`。
2. 如果用户指定的是 `revision_id`,不要假设它唯一,也不要把 `revision_id` 直接传给 `+history-revert`。先拉一页并在 `entries[]` 中筛选 `revision_id` 相同的候选;如果未匹配到且 `has_more=true`,继续用 `page_token` 翻页;如果已匹配到候选,最多额外再拉一页补齐可能跨页的相邻候选。最终优先根据用户目标时间与 `edit_time` 的接近程度选择最合适的一条,取同一条的 `history_version_id`;如果没有目标时间,或多个候选无法可靠区分,再向用户展示候选版本(`history_version_id`、`revision_id`、`edit_time`、`name/description`)并确认后回滚。
3. 如果用户指定的是某一时刻但没有指定 `revision_id`,按 `entries[].edit_time` 匹配;优先选择不晚于目标时刻的最近一条历史记录,无法明确匹配时先向用户确认候选版本。
4. 使用 `+history-revert` 发起回滚。接口会立即返回 `task_id`,回滚任务在服务端异步执行。
5. 如果返回 `status: running`,保存 `task_id`,按照返回的 `poll_after_ms` 等待后调用 `+history-revert-status`。任务创建成功后,不得因为状态查询失败而重新发起回滚。
6. 状态变为 `done`、`partial_failed` 或 `failed` 后停止轮询;达到整体轮询上限时也停止轮询,并向用户返回 `task_id` 和当前状态。
7. 回滚完成后,用 `slides +xml-get` 或 `slides xml_presentations get` 读取演示文稿确认内容。

## 按 revision_id 或时间点回滚

当用户说“回滚到 revision_id=42”“恢复到昨天下午 3 点的版本”这类需求时,流程是:

1. 执行 `slides +history-list --presentation <presentation>` 获取第一页历史记录;`+history-list` 是分页接口,只有 `has_more=true` 且还需要更多候选时才继续传 `--page-token` 翻页。
2. 如果用户给出 `revision_id`:先筛选当前页中 `entries[].revision_id == 用户给出的 revision_id`。如果未命中且 `has_more=true`,继续拉下一页;如果已经命中候选,最多额外再拉一页,补齐同一个 `revision_id` 可能跨页出现的相邻 `history_version_id`。若用户同时给出目标时间,在候选里选择 `edit_time` 与目标时间最接近的一条;若未给目标时间但候选只有一条,可直接使用;若多个候选无法可靠区分,不要自行取第一条,向用户展示候选并确认。
3. 如果用户只给出时间:用 `entries[].edit_time` 匹配,选择目标时刻之前最近的一条;如果用户表达的是“最接近某时刻”,则选择绝对时间差最小的一条。
4. 从最终匹配条目读取 `history_version_id`。`history_version_id` 对应服务端 `minor_history.version`,这是回滚接口需要的 ID。
5. 执行 `slides +history-revert --presentation <presentation> --history-version-id <history_version_id>`。

候选确认时使用类似格式:

```text
同一个 revision_id 命中多个历史版本,请确认要回滚哪一条:
- history_version_id=11 revision_id=42 edit_time=2026-06-22T12:24:45Z name=...
- history_version_id=12 revision_id=42 edit_time=2026-06-22T12:25:14Z name=...
```

## 命令

```bash
# 列出历史版本
lark-cli slides +history-list --presentation "<slides_url_or_token>" --page-size 20

# 翻页
lark-cli slides +history-list --presentation "<slides_url_or_token>" --page-size 20 --page-token "<page_token>"

# 发起回滚任务,立即返回 task_id
lark-cli slides +history-revert --presentation "<slides_url_or_token>" --history-version-id 42

# 查询回滚任务状态
lark-cli slides +history-revert-status --presentation "<slides_url_or_token>" --task-id "<task_id>"
```

## 参数

| 命令 | 参数 | 必填 | 说明 |
|-|-|-|-|
| `+history-list` | `--presentation` | 是 | `xml_presentation_id`、Slides URL,或可解析为 Slides 的 wiki URL |
| `+history-list` | `--page-size` | 否 | 返回条数,范围 `1-20`,默认 `20` |
| `+history-list` | `--page-token` | 否 | 上一页返回的 `page_token` |
| `+history-revert` | `--presentation` | 是 | 同一个演示文稿 |
| `+history-revert` | `--history-version-id` | 是 | `+history-list` 返回的 `history_version_id`,必须大于 0 |
| `+history-revert-status` | `--presentation` | 是 | 同一个演示文稿 |
| `+history-revert-status` | `--task-id` | 是 | `+history-revert` 返回的 `task_id` |

## 异步轮询策略

1. `+history-revert` 返回 `task_id` 后,认为回滚任务已经成功创建。
2. 如果 `status` 不是 `running`,不再调用状态接口。
3. 如果 `status` 是 `running`,等待响应中的 `poll_after_ms` 后调用 `+history-revert-status`;`poll_after_ms` 缺失、为 `0` 或非法时,默认等待 10 秒。
4. 状态查询返回 `running` 时继续轮询;返回 `done`、`partial_failed` 或 `failed` 时停止。
5. 除非用户另有要求,默认最多轮询 5 分钟。达到上限后停止轮询,向用户说明任务仍在运行并返回 `task_id`,不得将其描述为回滚失败。
6. 状态查询出现临时错误时,按相同间隔最多连续重试 3 次;只重试 `+history-revert-status`,不得重新调用 `+history-revert`。
7. `done` 后读取当前演示文稿内容进行验证。
8. `partial_failed` 或 `failed` 时展示 `failed_block_tokens`;除非用户明确确认,不得自动再次发起回滚。

## 返回值要点

`+history-list` 返回:

```json
{
  "entries": [
    {
      "revision_id": 42,
      "history_version_id": "11",
      "edit_time": "2026-06-22T12:24:45Z",
      "type": 1,
      "name": "版本名",
      "description": "版本说明",
      "editor_ids": ["ou_xxx"]
    }
  ],
  "has_more": true,
  "page_token": "page_token"
}
```

`+history-revert` 返回:

```json
{
  "task_id": "task_xxx",
  "status": "running",
  "history_version_id": "11",
  "poll_after_ms": 10000
}
```

`+history-revert-status` 返回:

```json
{
  "status": "partial_failed",
  "history_version_id": "11",
  "failed_block_tokens": ["blk_xxx"]
}
```

`status` 可能是 `running`、`done`、`partial_failed`、`failed`。当状态是 `partial_failed` 或 `failed` 时,优先检查 `failed_block_tokens`。

## 回滚后验证

回滚成功后必须读取一次当前内容确认:

```bash
lark-cli slides +xml-get --presentation "<slides_url_or_token>" --output ./presentation.xml
```

如果只需要快速检查返回结构,也可以走 raw OpenAPI:

```bash
lark-cli api get "/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>" \
  --params '{"revision_id":-1}'
```
references/cli/lark-slides-media-upload.md
# slides +media-upload(上传本地图片到飞书幻灯片)

把本地图片上传到指定演示文稿的 drive 媒体库,返回 `file_token`。**返回的 token 作为 `<img src="...">` 的值塞进 slide XML 即可显示图片。**

## 命令

```bash
# 直接传 xml_presentation_id
lark-cli slides +media-upload --as user \
  --file ./pic.png \
  --presentation slidesXXXXXXXXXXXXXXXXXXXXXX

# 传 slides URL 也行
lark-cli slides +media-upload --as user \
  --file ./chart.png \
  --presentation "https://xxx.feishu.cn/slides/slidesXXXXXXXXXXXXXXXXXXXXXX"

# 传 wiki URL(CLI 自动 wiki.spaces.get_node 解析为真实 token,校验 obj_type=slides)
lark-cli slides +media-upload --as user \
  --file ./pic.png \
  --presentation "https://xxx.feishu.cn/wiki/wikcnXXXXXX"

# 预览(不实际上传)
lark-cli slides +media-upload --file ./pic.png --presentation $PRES_ID --dry-run
```

## 返回值

```json
{
  "file_token": "boxcnXXXXXXXXXXXXXXXXXXXXXX",
  "file_name": "pic.png",
  "size": 12345,
  "presentation_id": "slidesXXXXXXXXXXXXXXXXXXXXXX"
}
```

- **`file_token`**:把它写进 `<img src="...">`
- **`file_name` / `size`**:上传文件元信息
- **`presentation_id`**:解析后的真实 `xml_presentation_id`(wiki URL 解析后会变化)

## 参数

| 参数 | 必填 | 说明 |
|------|------|------|
| `--file` | 是 | 本地图片路径,**必须是 CWD 内的相对路径**(如 `./pic.png`)。**最大 20 MB**(slides upload API 不支持分片上传)。**仅支持 png / jpeg / gif / bmp / tiff / webp** |
| `--presentation` | 是 | `xml_presentation_id`、`/slides/<token>` URL,或 `/wiki/<token>` URL |

> [!IMPORTANT]
> **路径必须在 CWD 内**:`--file /abs/path/x.png` 或 `--file ../up/x.png` 会被 CLI 拒绝(报 `unsafe file path`)。如果素材在别的目录,先 `cd` 过去再执行。

## 使用流程

> 新建 PPT([`+create --slides`](lark-slides-create.md))或给已有 PPT 加新页([`+add-slide`](lark-slides-add-slide.md))都不需要单独上传:XML 里把 `<img src>` 写成 `@<本地路径>`,CLI 会自动上传并替换成 `file_token`。
> 本命令用于往**已有页**里加图,或需要自己拿着 `file_token` 拼 XML 的场景。

### 给已有 PPT 的已有页加图

拿到 `file_token` 后走 [`+replace-slide`](lark-slides-replace-slide.md) 的 `block_insert`,不用搬原 XML、不改 `slide_id`、不打乱页序:

```bash
PRES_ID=xxx
SID=yyy       # 要加图的那一页

# 1) 上传图片拿 file_token
TOKEN=$(lark-cli slides +media-upload --as user \
  --file ./pic.png --presentation $PRES_ID --jq '.data.file_token')

# 2) block_insert 到页末(或用 insert_before_block_id 指定插入位置)
lark-cli slides +replace-slide --as user \
  --presentation "$PRES_ID" --slide-id "$SID" \
  --parts "$(jq -n --arg token "$TOKEN" \
    '[{action:"block_insert",insertion:("<img src=\""+$token+"\" topLeftX=\"500\" topLeftY=\"100\" width=\"200\" height=\"150\"/>")}]')"
```

注意事项:

1. **`<img>` 坐标避开现有元素** —— 先读现有元素 bbox 挑空白区;空间不够就先用 `block_replace` 挪动/缩小现有元素后再放图
2. **`<img>` 的 `width:height` 对齐原图比例** —— 比例不一致会被裁剪,参见 [xml-schema-quick-ref.md](../xml/xml-schema-quick-ref.md) `<img>` 说明

## 工作原理

`+media-upload` 内部调用 `POST /open-apis/drive/v1/medias/upload_all`(单次上传,最大 20 MB),固定使用:

- `parent_type=slide_file`(slides 后端唯一接受的取值)
- `parent_node=<xml_presentation_id>`

**不要尝试用 `slides_image`、`slide_image` 等 parent_type**——后端会返回 1061001 / 1061002 错误。这是 slides 的特殊约定。

## 常见错误

| 错误码 | 含义 | 解决方案 |
|--------|------|----------|
| 1061002 | params error / 不支持的 parent_type | 不要用原生 API 自己拼 parent_type;用 `+media-upload` 即可 |
| 1061004 | forbidden:当前身份对该演示文稿无编辑权限 | 确认当前身份(user 或 bot)对目标 PPT 有编辑权限。bot 模式常见原因:PPT 不是该 bot 创建的——可用 `+create --as bot` 新建,或以 user 身份给 bot 授权 `lark-cli drive permission.members create --as user ...` |
| 1061044 | parent node not exist | `--presentation` 给的 token 不对,或不是 slides 类型 |
| 403 | 权限不足 | 检查 `docs:document.media:upload` scope;wiki URL 还需要 `wiki:node:read` |

## 相关命令

- [+create](lark-slides-create.md) — 新建 PPT(支持 `@` 占位符自动上传图片)
- [+replace-slide](lark-slides-replace-slide.md) — 给已有页加图 / 换图(`block_insert` / `block_replace`)
- [+add-slide](lark-slides-add-slide.md) — 追加/插入单页(同样支持 `@` 占位符自动上传)
references/cli/lark-slides-replace-slide.md
# slides +replace-slide(块级替换 / 插入)

对指定 slide 做块级替换或插入。编辑已有 PPT 的主路径——`slide_id` 不变、页序不动、只影响被指定的块。

> **编写 `--parts` 时只使用标准 action 和字段**:`block_replace` 使用 `block_id` + `replacement`,`block_insert` 使用 `insertion`(可选 `insert_before_block_id`)。不要根据其他 API 或自然语言猜 action、字段名;具体结构以本文表格为准。

相比直接调 `xml_presentation.slide.replace`,这个 shortcut 的四个额外价值:

1. `--presentation` 接受 `xml_presentation_id` / `/slides/` URL / `/wiki/` URL(wiki 自动解析);
2. `block_replace` 的 `replacement` 根元素 `id="<block_id>"` 由 CLI 自动注入——底层 API 的硬约束(不注入返回 3350001);直接调原生 API 需自己加,用 Shortcut 则自动注入;
3. `<shape>` 元素缺少 `<content/>` 子元素时由 CLI 自动注入——SML 2.0 schema 要求每个 `<shape>` 必须有 `<content/>` 子元素,缺失同样触发 3350001;自闭合的 `<shape .../>` 也会被自动展开为 `<shape ...><content/></shape>`;
4. 3350001 错误时提供上下文感知的 hint,帮助 AI agent 和用户快速定位原因。

## 命令

```bash
# block_insert:在页末追加一个新元素
lark-cli slides +replace-slide --as user \
  --presentation slidesXXXXXXXXXXXXXXXXXXXXXX \
  --slide-id pfG \
  --parts '[{"action":"block_insert","insertion":"<shape type=\"rect\" topLeftX=\"500\" topLeftY=\"100\" width=\"200\" height=\"100\"/>"}]'

# block_replace:已知某块 id,整块替换(replacement 根 id 自动注入为 bUn)
lark-cli slides +replace-slide --as user \
  --presentation slidesXXXXXXXXXXXXXXXXXXXXXX \
  --slide-id pfG \
  --parts '[{"action":"block_replace","block_id":"bUn","replacement":"<shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"120\"><content textType=\"title\"><p>新标题</p></content></shape>"}]'

# 大 --parts 走文件或 stdin(auto-gen 命令不支持 @file,但 shortcut 支持)
lark-cli slides +replace-slide --as user \
  --presentation $PRES_ID --slide-id $SID --parts @parts.json
cat parts.json | lark-cli slides +replace-slide --as user \
  --presentation $PRES_ID --slide-id $SID --parts -

# wiki URL 直接传(CLI 自动 get_node → 拿真实 xml_presentation_id)
lark-cli slides +replace-slide --as user \
  --presentation "https://xxx.feishu.cn/wiki/wikcnXXXXXX" --slide-id pfG \
  --parts '[{"action":"block_insert","insertion":"<shape type=\"rect\" width=\"100\" height=\"100\"/>"}]'

# 预览(不实际调用)
lark-cli slides +replace-slide --as user \
  --presentation $PRES_ID --slide-id $SID --parts "$PARTS" --dry-run
```

## 参数

| 参数 | 必填 | 说明 |
|------|------|------|
| `--presentation` | 是 | `xml_presentation_id`、`/slides/<token>` URL,或 `/wiki/<token>` URL |
| `--slide-id` | 是 | 页面 ID(`xml_presentation.slide.get` / `slides +xml-get` 都能拿到) |
| `--parts` | 是 | JSON 数组(`[{...}, ...]`),单次最多 200 条。支持 `@<file>` 和 `-`(stdin)读取 |
| `--revision-id` | 否 | 基础版本号;默认 `-1` 表示基于最新版执行;传具体版本号时,服务端以该版本为 base 执行;**传不存在的版本号(超过当前 revision)返回 3350002** |
| `--tid` | 否 | 并发事务 ID;多人协作长事务才用,单次单人调用留空 |

## parts 元素结构

> **限制**:最多 200 条;`block_replace` 和 `block_insert` 可以在同一批次混用。**其他 action(含 `str_replace`)CLI 会直接报错拒绝**。

每条 part 按 `action` 取不同字段:

### action = `block_replace`

| 字段 | 必填 | 说明 |
|------|------|------|
| `action` | 是 | `"block_replace"` |
| `block_id` | 是 | 目标块的 3 位 short element ID(从 `slide.get` 返回 XML 里读) |
| `replacement` | 是 | 新 XML 片段;**根元素 `id` 会被 CLI 自动注入为 `block_id`**,用户不用自己加(如果已经加了且不一致会被覆盖为正确值) |

### action = `block_insert`

| 字段 | 必填 | 说明 |
|------|------|------|
| `action` | 是 | `"block_insert"` |
| `insertion` | 是 | 要插入的 XML 片段 |
| `insert_before_block_id` | 否 | 插到这个块之前;省略(不提供此字段)则追加到页末 |

### 错误字段名(CLI 直接拒绝)

编写 part 时只使用上表中的标准字段。CLI 返回 unknown field 时会点名写错的字段,并按情况给出下一步:能对上正确字段时直接建议它(`did you mean \"replacement\"?`),字段属于另一个 action 时说明归属(`it belongs to block_insert`),都对不上时列出该 action 的合法字段集。无论哪种,**要改的是字段名,不是字段值**。

```jsonc
// ❌ 全部被拒
[{"action":"block_replace","block_id":"bUn","xml":"<shape.../>"}]           // unknown field "xml"; did you mean "replacement"?
[{"action":"block_replace","block_id":"bUn","data":"<shape.../>"}]          // data 不是标准字段
[{"action":"block_replace","block_id":"bUn","insertion":"<shape/>"}]        // insertion 属于 block_insert
[{"action":"block_replace","block_id":"bUn","replacement":{"type":"..."}}]  // replacement 必须是字符串,报 .replacement must be a string

// ✅ 正确
[{"action":"block_replace","block_id":"bUn","replacement":"<shape type=\"text\"><content><p>新内容</p></content></shape>"}]
[{"action":"block_insert","insertion":"<shape type=\"rect\" width=\"100\" height=\"100\"/>"}]
```

## 合法根元素速查

`block_replace.replacement` 和 `block_insert.insertion` 必须以 SML 2.0 定义的合法元素为根。完整权威定义看 [`slides_xml_schema_definition.xml`](../xml/slides_xml_schema_definition.xml);这里只列能作为**根**的类型 + 每种类型的最小可工作片段。

| 元素 | 用途 | 关键点 |
|---|---|---|
| `<shape>` | 矩形/椭圆/三角/文本框等所有形状 | `type` 必填;`<content/>` 缺失时 CLI 会自动注入 |
| `<line>` | 直线 | 需 `startX/startY/endX/endY` |
| `<polyline>` | 折线 | `points` 读回时被服务端规整丢弃(几何已入库) |
| `<img>` | 图片 | `src` 必须是 [`+media-upload`](lark-slides-media-upload.md) 返回的 `file_token`,不能是 URL |
| `<icon>` | 图标 | `iconType` 取自 iconpark 资源;语义图标先用 `scripts/iconpark_tool.py search` 检索 |
| `<table>` | 表格 | 整表替换会**重建内部 td id**,旧 td block_id 立即失效 |
| `<td>` | 单元格局部替换 | 只能 `block_replace`,不能 `block_insert`;`block_id` 必须是最新 `slide.get` 拿到的 td id |
| `<chart>` | 图表(line/bar/column/pie/area/radar/combo) | 必须嵌 `<chartPlotArea>` + `<chartData>` + `<dim1>/<dim2>/<chartField>` |

**不可作为根元素**:

- `<video>` / `<audio>` —— SML 2.0 没有这两个原生元素;`<undefined type="video|audio">` 是**导出时**的占位符(服务端遇到不支持的类型时用它代替),**不能写入**。尝试 insert/replace 都会返回 3350001。

### 最小 XML 片段(JSON 嵌入时记得把 `"` 转义成 `\"`)

`<shape>`(文本框;`type` 还可选 `rect`/`ellipse`/`triangle`/`custom` 等):
```xml
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
  <content textType="title"><p>标题</p></content>
</shape>
```

`<img>`:
```xml
<img src="{file_token}" topLeftX="600" topLeftY="20" width="80" height="80"/>
```

`<polyline>`:
```xml
<polyline topLeftX="10" topLeftY="10" width="100" height="50" points="0,0 50,50 100,0"/>
```

`<table>`(2×2):
```xml
<table topLeftX="30" topLeftY="80">
  <colgroup><col span="2" width="110"/></colgroup>
  <tr><td><content><p>A</p></content></td><td><content><p>B</p></content></td></tr>
  <tr><td><content><p>C</p></content></td><td><content><p>D</p></content></td></tr>
</table>
```

`<td>`(`block_replace` 单元格;`block_id` 必须是最新 `slide.get` 拿到的 td id):
```xml
<td><content><p>新内容</p></content></td>
```

`<chart>`(`type` 改成 `bar`/`column`/`pie`/`area`/`radar`/`combo` 切换图型):
```xml
<chart topLeftX="30" topLeftY="300" width="300" height="200">
  <chartPlotArea><chartPlot type="line"/></chartPlotArea>
  <chartData>
    <dim1><chartField name="x" valueType="string">Q1,Q2,Q3,Q4</chartField></dim1>
    <dim2><chartField name="Sales" valueType="number">10,20,15,30</chartField></dim2>
  </chartData>
</chart>
```

## 返回值

```json
{
  "xml_presentation_id": "slidesXXXXXXXXXXXXXXXXXXXXXX",
  "slide_id": "pfG",
  "parts_count": 1,
  "revision_id": 102
}
```

| 字段 | 说明 |
|------|------|
| `xml_presentation_id` | 解析后的真实 token(wiki URL 解析后会变化) |
| `slide_id` | 与入参一致 |
| `parts_count` | 本次提交的 parts 条数 |
| `revision_id` | 成功后的新版本号,下次做乐观锁时用 |
| `failed_part_index` | 有部分失败时存在,指向第几条 part 失败 |
| `failed_reason` | 失败原因文字描述 |

整批作为原子事务:任一 part 失败则整批不生效,服务端通过 `failed_part_index` / `failed_reason` 告诉你是哪条;按此定位修正后重发。

## 使用流程

### 给已有页加图(典型场景)

```bash
PRES_ID=xxx
SID=yyy

# 1) 上传图片
TOKEN=$(lark-cli slides +media-upload --as user \
  --file ./pic.png --presentation "$PRES_ID" --jq '.data.file_token')

# 2) block_insert 到页末
lark-cli slides +replace-slide --as user \
  --presentation "$PRES_ID" --slide-id "$SID" \
  --parts "$(jq -n --arg token "$TOKEN" \
    '[{action:"block_insert",insertion:("<img src=\""+$token+"\" topLeftX=\"500\" topLeftY=\"100\" width=\"200\" height=\"150\"/>")}]')"
```

### 改标题(block_replace)

```bash
# 先拿原页 XML,从里面找到标题块的 3 位 short id(如 bUn)
lark-cli slides xml_presentation.slide get --as user \
  --params "{\"xml_presentation_id\":\"$PRES_ID\",\"slide_id\":\"$SID\"}"

# block_replace 换掉整个标题块(id 自动注入)
lark-cli slides +replace-slide --as user \
  --presentation "$PRES_ID" --slide-id "$SID" \
  --parts '[{"action":"block_replace","block_id":"bUn","replacement":"<shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"120\"><content textType=\"title\"><p>新标题</p></content></shape>"}]'
```

### 批量:一次换标题 + 追加装饰图

`block_replace` 和 `block_insert` 可以在同一个 `--parts` 里混用,整批原子执行。

```bash
lark-cli slides +replace-slide --as user \
  --presentation "$PRES_ID" --slide-id "$SID" \
  --parts '[
    {"action":"block_replace","block_id":"bab","replacement":"<shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"120\"><content textType=\"title\"><p>新标题</p></content></shape>"},
    {"action":"block_insert","insertion":"<img src=\"<file_token>\" topLeftX=\"700\" topLeftY=\"400\" width=\"180\" height=\"100\"/>"}
  ]'
```

### 乐观锁

```bash
# 读时记录 revision_id
REV=$(lark-cli slides xml_presentation.slide get --as user \
  --params "{\"xml_presentation_id\":\"$PRES_ID\",\"slide_id\":\"$SID\"}" \
  --jq '.data.revision_id')

# 写时传 --revision-id;传不存在的版本号(超过当前 revision)返回 3350002
lark-cli slides +replace-slide --as user \
  --presentation "$PRES_ID" --slide-id "$SID" --revision-id "$REV" \
  --parts "$PARTS"
```

## 常见错误

| 现象 | 原因 | 对策 |
|------|------|------|
| 3350001 + hint "block_id not found" | `parts[i].block_id` 在当前页不存在 | 重新 `slide.get` 拿最新 XML,按里面的 short ID 再填 |
| 3350002 not found | `--revision-id` 传了不存在的版本号(超过当前 revision) | 用 `-1` 或用 `slide.get` 拿到的有效 `revision_id` |
| `--parts invalid JSON` | JSON 本身不完整,或被 shell 引号/转义破坏 | 将数组写入 `parts.json` 后传 `--parts @parts.json`,或通过 stdin 传给 `--parts -` |
| `--parts[i] action "str_replace" is not supported` | CLI 不暴露 `str_replace` | 把替换需求改写成 `block_replace` / `block_insert` |
| `--parts[i] action "page_replace" / "slide_replace" means whole-page replacement` | 把整页更新意图传给了块级 shortcut | 改用 [`slides +update-slide`](lark-slides-update-slide.md) 整页原地写回 |
| `--parts contains N items, exceeds maximum of 200` | 一次提交 parts 太多 | 拆多次调用 |
| `--parts[i] unknown field "xml"; did you mean "replacement"?` | XML 塞进了未支持的字段名(如 `xml` / `new_xml` / `data`) | 使用标准字段:`block_replace` 用 `replacement`,`block_insert` 用 `insertion` |
| `--parts[i] unknown field "insertion"; it belongs to block_insert` | 字段和 `action` 不配对 | 按 action 取字段:`block_replace` = `block_id` + `replacement`;`block_insert` = `insertion` (+ `insert_before_block_id`) |
| `--parts[i] (block_replace) requires non-empty block_id` / `replacement` | 字段名对,但值缺失或是空串 | 按 parts 元素结构补齐值 |
| `<img>` 不显示 / 显示破图 | `src` 写了外链 URL | 换成通过 [`+media-upload`](lark-slides-media-upload.md) 拿到的 `file_token` |
| 3350001 | `replacement` 不是合法单根 XML 片段,或 `block_id` 不存在 | CLI 已自动注入 `id` 和 `<content/>`;如果仍报错,重新 `slide.get` 拿最新 XML 确认 `block_id` 存在;检查 XML 结构是否合法;坐标是否超出 960×540 |
| 403 | 权限不足 | 需要 `slides:presentation:update` 或 `slides:presentation:write_only`;wiki URL 还需要 `wiki:node:read` |

## 相关命令

- [xml_presentation.slide get](lark-slides-xml-presentation-slide-get.md) — 读原页拿 `block_id` / `revision_id`
- [xml_presentation.slide replace](lark-slides-xml-presentation-slide-replace.md) — 底层 replace API 参考
- [+media-upload](lark-slides-media-upload.md) — 上传图片拿 `file_token`
- [slides-editing.md](../workflow/slides-editing.md) — 读-改-写闭环 + 决策树
references/cli/lark-slides-screenshot.md
# slides +screenshot

## 用途

获取幻灯片页面截图并保存为本地图片文件。默认用于已存在 PPT 页面截图;传入 `--content` 时用于直接渲染单个 `<slide>` XML 片段预览。本 shortcut 会在 CLI 进程内解码并写入文件,stdout 只返回文件路径、大小、页面 ID 等元信息,避免把图片 Base64 输出给模型。

截图失败则降级到 XML 读回、结构 lint等非截图检查路径。

## 命令

```bash
lark-cli slides +screenshot --as user \
  --presentation '<xml_presentation_id 或 slides/wiki URL>' \
  --slide-number 1
```

渲染本地 XML 内容:

```bash
lark-cli slides +screenshot --as user \
  --content @slide.xml
```

## 截图全部页面

枚举全部页面的 `slide_id` 或页码,按每批最多 10 页分组并串行调用 `slides +screenshot`,复用同一个 `--output-dir`;记录失败批次,已完成批次不重复执行。

## 参数

| 参数 | 必需 | 说明 |
|------|------|------|
| `--presentation` | list 模式必需 | `xml_presentation_id`、`/slides/` URL,或解析后为 slides 的 `/wiki/` URL。传 `--content` 时不能使用 |
| `--slide-id` | list 模式与 `--slide-number` 二选一 | 页面 short ID;不能与 `--slide-number` 同时使用;多页截图时重复传入,或用逗号分隔一次传多个(如 `--slide-id slide_1,slide_2`);一次最多 10 个 ID |
| `--slide-number` | list 模式与 `--slide-id` 二选一 | 页面页号;不能与 `--slide-id` 同时使用;多页截图时重复传入,或用逗号分隔一次传多个(如 `--slide-number 1,2,3`);一次最多 10 个页码 |
| `--content` | render 模式必需 | 要直接渲染的 `<slide>` XML 片段;支持直接传值、`@file`、`-` stdin。传入后不能同时传 `--slide-id` / `--slide-number` |
| `--output` | 否 | 单张截图的期望相对输出路径,可不写扩展名,显式扩展名只支持 `.png`、`.jpg`、`.jpeg`。只能选择一页,不能与 `--output-dir` / `--output-name` 同时使用;最终路径以返回的 `output` 为准 |
| `--output-dir` | 否 | 输出目录,默认 `.lark-slides/screenshots`;必须是当前目录内的相对路径 |
| `--output-name` | 否 | 仅用于 `--content` render 模式设置输出文件名 stem。普通页面截图传入该参数会返回 `validation/invalid_argument`(`param: --output-name`)并提示改用 `--output` |

## 示例

### 单页截图并固定路径

```bash
lark-cli slides +screenshot --as user \
  --presentation slides_example_presentation_id \
  --slide-number 1 \
  --output .lark-slides/screenshots/example-deck-task/page-01
```

按 `slide_id` 选择单页时同样使用 `--output`:

```bash
lark-cli slides +screenshot --as user \
  --presentation slides_example_presentation_id \
  --slide-id slide_example_id \
  --output .lark-slides/screenshots/example-deck-task/page-01
```

### 多页截图

一次不要超过 10 页;如需更多页面,分批调用。可以重复传参,也可以用逗号分隔一次传多个:

```bash
lark-cli slides +screenshot --as user \
  --presentation slides_example_presentation_id \
  --slide-number 1 \
  --slide-number 2 \
  --output-dir .lark-slides/screenshots/example-deck-task
```

### 渲染 XML 预览

```bash
lark-cli slides +screenshot --as user \
  --content @.lark-slides/out/demo/slide.xml \
  --output .lark-slides/screenshots/example-deck-task/preview
```

## 返回值

返回 JSON 不包含 Base64 图片内容:

```json
{
  "ok": true,
  "identity": "user",
  "data": {
    "xml_presentation_id": "slides_example_presentation_id",
    "output": "/abs/path/.lark-slides/screenshots/example-deck-task/page-01.jpg",
    "screenshots": [
      {
        "slide_id": "slide_example_id",
        "slide_number": 1,
        "format": "jpeg",
        "path": "/abs/path/.lark-slides/screenshots/example-deck-task/page-01.jpg",
        "size": 12345
      }
    ]
  }
}
```

## 注意事项

1. 优先使用 `slides +screenshot` 保存本地图片,不要把图片 Base64 打到 stdout。
2. 已存在 PPT 页面截图时,不传 `--content`,用 `--presentation` + `--slide-id` 或 `--slide-number`。
3. 本地 XML 预览时,传 `--content @file` 或 `--content -`,内容应为单个 `<slide>` XML 片段;此时不要传 `--presentation` / `--slide-id` / `--slide-number`。
4. `slide_id` 是页面 short ID,页码请用 `--slide-number`。
5. list 模式下 `--slide-id` 与 `--slide-number` 必须二选一;同一类型 selector 一次最多传 10 个,更多页面请分批截图。
6. 单张使用 `--output`,多张使用 `--output-dir`,由 CLI 按页面信息生成文件名。新建或大幅改写 Deck 时,截图目录复用 planning 阶段的 `<deck-or-task-id>`;已有 Deck 没有 task ID 时,使用 presentation ID 作为目录名。
7. CLI 不转换图片格式,也不要求模型预判服务端格式。未写扩展名时自动追加真实扩展名;请求扩展名与真实格式不一致时保留目录和名称、修正扩展名,例如请求 `slide3.png` 而服务端返回 JPEG 时实际保存为 `slide3.jpg`。
8. 发生扩展名修正或同名避让时会返回原始 `requested_output`、实际绝对路径 `output` 和 `output_adjusted: true`;后续必须使用 `output` / `screenshots[].path`,不要继续猜测请求路径。
9. list 模式默认文件名包含 presentation ID、页码和/或 slide ID。
10. 截图来自服务端渲染结果,适合创建/替换后验证页面是否为空白、破图或布局明显异常。
references/cli/lark-slides-update-slide.md
# slides +update-slide(整页更新已有页面)

把一整页 XML 交给某个已有页面,页面变成 `--content` 描述的样子。`slide_id` 和页序都不变。

## 命令

```bash
# 标准用法:整页 XML 从文件读(推荐:避免 shell 转义和长参数截断)
lark-cli slides +update-slide --as user \
  --presentation "https://xxx.larkoffice.com/slides/SCtZ...ynae" \
  --slide-id "piy" \
  --content @page.xml

# XML 从 stdin 读
cat page.xml | lark-cli slides +update-slide --as user \
  --presentation "$PRES" --slide-id "$SLIDE" --content -

# wiki 链接直接传(CLI 自动解析并校验 obj_type=slides)
lark-cli slides +update-slide --as user \
  --presentation "https://xxx.larkoffice.com/wiki/wikcn..." \
  --slide-id "piy" --content @page.xml

# 预览请求,不实际写入
lark-cli slides +update-slide --as user \
  --presentation "$PRES" --slide-id "$SLIDE" --content @page.xml --dry-run
```

## 参数

| 参数 | 必需 | 说明 |
|------|------|------|
| `--presentation` | 是 | `xml_presentation_id`、`/slides/` URL 或 `/wiki/` URL |
| `--slide-id` | 是 | 要整页替换的页面 `slide_id` |
| `--content` | 是 | 这一页的完整目标 XML,单一 `<slide>` 根;支持字面量、`@file`、stdin `-`。别名:`--xml` / `--slide-xml` / `--slide-content` / `--content-xml` |
| `--revision-id` | 否 | 默认 `-1`(最新)。它只选择服务端执行所基于的快照,不是“页面有新编辑就拒绝”的乐观锁;传旧版本号会以旧快照重建页面并丢弃其后的编辑 |
| `--tid` | 否 | 调用方提供的任务/事务标识,CLI 原样透传;用于关联同一编辑任务或重试,不等同于版本前置条件,不能单独保证并发冲突时拒绝写入。一般留空 |

`@file` 和 `+xml-get --output` 一样**只接受当前目录下的相对路径**,绝对路径会被拒。
命令别名:`slides +update`(隐藏);服务别名:`lark-cli slide …` 等价于 `lark-cli slides …`。

如果要求“从读取之后页面一旦变化就不再写入”,不能只传 `--revision-id` 或 `--tid`。写入前必须再次用 `+xml-get` 回读最新版,比较读取期间是否发生变化;有变化时先基于最新版重新合并本次修改,再执行整页写回。当前 shortcut 不提供严格的 compare-and-swap 保证。

## 语义:`--content` 就是这一页的最终状态

**没写进 `--content` 的东西会从页面上消失。** 这不是补丁,是整页覆盖。

| 你在 `--content` 里怎么写 | 页面上的结果 |
|---|---|
| 元素带原来的 `id` | 按新 XML 更新这个元素 |
| 元素不带 `id` | 作为新元素插入到它所在的位置 |
| 原来有、`--content` 里没有的元素 | **删除** |
| `<style>` 改了 | 背景等页面样式跟着改 |
| 没写 `<note>` | 讲者备注被清空 |

一次请求就能同时做完改样式、插入、删除、换备注、换背景——这是 `+replace-slide` 逐元素 part 做不到的(它没法寻址背景,也没有 move 操作)。

## 本地图片:`@路径` 占位符

`--content` 的 XML 里写 `<img src="@./chart.png" .../>`,CLI 会:先把每个不重复的本地文件上传到这份演示文稿(`parent_type=slide_file`),再把 `src` 替换成返回的 `file_token`,最后才整页写回。

占位符路径按**执行命令时的 CWD** 解析,跟 `--content @file` 所在目录无关;`@./assets/x.png` 找的是 `$PWD/assets/x.png`。

```bash
lark-cli slides +update-slide --as user \
  --presentation "$PRES" --slide-id "$SLIDE" \
  --content '<slide xmlns="https://www.larkoffice.com/sml/2.0"><data><img src="@./chart.png" topLeftX="100" topLeftY="100" width="320" height="180"/></data></slide>'
```

- 文件不存在、不是普通文件、超过 20 MB,都在**调用任何接口之前**报错,不会留下半成品。
- 去重只在**单次调用内**生效:多页共用同一张图时,逐页更新会把它每页重传一次。这种图先用 [`+media-upload`](lark-slides-media-upload.md) 传一次,把 `file_token` 写进各页的 `src`。
- 整页只发一个 part,所以上传是这条命令里**唯一不可逆的一半**:图先落进演示文稿的 media store,若随后 replace 失败,报错 hint 会告诉你已经传了几张,直接重试会再传一份。先 `--dry-run` 可提前看到 `images_to_upload` 和上传步骤。

## 标准读-改-写流程

```bash
# 1. 读回当前页(拿到带 id 的完整 XML)
lark-cli slides +xml-get --as user \
  --presentation "$PRES" --slide-id "$SLIDE" --output page.xml

# 2. 编辑 page.xml —— 保留想留下的元素的 id,删掉不要的整段,新元素不写 id

# 3. 整页写回
lark-cli slides +update-slide --as user \
  --presentation "$PRES" --slide-id "$SLIDE" --content @page.xml
```

先 `--dry-run` 看请求,确认无误再执行。

> ⚠️ **第 1 步不要加 `--remove-attr-id`。** 那个参数会把所有元素的 `id` 去掉,再交给 `+update-slide` 的话,每个元素都会被当成新元素插入、原来的全部被删除——页面看起来一样,但所有元素换了新 id,锚在旧 id 上的评论和 block 直达链接全部失效,而且**不会有任何报错**。`--remove-attr-id` 只用于只读查看。

## 命令校验与空页限制

| 情况 | 报错 |
|---|---|
| 根元素不是 `<slide>`(例如直接给了 `<shape>`) | `--content root must be <slide>` → 改单个元素请用 `+replace-slide` |
| 根 `id` 和 `--slide-id` 不一致 | 拒绝。这通常是 A 页的 XML 要写到 B 页 —— 会毁掉 B 页 |
| 根 `id` 缺失 | 自动补上 `--slide-id`,不报错 |
| 根标签带命名空间前缀(`<sml:slide>`) | 拒绝。页面 id 没法贴到带前缀的标签上;写成 `<slide>`,需要命名空间就用默认 `xmlns` |
| `<slide>` 之后还有第二个根元素或多余文本 | 拒绝。服务端解析会静默丢掉它们 |
| XML 不合法 | 拒绝,带上出错位置 |
| `<slide/>`(自闭合,空页) | 命令本身可以解析,但提交前的强制版式 lint 会报 `blank_slide`;按本 Skill 不得调用接口提交空页 |

标为“拒绝”的情况由命令校验拦截,**不会发出任何请求**;空页则必须在调用命令前由强制版式 lint 拦截。

## 什么时候不要用它

- **只改一个元素** → 用 [`+replace-slide`](lark-slides-replace-slide.md),一条 `block_replace` part 更省,也不用带上整页
- **要改多个页面** → 对每一页各跑一次本命令
- **要新建页面** → `slides +create` 或 `xml_presentation.slide create`

## 提交前与写入后验证

和其他整页写入一样,把 `--content` 存成本地文件后先跑版式 lint。先取得当前已加载 `lark-slides/SKILL.md` 的父目录,记为 `<lark-slides-skill-dir>`;不要猜测全局安装路径:

```bash
python3 "<lark-slides-skill-dir>/scripts/xml_lint.py" --input page.xml
```

`summary.error_count` 必须为 0 才调接口;`warning_count > 0` 时写完要截图复核。

写入成功后,必须回读整份演示文稿的最新 XML,而不是只相信写接口的成功响应:

```bash
lark-cli slides +xml-get --as user \
  --presentation "$PRES" --output readback.xml
```

按当前已加载 `lark-slides/SKILL.md` 指向的 [validation-xml.md](../workflow/validation-xml.md) 完成验证:核对总页数、目标页和关键元素(包括需要保留的 ID、文本、背景与备注),并对回读 XML 运行同一版式 lint;发现差异时先停止后续写入并重新基于最新版处理。

## 成功输出

```json
{
  "ok": true,
  "identity": "user",
  "data": {
    "xml_presentation_id": "slides_example_presentation_id",
    "slide_id": "piy",
    "revision_id": 43
  }
}
```

| `data` 下的字段 | 说明 |
|------|------|
| `xml_presentation_id` | 实际写入的演示文稿 ID |
| `slide_id` | 与传入相同——整页覆盖不换页 id |
| `revision_id` | 写入后的新版本号 |
| `images_uploaded` | 仅当 `--content` 带 `@` 占位符时出现:本次去重后实际上传的图片张数 |

服务端拒绝这次写入时(`failed_reason` 非空)**不会**返回成功输出,而是报错并带上原因——单个 part 承载整页,任何失败都意味着页面没被写入。

- 原因包含 `not found`:先检查 `--presentation` 和 `--slide-id`,再用 `slides +xml-get` 回读当前页面 ID。页面可能已删除,或 ID 来自另一份演示文稿。
- 其他 invalid-parameter 错误:检查 `--content` 中不支持的元素、缺少 `<content/>` 的 `<shape>`,以及超出 960×540 的坐标。

## 常见错误

| 现象 | 原因 | 解决 |
|------|------|------|
| 3350001,原因包含 `not found` | `--presentation` 不匹配,或 `--slide-id` 对应的页面已被删除 | 检查 `--presentation` 和 `--slide-id`,再用 `slides +xml-get` 回读当前页面 ID |
| 3350001,其他 invalid param | `--content` 的 XML 结构有问题(如 `<shape>` 缺 `<content/>`、包含服务端不支持的元素) | 按 [error-handling.md](../workflow/error-handling.md) 检查 `--content` 的 XML 结构 |
| 3350002 not found | `--revision-id` 传了不存在的版本号 | 用 `-1` 或真实存在的 `revision_id` |
| 1061004 / 403 | 当前身份对这份 PPT 没有编辑权限 | 检查是否拥有 `slides:presentation:update` 或 `slides:presentation:write_only` scope;wiki 链接另需 `wiki:node:read`,`@` 占位符另需 `docs:document.media:upload`;`--as bot` 还要求该 bot 对目标 PPT 有编辑权限 |
references/cli/lark-slides-xml-presentation-slide-get.md
# lark-slides xml_presentation.slide get

## 用途

按 `slide_id` 拉取指定演示文稿单页的 XML 内容(可指定历史版本)。常用于"读-改-写"编辑闭环的第一步。

## 命令

```bash
lark-cli slides xml_presentation.slide get --as user --params '<json_params>'
```

## 参数说明

| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `--params` | JSON string | 是 | 路径参数与查询参数 |

### params JSON 结构

```json
{
  "xml_presentation_id": "slides_example_presentation_id",
  "slide_id": "slide_example_id",
  "revision_id": -1
}
```

| 字段 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `xml_presentation_id` | string | 是 | 目标演示文稿唯一标识 |
| `slide_id` | string | 是 | 目标页面唯一标识 |
| `revision_id` | integer | 否 | 版本号,`-1` 表示最新版(默认)|

## 使用示例

### 读最新版本

```bash
lark-cli slides xml_presentation.slide get --as user --params '{
  "xml_presentation_id": "slides_example_presentation_id",
  "slide_id": "slide_example_id"
}'
```

### 只提取 XML 内容

```bash
lark-cli slides xml_presentation.slide get --as user \
  --params '{"xml_presentation_id":"slides_example_presentation_id","slide_id":"slide_example_id"}' \
  --jq '.data.slide.content'
```

### 读指定历史版本

```bash
lark-cli slides xml_presentation.slide get --as user --params '{
  "xml_presentation_id": "slides_example_presentation_id",
  "slide_id": "slide_example_id",
  "revision_id": 42
}'
```

## 返回值

```json
{
  "ok": true,
  "identity": "user",
  "data": {
    "slide": {
      "slide_id": "slide_example_id",
      "content": "<slide id=\"slide_example_id\"><style/><data>...</data></slide>"
    },
    "revision_id": 100
  }
}
```

| 字段 | 类型 | 说明 |
|------|------|------|
| `data.slide.slide_id` | string | 页面唯一标识 |
| `data.slide.content` | string | 页面完整 XML(`<slide>` 根节点,不含 xmlns)|
| `data.revision_id` | integer | 此次读到的版本号,可用于后续 replace 的乐观锁 |

## 常见错误

| 错误码 | 含义 | 解决方案 |
|--------|------|----------|
| 404 | 演示文稿或页面不存在 | 检查 `xml_presentation_id` / `slide_id` |
| 403 | 权限不足 | 需要 `slides:presentation:read` scope,并对该 PPT 有访问权限 |
| 400 | `revision_id` 不存在 | 传了无效版本号,用 `-1` 或真实存在的版本号 |

## 注意事项

1. **执行前必做**:`lark-cli schema slides.xml_presentation.slide.get` 查看最新参数结构
2. **block_id 提取**:返回 XML 里每个顶层块(shape、img、table、chart 等)的 `id` 属性即为 `block_id`,通常是 3 字符短码,例如 `<shape id="bUn" ...>`。用以下命令列出当前页所有 block_id:

   ```bash
   lark-cli slides xml_presentation.slide get --as user \
     --params "{\"xml_presentation_id\":\"$PRES_ID\",\"slide_id\":\"$SID\"}" \
     --jq '.data.slide.content' | grep -oE 'id="[^"]+"' | sed 's/id="//;s/"//'
   ```

## 相关命令

- [slides +replace-slide](lark-slides-replace-slide.md) — 块级替换 shortcut(推荐)
- [xml_presentation.slide replace](lark-slides-xml-presentation-slide-replace.md) — 底层 replace API 参考
- [slides +xml-get](lark-slides-xml-presentations-get.md) — 读整个 PPT 并保存到本地文件
- [slides-editing.md](../workflow/slides-editing.md) — 读-改-写闭环
references/cli/lark-slides-xml-presentation-slide-replace.md
# lark-slides xml_presentation.slide replace

## 用途

对单页做**块级局部替换**:不覆盖整页,按 patch 列表做 `block_replace`(整块替换)或 `block_insert`(整块插入)。适合"只想加 / 换一个元素、不动其他元素"的场景。

> **推荐**:优先使用 [`+replace-slide`](lark-slides-replace-slide.md) Shortcut——它会自动注入 `id` 和 `<content/>`,直接调本 API 需自己处理这两个约束(见注意事项 5、6)。

## 命令

```bash
lark-cli slides xml_presentation.slide replace --as user --params '<json_params>' --data '<json_data>'
```

## 参数说明

| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `--params` | JSON string | 是 | 路径参数与查询参数 |
| `--data` | JSON string | 是 | patch 列表 |

### params JSON 结构

```json
{
  "xml_presentation_id": "slides_example_presentation_id",
  "slide_id": "slide_example_id",
  "revision_id": -1,
  "tid": "idMock"
}
```

| 字段 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `xml_presentation_id` | string | 是 | 演示文稿唯一标识 |
| `slide_id` | string | 是 | 页面唯一标识 |
| `revision_id` | integer | 否 | 默认 `-1`(以最新版为基准);传具体版本号做乐观锁 |
| `tid` | string | 否 | 事务 ID,一般留空 |

### data JSON 结构

```json
{
  "parts": [
    { "action": "block_replace", "block_id": "bab", "replacement": "<shape .../>" },
    { "action": "block_insert", "insertion": "<img .../>", "insert_before_block_id": "baa" }
  ]
}
```

| 字段 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `parts` | array | 是 | patch 列表,长度 1~200,顺序执行 |

### parts[] 字段(按 action 不同)

本期 CLI 文档化两种 action:

#### action = "block_replace" — 整块替换

| 字段 | 必填 | 说明 |
|------|------|------|
| `action` | 是 | 固定 `block_replace` |
| `block_id` | 是 | 目标块的 3 位 short element ID(从 `slide.get` 返回的 XML 里读到) |
| `replacement` | 是 | 新 XML 片段,替换整个目标块 |

#### action = "block_insert" — 整块插入

| 字段 | 必填 | 说明 |
|------|------|------|
| `action` | 是 | 固定 `block_insert` |
| `insertion` | 是 | 要插入的完整 XML 片段 |
| `insert_before_block_id` | 否 | 插到这个块之前;省略则追加到页面末尾 |

## 使用示例

### block_replace:换一个 shape 的整体内容

```bash
lark-cli slides xml_presentation.slide replace --as user --params '{
  "xml_presentation_id": "slides_example_presentation_id",
  "slide_id": "slide_example_id"
}' --data '{
  "parts": [
    {
      "action": "block_replace",
      "block_id": "bab",
      "replacement": "<shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"120\"><content textType=\"title\"><p>新标题</p></content></shape>"
    }
  ]
}'
```

### block_insert:在已有页上加一张图

```bash
# 先拿 file_token
TOKEN=$(lark-cli slides +media-upload --file ./pic.png --presentation "$PRES_ID" --as user --jq '.data.file_token')

lark-cli slides xml_presentation.slide replace --as user --params "{
  \"xml_presentation_id\": \"$PRES_ID\",
  \"slide_id\": \"$SID\"
}" --data "$(jq -n --arg token "$TOKEN" '{
  parts: [
    {
      action: "block_insert",
      insertion: ("<img src=\"" + $token + "\" topLeftX=\"500\" topLeftY=\"100\" width=\"200\" height=\"150\"/>")
    }
  ]
}')"
```

### 多条 parts 原子执行

```bash
lark-cli slides xml_presentation.slide replace --as user --params '{
  "xml_presentation_id": "slides_example_presentation_id",
  "slide_id": "slide_example_id"
}' --data '{
  "parts": [
    {"action":"block_replace","block_id":"bab","replacement":"<shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"120\"><content textType=\"title\"><p>新标题</p></content></shape>"},
    {"action":"block_insert","insertion":"<img src=\"<file_token>\" topLeftX=\"700\" topLeftY=\"400\" width=\"180\" height=\"100\"/>"}
  ]
}'
```

## 返回值

### 成功

```json
{
  "ok": true,
  "identity": "user",
  "data": {
    "revision_id": 105
  }
}
```

### 失败(任一 part 失败,整批不生效)

失败时命令以非零退出码结束,stderr 返回类型化错误信封(`error.code`(如 3350001)/ `error.message` / `error.hint`),stdout 不会打印后端原始响应:

```json
{
  "ok": false,
  "identity": "user",
  "error": {
    "type": "api",
    "subtype": "...",
    "code": 3350001,
    "message": "...",
    "hint": "..."
  }
}
```

| 字段 | 类型 | 说明 |
|------|------|------|
| `data.revision_id` | integer | 成功时返回更新后最新版本号 |

## 常见错误

| 错误码 | 含义 | 解决方案 |
|--------|------|----------|
| 3350001 | `block_id` 在当前页不存在,或 XML 格式 / 结构错误 | 重新 `slide.get` 拿最新 XML,确认 `block_id` 存在;检查 `replacement` / `insertion` 是否合法 XML |
| 400 | `parts` 长度超过 200 | 拆多次调用 |
| 3350002 | `revision_id` 不存在(超过当前版本号) | 用 `-1` 或实际存在的 `revision_id` |
| 400 | XML 格式错误 | `replacement` / `insertion` 必须为合法的 XML 片段,标签闭合 + 属性引号 |
| 403 | 权限不足 | 需要 `slides:presentation:update` 或 `slides:presentation:write_only` |

## 注意事项

1. **parts 原子事务**:任一条失败整批回滚,不会出现"前几条成功、后几条失败"的中间态。
2. **block_id 的获取**:`slide.get` 返回的 XML 里每个块(shape、img、table、chart 等)会带 3 位 short element ID,用这个值填 `block_id` / `insert_before_block_id`。
3. **`<img>` 必须用 file_token**:不能用外链 URL——先 [`slides +media-upload`](lark-slides-media-upload.md) 拿 token。
4. **不能字段级 patch**:要改一个块的某个属性(比如只改 `topLeftX`),得写整块新 XML 走 `block_replace`;API 不支持"只改一个字段"。
5. **`block_replace` 要求 `replacement` 根元素带 `id="<block_id>"`**:底层 API 的硬约束,缺失会返回 3350001。推荐走 shortcut [`+replace-slide`](lark-slides-replace-slide.md)——它会自动把 `id` 注入到 `replacement` 根元素上,用户写 XML 时不用自己加。
6. **`<shape>` 必须有 `<content/>` 子元素**:SML 2.0 schema 要求,缺失同样触发 3350001。shortcut [`+replace-slide`](lark-slides-replace-slide.md) 会自动注入 `<content/>`,直接调底层 API 需要自己加。
7. **执行前必做**:`lark-cli schema slides.xml_presentation.slide.replace` 查看最新参数结构。

## 相关命令

- [slides +replace-slide](lark-slides-replace-slide.md) — 块级替换 shortcut(推荐,自动注入 id)
- [xml_presentation.slide get](lark-slides-xml-presentation-slide-get.md) — 读原页拿 block short ID
- [slides +media-upload](lark-slides-media-upload.md) — 上传图片拿 file_token
- [slides-editing.md](../workflow/slides-editing.md) — 读-改-写闭环 + 决策树
references/cli/lark-slides-xml-presentations-get.md
# lark-slides xml_presentations get

## 用途

读取飞书幻灯片(PPT)的完整演示文稿 XML,或通过 `--slide-id` / `--slide-number` 读取指定单页 XML。

## Shortcut

使用 `slides +xml-get` shortcut,可以把 XML 保存到本地文件,避免终端输出被截断。

```bash
lark-cli slides +xml-get --as user \
  --presentation "slides_example_presentation_id" \
  --output .lark-slides/plan/slides_example_presentation_id/readback.xml \
  --json
```

### 参数说明

| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `--presentation` | string | 是 | 演示文稿的唯一标识符 |
| `--revision-id` | integer | 否 | 版本号,`-1` 表示最新版本 |
| `--output` | string | 否 | XML 保存路径,必须使用相对路径;省略时 XML 在 stdout 的 JSON envelope 中返回 |
| `--raw` | flag | 否 | 直接把 XML 输出到 stdout,不包 JSON envelope;不能与 `--output`、`--jq` 或非 JSON `--format` 同时使用 |
| `--slide-id` | string | 否 | 只读取指定 `slide_id` 的单页 XML;不能与 `--slide-number` 或 `--remove-attr-id` 同时使用 |
| `--slide-number` | integer | 否 | 只读取指定的 1-based 页码;不能与 `--slide-id` 或 `--remove-attr-id` 同时使用 |
| `--remove-attr-id` | flag | 否 | 仅全文读取可用;移除 XML id 属性后读取,不适合后续精确块编辑 |
| `--json` | flag | 否 | `--format json` 的简写,json 为默认输出格式 |


### 基础示例

```bash
lark-cli slides +xml-get --as user \
  --presentation "slides_example_presentation_id" \
  --output .lark-slides/plan/slides_example_presentation_id/readback.xml \
  --json
```

### 读取单页并保存

按页面 ID 和按页码二选一:

```bash
lark-cli slides +xml-get --as user \
  --presentation "slides_example_presentation_id" \
  --slide-id "slide_example_id" \
  --output .lark-slides/plan/slides_example_presentation_id/slide.xml \
  --json
```

### 直接输出 XML 到管道

```bash
lark-cli slides +xml-get --as user \
  --presentation "slides_example_presentation_id" \
  --slide-number 1 \
  --raw
```

### 指定版本读取

```bash
lark-cli slides +xml-get --as user \
  --presentation "slides_example_presentation_id" \
  --revision-id 10 \
  --output .lark-slides/plan/slides_example_presentation_id/readback-r10.xml \
  --json
```

### 移除 XML id 属性后读取

```bash
lark-cli slides +xml-get --as user \
  --presentation "slides_example_presentation_id" \
  --remove-attr-id \
  --output .lark-slides/plan/slides_example_presentation_id/readback-no-id.xml \
  --json
```


## 底层原生命令形态

```bash
lark-cli slides xml_presentations get --as user --params '<json_params>'
```

### 参数说明

| 参数 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `--params` | JSON string | 是 | 路径参数与查询参数,结构以 schema 为准 |

### params JSON 结构

```json
{
  "xml_presentation_id": "slides_example_presentation_id",
  "revision_id": -1
}
```

| 字段 | 类型 | 必需 | 说明 |
|------|------|------|------|
| `xml_presentation_id` | string | 是 | 演示文稿的唯一标识符 |
| `revision_id` | integer | 否 | 版本号,`-1` 表示最新版本 |


### 返回值

成功时返回演示文稿的完整信息:

```json
{
  "ok": true,
  "identity": "user",
  "data": {
    "xml_presentation": {
      "presentation_id": "slides_example_presentation_id",
      "revision_id": 1,
      "content": "<presentation xmlns=\"https://www.larkoffice.com/sml/2.0\" height=\"540\" width=\"960\">...</presentation>"
    }
  }
}
```

### 返回字段说明

| 字段 | 类型 | 说明 |
|------|------|------|
| `data.xml_presentation.presentation_id` | string | 演示文稿唯一标识 |
| `data.xml_presentation.revision_id` | integer | 版本号 |
| `data.xml_presentation.content` | string | XML 格式的完整内容 |

### 常见错误

| 错误码 | 含义 | 解决方案 |
|--------|------|----------|
| 404 | 演示文稿不存在 | 检查 `xml_presentation_id` 是否正确 |
| 403 | 权限不足 | 检查是否拥有 `slides:presentation:read` scope,或是否有访问权限 |
| 400 | 参数格式错误 | 确保 `--params` 是合法的 JSON 字符串 |


### 注意事项

1. lark-slides 工作流默认使用 `slides +xml-get`;只有必须直接调底层 API 时,才使用
2. 直接调用底层 API 前,使用 `lark-cli schema slides.xml_presentations.get` 查看最新的参数结构
3. 返回的 XML 在 `data.xml_presentation.content` 字段中
4. 如果只需要部分信息,可以使用 `jq` 等工具过滤返回结果
5. 不要在普通工作流中把完整 XML 打到终端;用 `slides +xml-get --output` 保存文件

## 相关命令

- [slides +create](lark-slides-create.md) - 创建空白 PPT
- [slides +add-slide](lark-slides-add-slide.md) - 添加幻灯片页面
- [slides +delete-slide](lark-slides-delete-slide.md) - 删除幻灯片页面
references/iconpark-index.json
# IconPark 图标索引(兼容入口)

本文档已迁移至 [`xml/iconpark-index.json`](xml/iconpark-index.json)。

此文件仅保留旧路径兼容性;后续引用请使用新路径。
references/iconpark.md
# IconPark 图标(兼容入口)

本文档已迁移至 [`xml/iconpark.md`](xml/iconpark.md)。

此文件仅保留旧路径兼容性;后续引用请使用新路径。
references/lark-slides-add-slide.md
# slides +add-slide(兼容入口)

本文档已迁移至 [`cli/lark-slides-add-slide.md`](cli/lark-slides-add-slide.md)。

此文件仅保留旧路径兼容性;后续引用请使用新路径。
references/lark-slides-create.md
# slides +create(创建飞书幻灯片)(兼容入口)

本文档已迁移至 [`cli/lark-slides-create.md`](cli/lark-slides-create.md)。

此文件仅保留旧路径兼容性;后续引用请使用新路径。
references/lark-slides-delete-slide.md
# slides +delete-slide(兼容入口)

本文档已迁移至 [`cli/lark-slides-delete-slide.md`](cli/lark-slides-delete-slide.md)。

此文件仅保留旧路径兼容性;后续引用请使用新路径。
references/lark-slides-edit-workflows.md
# 编辑已有 PPT:读-改-写闭环(兼容入口)

本文档已迁移至 [`workflow/slides-editing.md`](workflow/slides-editing.md)。

此文件仅保留旧路径兼容性;后续引用请使用新路径。
references/lark-slides-history.md
# slides history(历史版本与回滚)(兼容入口)

本文档已迁移至 [`cli/lark-slides-history.md`](cli/lark-slides-history.md)。

此文件仅保留旧路径兼容性;后续引用请使用新路径。
references/lark-slides-media-upload.md
# slides +media-upload(兼容入口)

本文档已迁移至 [`cli/lark-slides-media-upload.md`](cli/lark-slides-media-upload.md)。

此文件仅保留旧路径兼容性;后续引用请使用新路径。
references/lark-slides-pptx-template-workflows.md
# PPT Template Rewrite Principles(兼容入口)

本文档已迁移至 [`workflow/template-editing.md`](workflow/template-editing.md)。

此文件仅保留旧路径兼容性;后续引用请使用新路径。
references/lark-slides-replace-slide.md
# slides +replace-slide(兼容入口)

本文档已迁移至 [`cli/lark-slides-replace-slide.md`](cli/lark-slides-replace-slide.md)。

此文件仅保留旧路径兼容性;后续引用请使用新路径。
references/lark-slides-screenshot.md
# slides +screenshot(兼容入口)

本文档已迁移至 [`cli/lark-slides-screenshot.md`](cli/lark-slides-screenshot.md)。

此文件仅保留旧路径兼容性;后续引用请使用新路径。
references/lark-slides-xml-presentation-slide-get.md
# lark-slides xml_presentation.slide get(兼容入口)

本文档已迁移至 [`cli/lark-slides-xml-presentation-slide-get.md`](cli/lark-slides-xml-presentation-slide-get.md)。

此文件仅保留旧路径兼容性;后续引用请使用新路径。
references/lark-slides-xml-presentation-slide-replace.md
# lark-slides xml_presentation.slide replace(兼容入口)

本文档已迁移至 [`cli/lark-slides-xml-presentation-slide-replace.md`](cli/lark-slides-xml-presentation-slide-replace.md)。

此文件仅保留旧路径兼容性;后续引用请使用新路径。
references/lark-slides-xml-presentations-get.md
# lark-slides xml_presentations get(兼容入口)

本文档已迁移至 [`cli/lark-slides-xml-presentations-get.md`](cli/lark-slides-xml-presentations-get.md)。

此文件仅保留旧路径兼容性;后续引用请使用新路径。
references/planning-layer.md
# Planning Layer

新建演示文稿或大幅改写页面时,必须先写 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。这个文件是 deck 的设计中间层,用来把叙事、页面角色、布局、视觉重点和文字密度固定下来,避免从用户提示直接跳到 XML。

小型已有页编辑可豁免,例如只替换一个标题、改一个数字、插入一个块、上传并插入一张图。只要任务会重排多页、生成新 deck、替换整页结构,仍然需要规划层。

## Required Flow

1. 理解用户需求,必要时澄清主题、受众、页数、风格。
2. 选择唯一 plan 目录:`.lark-slides/plan/<deck-or-task-id>/`。
3. 先创建目录:`mkdir -p .lark-slides/plan/<deck-or-task-id>`。
4. 写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`。
5. 读取 `xml/xml-schema-quick-ref.md`、`visual-planning.md` 和 `asset-planning.md`。
6. 按 plan、visual planning 和 asset planning 规则逐页生成 XML,把 `layout_type`、`visual_focus`、`text_density` 转成具体页面几何和文本量约束,并把缺失素材转成可执行兜底视觉。
7. 创建 PPT 后用 `slides +xml-get` 回读,核对页面数量、关键元素和 plan 到 XML 的对应关系,空白 PPT 中没有 slide 元素。


## Plan Path

Use a separate plan directory per deck or task so multiple presentations in the same workspace cannot overwrite each other.

Recommended IDs:

- New deck before creation: title slug plus date/time, such as `q3-review-20260507-1805`.
- Existing PPT rewrite: the `xml_presentation_id`.
- Ambiguous or untitled task: short task slug plus date/time.

Rules:

- Do not reuse `.lark-slides/plan/slide_plan.json` as a shared path.
- Create the directory before writing the file.
- Reuse the same plan path for XML generation and post-create verification for that deck.

## Artifact Lifecycle

`.lark-slides/` is local agent state. It supports recovery, iteration, and later edits, but it should not be treated as source code or committed by default.

Keep:

- `.lark-slides/plan/<deck-or-task-id>/slide_plan.json` after successful creation or major rewrite. The plan is the editable design state for the deck.
- A small manifest when useful for follow-up work, such as `xml_presentation_id`, slide IDs, `revision_id`, plan path, and verification status.

Clean or avoid keeping:

- Transient XML payloads after successful creation and verification. Prefer `/tmp` for throwaway XML, or delete generated XML files after success.
- Stale XML drafts that no longer match the current presentation state.

Exception:

- If creation fails or partially succeeds, keep the relevant XML/debug payloads until recovery is complete. Record `xml_presentation_id` first, then fetch current state before retrying.

## JSON Shape

```json
{
  "presentation_goal": "Explain the proposal and secure approval for the next phase.",
  "audience": "Product and engineering leaders who know the domain but need a concise decision narrative.",
  "theme_style": "Clean business style, light background, restrained blue accent, strong visual hierarchy.",
  "visual_system": {
    "background_strategy": "Content pages use one light base; cover and closing may use a related dark treatment with the same accent system.",
    "motif": "Consistent card style and numbered anchors.",
    "color_roles": {
      "primary": "Used for the dominant structural motif and about 60-70% of visual weight.",
      "secondary": "Used for grouped regions, comparison panels, or supporting categories.",
      "accent": "Used only for key numbers, conclusions, or focus markers."
    }
  },
  "typography_constraints": {
    "title_max_lines": 2,
    "body_max_lines_per_box": 2,
    "footer_max_lines": 1,
    "long_text_handling": "Shorten, split into multiple boxes, or move detail to speaker notes instead of shrinking into a tight box."
  },
  "verification_plan": {
    "check_background_consistency": true,
    "check_text_fit": true,
    "check_visual_focus": true,
    "check_asset_rendering": true
  },
  "slides": [
    {
      "page": 1,
      "title": "Proposal Title",
      "key_message": "The initiative is ready for a focused pilot.",
      "layout_type": "title-cover",
      "visual_focus": "Large title area with one concise supporting statement.",
      "asset_need": {
        "asset_type": "logo",
        "purpose": "Signal product or team identity on the opening page.",
        "suggested_query": "product logo",
        "fallback_if_missing": "Create a close-enough image with the image generation tool instead of a real logo."
      },
      "text_density": "low",
      "speaker_intent": "Frame the decision and establish the deck's point of view."
    }
  ]
}
```

## Required Fields

Top-level fields:

- `presentation_goal`: what the whole deck is trying to achieve.
- `audience`: target readers or listeners and their assumed background.
- `theme_style`: visual tone, palette direction, and professional style.
- `visual_system`: deck-level visual rules that must stay stable across pages, including background strategy, recurring motif, and color roles.
- `typography_constraints`: deck-level limits for line count, text box density, and how to handle long text before XML generation.
- `verification_plan`: explicit checks to perform after creation or major edits; include background consistency, text fit, visual focus, and asset rendering when relevant.
- `slides`: ordered page plans.

Each slide must include:

- `page`: 1-based page number.
- `title`: slide title.
- `key_message`: the one idea this page must land.
- `layout_type`: planned page structure.
- `visual_focus`: dominant visual object or region.
- `asset_need`: planning-only structured asset metadata; no search, download, or upload required. Follow `asset-planning.md`.
- `text_density`: `low`, `medium`, or `high`.
- `speaker_intent`: why the speaker needs this page and how it advances the story.

Optional slide fields:

- `chart_contract`: required when the page plan includes a standard data chart that `<chart>` supports. Use this shape:

```json
{
  "chart_contract": {
    "required": true,
    "render_as": "native_chart",
    "chart_type": "line",
    "data_source": "mock_placeholder",
    "data_series_required": true,
    "placeholder_label_required": true,
    "manual_shape_fallback_allowed": false
  }
}
```

When `chart_contract.required == true`, XML generation must produce a `<chart>` element on that slide. A shape, line, or polyline approximation does not satisfy the plan.

`data_source` must be one of:

- `user_provided`: the user supplied concrete values, tables, CSV, or metric lists; use them and do not replace them with mock data.
- `mock_placeholder`: the user asked for a placeholder, template, example, or later-replaceable chart position; use mock data in native `<chart>`.
- `mock_required_by_intent`: the user did not provide concrete values but asked for data expression, charts, trends, comparisons, or distributions; use mock data in native `<chart>`.

`data_series_required` means the generated XML must include `<chartData>`. It does not require user-provided real-world values. When real values are unavailable but chart expression is part of the user's intent, write mock or placeholder values into native `<chart>` and label them clearly instead of switching to manual drawing primitives or metric blocks.

## Layout Vocabulary

Use one of these `layout_type` values unless the user explicitly needs a custom structure:

- `title-cover`
- `section-divider`
- `two-column`
- `image-left-text-right`
- `image-right-text-left`
- `big-number`
- `timeline`
- `comparison`
- `architecture-diagram`
- `process-flow`
- `quote-highlight`
- `conclusion`

The value must affect XML geometry, not just appear as a label. For example, `timeline` should create a horizontal or vertical sequence, `comparison` should create distinct side-by-side regions, and `big-number` should reserve dominant space for a large metric.

## Text Density Rules

- `low`: title plus 1 short statement, or 1-3 very short labels.
- `medium`: title plus 2-4 concise bullets or labeled regions.
- `high`: allowed only when the user needs detail; use tables, columns, or grouped regions instead of a long bullet list.

Do not let all pages become title + bullet slides. For decks of 4 or more pages, aim for at least 4 different `layout_type` values when the content allows it.

Text density must be realistic for the planned geometry. If a page needs long titles, bilingual labels, paper figure captions, legal disclaimers, or dense technical wording, record how the text will be shortened, split, or moved to speaker notes. Do not rely on small font sizes or tight boxes to make text fit.

## Visual System Planning

Before generating XML, define a visual system that can survive the whole deck:

- `background_strategy`: specify the default background for normal content pages, and which page roles may intentionally differ. Do not let pages drift through near-identical but inconsistent background colors.
- `motif`: choose one reusable structural device, such as numbered node, card treatment, half-bleed image zone, headline, or footer. The motif should appear consistently enough that pages feel related.
- `color_roles`: assign primary, secondary, and accent roles. The same color must not mean unrelated things across pages.
- `cover_content_relationship`: if the cover uses a different dark or image-led treatment, state how it connects to content pages through shared colors, motifs, or geometry.
- `closing_relationship`: if the closing page mirrors the cover, state that explicitly so it looks intentional rather than like a new theme.

These are planning constraints, not decoration notes. They must affect coordinates, background fills, shape styles, and text placement in generated XML.

## Iterative Deck State

When continuing an existing deck, update the same plan path rather than creating a new disconnected plan. Keep the plan aligned with what has actually been created.

Recommended optional fields for long-running work:

- `deck_status`: current slide count, target slide count if known, and last verified revision or timestamp.
- `created_slides`: page number, slide id when known, and the page role.
- `assets_used`: source, local path when applicable, uploaded token when known, and which page uses it.
- `open_issues`: known layout, text fit, asset, or consistency risks that still need correction.

Do not hard-code a page number just because a previous deck used that pattern. Plan by page role and evidence need, such as "method overview pages should use a figure when the source has a readable figure" instead of binding screenshots, charts, or diagrams to a fixed page index. The plan should describe decision rules, not a rigid template sequence.

## Asset Planning

`asset_need` is metadata. It can describe a desired figure, diagram, chart, icon, logo, screenshot, or fallback visual.

Use an object for one planned asset, an array for multiple real needs, or `asset_type: "none"` when no asset is useful. Each planned asset must include:

- `asset_type`: one of `paper_figure`, `architecture_diagram`, `icon`, `logo`, `chart`, `infographic`, `screenshot`, `flow_diagram`, or `none`.
- `purpose`: why this asset helps the page's key message.
- `suggested_query`: short future lookup hint only; do not execute it unless separately requested.
- `fallback_if_missing`: a plan to create a close-enough image with the image generation tool, or a native `<chart>` for data.
- `chart_contract`: when `asset_type` is `chart` and the visual is a supported standard data chart, set this optional slide-level field so generation is locked to native `<chart>`.

For detailed rules and examples, read `asset-planning.md`.

Good examples:

- `{"asset_type":"architecture_diagram","purpose":"Explain component relationships.","suggested_query":"service architecture diagram","fallback_if_missing":"Render the component diagram with <shape> + <line>."}`
- `{"asset_type":"logo","purpose":"Identify the customer context.","suggested_query":"customer logo","fallback_if_missing":"Create a close-enough image with the image generation tool instead of a real logo."}`
- `{"asset_type":"chart","purpose":"Show adoption trend.","suggested_query":"monthly adoption trend chart","fallback_if_missing":"Render a native `<chart>` using the provided series when available; otherwise render a native `<chart>` with mock placeholder values and label it as 模拟数据,仅占位,待替换真实数据."}`

## XML Generation Contract

Before writing each slide XML, map the plan fields to concrete decisions:

- `key_message` determines the headline, dominant claim, or main takeaway.
- `layout_type` determines the coordinate structure and element types. Use `visual-planning.md` for concrete layout rules.
- `visual_focus` determines the largest visual region or emphasized object.
- `text_density` caps visible text volume.
- `asset_need` informs placeholder diagrams, icons, charts, screenshots, or fallback visuals only. Missing real assets must use `fallback_if_missing`, not blank regions.
- `chart_contract` locks supported standard data charts to native `<chart>` output. Manual approximations are allowed only when the planned chart type is unsupported by `<chart>` or when the visual is explicitly non-data/decorative.

After creating the PPT, fetch the presentation and verify:

- Page count matches the plan.
- Every page has the planned title and key message represented.
- At least several pages have visibly different XML layout structures.
- Planned `visual_focus` appears as a dominant visual region or object.
- Asset planning is proportional to the deck topic and length: technical, research, product, and analytical decks should include meaningful planned visuals where they clarify the story, and each planned asset has a visible fallback if no real asset was used.
- `text_density` is reflected in the amount of visible text.
- Pages are not crowded, and any planned `timeline`, `comparison`, or `architecture-diagram` page uses its matching visual structure.
- The actual backgrounds match `visual_system.background_strategy`; any dark, image-led, or emphasis page has an intentional relationship to the rest of the deck.
- Text boxes respect `typography_constraints`; long labels, captions, footer text, and conclusion bars are not squeezed into boxes that are too short for the intended line count.
- If real assets are used, the final XML contains renderable asset tokens or supported local placeholders for creation, not http URLs, stale local paths, or blank image boxes.
references/slides_chart_demo.xml
# Slides 图表示例 XML(兼容入口)

本文档已迁移至 [`xml/slides_chart_demo.xml`](xml/slides_chart_demo.xml)。

此文件仅保留旧路径兼容性;后续引用请使用新路径。
references/slides_xml_schema_definition.xml
# Slides XML Schema 定义(兼容入口)

本文档已迁移至 [`xml/slides_xml_schema_definition.xml`](xml/slides_xml_schema_definition.xml)。

此文件仅保留旧路径兼容性;后续引用请使用新路径。
references/troubleshooting.md
# Troubleshooting(兼容入口)

本文档已迁移至 [`workflow/error-handling.md`](workflow/error-handling.md)。

此文件仅保留旧路径兼容性;后续引用请使用新路径。
references/validation-checklist.md
# Validation Checklist(兼容入口)

本文档已迁移至 [`workflow/validation-xml.md`](workflow/validation-xml.md)。

此文件仅保留旧路径兼容性;后续引用请使用新路径。
references/visual-planning.md
# Visual Planning

新建演示文稿或大幅改写页面时,在 `slide_plan.json` 完成后、生成 XML 前读取本文件。目标是让 `layout_type`、`visual_focus`、`text_density` 变成实际页面几何,而不是只写在 plan 里。

默认画布按 `960 x 540` 规划。模板 XML 可以覆盖具体坐标,但不能覆盖这些原则:页面要有主视觉区域、文本要受密度约束、不同 `layout_type` 必须产生明显不同的坐标结构。

## Core Rules

- `layout_type` must change geometry: element positions, region sizes, alignment, and visual rhythm must differ across page types.
- `visual_focus` determines the largest or highest-contrast region. It can be an image, diagram, metric, quote, or table.
- `text_density` caps visible text:
  - `low`: title plus one short statement, or 1-3 labels.
  - `medium`: title plus 2-4 concise bullets or labeled regions.
  - `high`: use a table, columns, grouped labels, or annotations. Do not use one long bullet box.
- Do not create a deck where every content page is title plus bullets. For 4 or more pages, use at least 4 different layout structures when the content allows.
- Keep safe outer margins around `40` px on standard content pages, and fill the content area densely with a card grid rather than leaving large empty space. Only go full-bleed for an intentional image or cover treatment.
- Reserve vertical space for titles. A typical content title area is `y=36..90`; main content should usually start at `y>=110`.
- Avoid crowding the bottom edge. Keep non-background content above `y=500` unless it is a footer.
- Keep backgrounds consistent with the deck's `visual_system.background_strategy`. Normal content pages should use the same base background unless there is a clear page-role reason to change.
- Treat text fit as a layout constraint, not a cleanup step. If a text box is too small for the intended line count, shorten the text, split it, or allocate more space before creating XML.
- Do not use `<shape>` to build pictorial visuals like mock photos or fake objects. Use the image generation tool instead.
- Do not place a `rect` or `line` for dividing or decorative purposes directly under a `headline` or `title`.
- Do not use section bands, horizontal bars, vertical bars, or page-edge strips.

## Background And Motif Consistency

Decks can vary page backgrounds, but variation must be intentional and legible:

- Pick one default background for ordinary content pages and reuse it exactly. Avoid near-identical drift such as several slightly different off-white values unless it encodes a clear section change.
- Cover, section divider, emphasis, and conclusion pages may use a dark, image-led, or high-contrast background. They must still share the deck's primary color, motif, typography, or geometry.
- If a cover uses a split composition, make the split visible in the background or layout. For example, reserve a darker text region and a related but distinct visual region instead of placing all elements on one flat field.
- Reuse a small number of visual devices: card radius, node style, icon container, or footer treatment. Do not introduce a new decorative language on each page.
- Insert background and motif shapes before content elements so they do not cover text, images, or diagrams.

## Text Fit Guardrails

Use these as conservative minimums on a 960 x 540 canvas. Increase height when using bold text, Chinese text, mixed Chinese/English, or line spacing above default.

| Text use | Typical font size | Minimum height |
|----------|-------------------|----------------|
| Caption, 1 line | 10-12 | 18 |
| Caption, 2 lines | 10-12 | 30 |
| Body, 1 line | 12-14 | 24 |
| Body, 2 lines | 12-14 | 40 |
| Body, 2 lines, bold | 12-14 | 48 |
| Headline, 1 line | 20-28 | 42 |
| Title, 2 lines | 28-36 | 110 |

Additional rules:

- Do not put long Chinese sentences or long English phrases into `height=18` or `height=22` boxes. Those heights are for short labels only.
- Footer/source text should usually be one short line. If it needs more, make it a real caption block above the footer area.
- Bottom conclusion bars should be at least `40` px tall for one emphasized line and at least `54` px tall for two lines.
- Diagram labels should be short enough to fit the shape. Prefer two short lines over one cramped long line.
- When a text block has more than one `<p>`, size the box for multiple lines explicitly. Do not assume the renderer will auto-expand.
- If a line contains mixed Chinese and English, budget more width than either language alone; mixed text wraps less predictably.

## Layout Types

### `title-cover`

Purpose: introduce the deck's point of view.

Geometry:
- Use one dominant title block, usually `x=70..120`, `y=150..250`, `width=700..820`.
- Add one subtitle or context line, not a bullet list.
- Visual focus MUST be an `<img>`: a full-bleed background image or a large **full-height** side image (searched by the image search tool or generated by the image generation tool). Do NOT compose the cover visual from `<shape>` or `<icon>`.
- If the cover has a large full-height side image, use a split layout: keep the title and subtitle in the text region on the opposite side, and reserve a separate visual region so the image does not overlap the title. Crop (size and place) the side image so it stays within its visual region and does not extend into the text region.
- For split covers, make the background reinforce the composition, such as a darker text side and a related visual panel. Avoid one flat field where title and diagram compete for attention.
- Keep source metadata to one short line where possible. If it wraps, shorten author lists or move details to notes.
- The main title should be controlled, normally one or two lines. Do not let it occupy both the text region and the visual region.
- Do not add a vertical accent bar, side rail, or decorative line/strip. 

Text:
- `low` only unless the user explicitly asks for detail.

### `section-divider`

Purpose: reset rhythm and mark a new chapter.

Geometry:
- Use a large section number, chapter label, or single centered claim.
- Keep the page sparse. A divider is not a content page.
- Visual focus can be one oversized number.

Text:
- Title plus one phrase. No bullets.

### `two-column`

Purpose: compare two related ideas or pair explanation with evidence.

Geometry:
- Split main region into two balanced columns, for example left `x=60,width=400`, right `x=500,width=400`.
- Each column needs its own heading or visual anchor.
- Do not place one full-width bullet box under a normal title; that is not a two-column layout.

Text:
- `medium`: 2-3 short items per column.
- `high`: use grouped rows or mini table structure inside columns.

### `image-left-text-right`

Purpose: let a visual establish context, with text explaining implication.

Geometry:
- Left visual region should occupy roughly `35-45%` of slide width, often full height or tall crop.
- Right text region starts around `x=420` and should have a strong headline plus short support.
- If no image is available, use the image generation tool to create an approximate image that matches `asset_need`.
- For dense screenshots, paper figures, or product captures with small labels, allocate a larger visual region when possible: often `50-65%` of slide width or at least `320` px height.
- Place screenshots in a deliberate frame or panel, and leave enough margin so axes, captions, and edge labels are not cropped by the slide boundary.

Text:
- Keep right-side text short. Avoid more than 4 bullets.
- For screenshot explanation pages, prefer 2-3 interpretation cards or callouts instead of a paragraph block.

### `image-right-text-left`

Purpose: lead with a message, then reinforce it with a visual.

Geometry:
- Left text region starts around `x=60..90`, width `400..460`.
- Right visual region occupies roughly `35-45%` of slide width.
- Align the image with the main text block, not only with the title.
- For dense screenshots, paper figures, or product captures with small labels, increase the visual region and reduce text. A readable image is more valuable than a fully populated text column.

Text:
- Use one main claim and 2-3 supporting points.
- Keep callouts parallel and short. If a callout needs more than two lines, split it into a separate note or a new slide.

### `big-number`

Purpose: make one metric or fact memorable.

Geometry:
- Reserve the largest object for the metric: font size often `40-52`.
- MUST Set `wrap="true" autoFit="normal-auto-fit"` on the metric's `<content>` so an oversized number shrinks to fit its box.
- Pair the number with one explanation and optional 2-3 small supporting labels.
- Do not bury the number in a bullet list or small card.

Text:
- `low` or `medium`. If detail is needed, add small annotations around the metric.
- Supporting labels must not compete with the number. Use compact labels, legends, or mini-cards rather than long explanatory bars.

### `timeline`

Purpose: show sequence, roadmap, history, or phases.

Geometry:
- Create a horizontal or vertical spine with 3-6 milestones.
- Each milestone should have a dot/card/date label connected by a line or arrow.
- Title is separate from the sequence. The sequence is the visual focus.

Text:
- Each milestone gets a short label and optional one-line explanation.
- Do not use paragraph-length milestone descriptions.

### `comparison`

Purpose: make a choice, before/after, old/new, or option tradeoff clear.

Geometry:
- Use two or three distinct panels, columns, or a table-like structure.
- Headings must be visually aligned so differences are easy to scan.
- Use color, border, icon, or label treatment to highlight the preferred option or key difference.

Text:
- Use parallel wording across columns.
- Avoid uneven long bullet lists that destroy comparability.

### `architecture-diagram`

Purpose: explain components, dependencies, or system flow.

Implementation: use `<shape>` + `<line>`.

Geometry:
- Main visual area should be a diagram, not prose.
- Use grouped boxes, lanes, arrows or lines, and short labels.
- Keep diagram labels concise. Put explanation in notes or a small side caption if needed.

Text:
- Prefer labels of 1-5 words.
- Use no more than one short explanatory text block.
- If a node label needs two lines, size the node and the text box for two lines. Do not let labels overlap connectors.

### `process-flow`

Purpose: show operational steps, workflow, or cause-effect path.

Implementation: use `<shape>` + `<line>`.

Geometry:
- Use numbered steps connected by arrows or lines.
- 3-5 steps is ideal for one slide. If there are more, group them into phases.
- The flow direction must be visually obvious.

Text:
- Each step gets a verb-led label and one short descriptor at most.
- Step labels should be parallel in length and grammar. If one step needs a long explanation, move the explanation to a side note or speaker notes.

### `quote-highlight`

Purpose: emphasize a customer voice, principle, thesis, or decision statement.

Geometry:
- Quote or claim is the dominant text object.
- Use large type, generous whitespace, and optional attribution or context badge.
- Do not combine a quote-highlight page with a normal bullet section.

Text:
- One quote or statement, plus optional attribution. No bullets.

### `conclusion`

Purpose: close with decision, recommendation, or next action.

Geometry:
- Use one dominant closing statement or call to action.
- Visual focus should be the recommendation or action, not decorative filler.
- When using a full-bleed background image, add a semi-transparent scrim between the image and the text so the text stays legible; verify contrast.

Text:
- Keep the final page easy to remember. Avoid recap overload.
- Conclusion pages may mirror the cover background.

## Screenshot And Paper Figure Pages

When a page uses a real screenshot, chart, paper figure, or product capture:

- Choose screenshot placement based on page role, not a fixed slide number. Method overview, evidence, comparison, and failure-analysis pages are common candidates; title, agenda, and conclusion pages usually are not.
- Use the real asset only when it is readable at slide size. If the figure is too dense, crop to the relevant region, create a zoomed detail, or regenerate the core message with the image generation tool.
- A screenshot should normally be the visual focus. Do not shrink it into a decorative thumbnail while surrounding it with dense text.
- Pair the image with a small number of interpretive annotations that tell the audience what to notice.
- Always include a short source caption when using external or paper-derived visuals.
- Verify the final XML contains a supported image token or creation-time local placeholder, not an unsupported external URL.

## Plan To XML Checklist

Before creating XML for each page, answer these checks:

1. Which region is the visual focus, and is it the largest or most prominent object?
2. Does the XML geometry match the `layout_type` description above?
3. Does `text_density` limit the number of paragraphs, bullets, labels, and text boxes?
4. Would this page still be recognizable if the `layout_type` label were removed from the plan?
5. Across the deck, do multiple pages use genuinely different structures?
6. Does the background follow the planned deck strategy, and are any deviations intentional?
7. Are all text boxes large enough for their intended font size and line count?
8. If the page uses a screenshot or paper figure, is it large enough to read and accompanied by concise interpretation?

After fetching the created presentation, verify:

- Use `timeline`, `comparison`, and `architecture-diagram` only when the content calls for them; do not force irrelevant page types.
- Any planned `timeline`, `comparison`, or `architecture-diagram` page uses the matching sequence, side-by-side comparison, or component-and-connection structure.
- Pages are not crowded and do not rely on long bullet boxes.
- Main claim, supporting detail, and visual focus have clear hierarchy.
- Static XML inspection should include text-fit risk: very short text boxes containing long text, multi-paragraph boxes with insufficient height, footer text that may wrap, and labels placed directly over connectors.
- Background and motif consistency should be checked across pages, not only within one slide.
references/workflow/error-handling.md
# Troubleshooting

本文件覆盖 lark-slides 的通用创建前自检、XML 排障和常见失败处理。命令专属问题优先看对应 reference,例如 `+replace-slide`、`+media-upload`。

## XML Preflight

在真正创建或替换前,至少检查:

- 特殊字符已转义:正文和标题里的 `&`、`<`、`>` 不能裸写;属性值里的裸 `&` 也必须写成 `&amp;`。
- 属性引号安全:XML 属性、shell 引号、JSON 字符串包装之间没有互相打断。
- 结构合法:`<slide>` 下只放 `<style>`、`<data>`、`<note>`,文本都在 `<content>` 内。
- 图片路径正确:`<img src="@...">` 占位符由 `+create` 和 `+add-slide` 处理。

## Failure Order

遇到 `invalid param`、某一页创建失败、页面空白或布局错乱时,按顺序处理:

1. 记录 `xml_presentation_id`,不要假设失败代表什么都没创建。
2. 用 `slides +xml-get` 回读,确认是否已有部分页面写入。
3. 检查失败页是否含未转义字符:`Q&A -> Q&amp;A`,文本 `<` / `>` 写成 `&lt;` / `&gt;`,属性 URL `a=1&b=2 -> a=1&amp;b=2`。
4. 检查标签闭合、属性引号、`<content>` 结构,以及 `<slide>` 直接子元素。
5. 页面空白、溢出、重叠或越界时,按 [validation-xml.md](validation-xml.md) 运行 `xml_lint.py`;先修复所有 `error`,再对 `warning` 指向的页面和元素做截图复核。
6. 如果使用 `--slides '[...]'` 字面量,怀疑 shell 转义或截断时改用文件输入:`+create --slide @page-01.xml --slide @page-02.xml`。
7. 局部问题用 `+replace-slide` 块级修正;整页结构要改时用 `+delete-slide` 删旧页 + `+add-slide` 建新页。

## Symptom Fixes

| 看到的问题 | 处理方式 |
|-----------|----------|
| 文字被截断 / 看不全 | 增大 shape 的 `width` 或 `height`,或减少文本量 |
| 元素重叠 | 调整 `topLeftX` / `topLeftY`,拉开间距 |
| 页面大面积空白 | 回读确认内容是否写入;若内容存在,再缩小间距或增加主体元素 |
| 文字和背景色太接近 | 深色背景用浅色文字,浅色背景用深色文字 |
| 表格列宽不合理 | 调整 `colgroup` 中 `col` 的 `width` 值 |
| 图表没有显示 | 检查 `chartPlotArea` 和 `chartData` 是否都包含,`dim1` / `dim2` 数据数量是否匹配 |
| 图片被裁掉一部分 | `<img>` 的 `width` / `height` 是裁剪后尺寸;要整图显示就让 `width:height` 对齐原图比例 |
| 图片不显示 / `<img src>` 仍是 `@path` | `@` 占位符由 `+create` 和 `+add-slide` 替换 |
| 新插入的 `<img>` 挡住原有元素 | `slide.get` 读原页,对照已有块坐标挑空白位置;空间不够就在同一批 `--parts` 里先移动/缩小现有块再插图 |
| 渐变背景变成白色 | 渐变必须用 `rgba()` 格式 + 百分比停靠点,如 `linear-gradient(135deg,rgba(30,60,114,1) 0%,rgba(59,130,246,1) 100%)` |
| 整体风格不统一 | 封面页和结尾页用同一背景,内容页保持一致的配色和字号体系 |

## Common Errors

| 错误码 / 信号 | 含义 | 解决方案 |
|--------------|------|----------|
| 400 XML 格式错误 | XML 语法错误 | 检查标签闭合、属性引号、特殊字符转义 |
| 400 请求包装错误 | `--data` 未按 schema 包装 | 检查是否传入 `xml_presentation.content` 或 `slide.content` |
| 创建成功但页面空白 / 内容缺失 / 布局错乱 | 常见于 `--slides '[...]'` 字面量的 shell 转义或长参数传递问题 | 改用 `--slide @file`(每页一个文件)或 `--slides @deck.json`,并在创建后立即读取 XML 验证 |
| 403 权限不足 | scope 或文档权限不匹配 | 确认 scope 和文档权限;无权限时根据错误响应引导用户解决 |
| 404 演示文稿不存在 | `xml_presentation_id` 不正确或无权限 | 检查 token;wiki URL 需先解析真实 `obj_token` |
| 404 幻灯片不存在 | `slide_id` 不正确 | 重新读取 presentation 或 slide,确认最新 ID |
| 1061002 媒体上传 params error | slides 媒体上传参数不符合约定 | 用 `slides +media-upload`,不要手拼原生 `medias/upload_all`;slides 唯一可用 `parent_type` 是 `slide_file` |
| 1061004 forbidden | 当前用户对演示文稿无编辑权限 | 确认当前用户对目标 PPT 有编辑权限 |
| 3350001 | XML 非 well-formed、XML 结构不符合服务端要求,或 replace 片段问题 | 优先检查未转义字符;replace 场景再看 `block_id` 和 `<content/>` |
| 3350002 | `revision_id` 大于当前版本 | 用 `-1` 取当前版本,或重新用 `slides +xml-get` 取最新 `revision_id` |
| validation: unsafe file path | `--file` 给了绝对路径或上层路径 | `--file` 必须是 CWD 内相对路径;先 `cd` 到素材目录再执行 |

## Command-Specific References

- 图片上传、`@path` 占位符、`file_token`:见 [lark-slides-media-upload.md](../cli/lark-slides-media-upload.md) 和 [lark-slides-create.md](../cli/lark-slides-create.md)。
- 块级替换、`block_id`、3350001 replace 细节:见 [lark-slides-replace-slide.md](../cli/lark-slides-replace-slide.md)。
- 追加/插入单页、`--before-slide-id` 和 `--slide @file` 绕开转义:见 [lark-slides-add-slide.md](../cli/lark-slides-add-slide.md)。
references/workflow/slides-editing.md
# 编辑已有 PPT:读-改-写闭环

局部编辑走 **shortcut [`+replace-slide`](../cli/lark-slides-replace-slide.md)**(块级替换 / 插入),配合 `xml_presentation.slide.get` 读原页拿 `block_id`。整页重建走 **[`+update-slide`](../cli/lark-slides-update-slide.md)**,多页就每页各跑一次 —— 它原地覆盖并保留 `slide_id` 和页序;只有写进 `--content` 且带原 id 的元素才会保留元素 id,遗漏的元素会被删除。

> 生成 XML 前**必读** [xml-schema-quick-ref.md](../xml/xml-schema-quick-ref.md)。

## 决策树:block_replace vs block_insert

| 需求 | 推荐 action | 理由 |
|------|------------|------|
| 已知某块的 `block_id`,要换这块内容(改标题、换图、挪坐标) | `block_replace` | 精准替换,原子性好;`replacement` 根 `id` 由 CLI 自动注入为 `block_id` |
| 只加 1~N 个元素、不动现有布局 | `block_insert` | 新增不覆盖,可选 `insert_before_block_id` 指定位置 |
| 一次动多个元素(如:换标题 + 加图) | 单次 `--parts` 里拼多条 | 整批作为原子事务,任一失败整批不生效;`block_replace` 和 `block_insert` 可混用 |
| 整页版式重建、整页坐标重排、改页面背景、删若干元素 | `+update-slide`(每页一次) | 原地整页覆盖,`slide_id` 和页序不变;带原 `id` 的元素保留 id,不带 `id` 的作为新元素插入,遗漏的被删除 |

> **没有字段级 patch**:即便只想改一个 `shape` 的 `topLeftX`,也得把整个块的新 XML 写出来用 `block_replace`。这不是"微调",是块级重写。

## 最小读-改-写闭环

```bash
PRES_ID="xml_presentation_id_here"
SID="slide_id_here"

# 1. 读原页,从 XML 里挑出要改的块的 3 位 short id(如 bUn / bab)
lark-cli slides xml_presentation.slide get --as user \
  --params "{\"xml_presentation_id\":\"$PRES_ID\",\"slide_id\":\"$SID\"}"

# 2. 用 +replace-slide 直接改那个块(不需要搬原 XML)
lark-cli slides +replace-slide --as user \
  --presentation "$PRES_ID" --slide-id "$SID" \
  --parts '[{"action":"block_replace","block_id":"bUn","replacement":"<shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"120\"><content textType=\"title\"><p>新标题</p></content></shape>"}]'
```

`slide_id` / 页序不会变。`block_replace` 的 `replacement` 根元素 `id` 会自动注入为 `block_id`,用户手写 XML 时不需要自己加。

> **编写 `--parts` 时只使用标准字段**:`block_replace` 使用 `action` + `block_id` + `replacement`(XML 字符串),`block_insert` 使用 `action` + `insertion`(可选 `insert_before_block_id`)。收到 unknown field 报错时应按上述结构修改字段名,而不是修改字段值。

## `revision_id` 参数

`--revision-id` 默认 `-1`,表示基于当前最新版执行。传具体版本号时,服务端以该版本为 base 应用变更:

```bash
# 读时拿当前 revision_id
REV=$(lark-cli slides xml_presentation.slide get --as user \
  --params "{\"xml_presentation_id\":\"$PRES_ID\",\"slide_id\":\"$SID\"}" \
  --jq '.data.revision_id')

# 写时传该版本号,服务端以此为 base
lark-cli slides +replace-slide --as user \
  --presentation "$PRES_ID" --slide-id "$SID" --revision-id "$REV" \
  --parts '[{"action":"block_replace","block_id":"bUn","replacement":"<shape type=\"rect\" topLeftX=\"100\" topLeftY=\"100\" width=\"200\" height=\"100\"/>"}]'
```

注意:传不存在的版本号(超过当前 revision)会返回 3350002 not found;不确定时用 `-1` 即可。

## `--tid` 事务锁

跨请求的并发事务 ID,多人协作长事务才用得上。**单人单次调用留空**即可。

## 两种 action 详解

### block_replace — 整块替换

适合"已知块 ID,要换这块整体内容"的场景。`replacement` 根元素的 `id="<block_id>"` 由 CLI 自动注入(用户手写的 XML 如果没带 `id` 直接省略即可;如果带了错的会被覆盖为正确值)。

```bash
lark-cli slides +replace-slide --as user \
  --presentation "$PRES_ID" --slide-id "$SID" \
  --parts '[{"action":"block_replace","block_id":"bab","replacement":"<shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"120\"><content textType=\"title\"><p>新标题</p></content></shape>"}]'
```

字段说明:

| 字段 | 必填 | 说明 |
|------|------|------|
| `action` | 是 | 固定为 `block_replace` |
| `block_id` | 是 | 目标块的 3 位 short element ID(从 `slide.get` 返回的 XML 里读)|
| `replacement` | 是 | 新 XML 片段;根元素 `id` 会被 CLI 自动注入为 `block_id` |

### block_insert — 整块插入

适合"只想加一个元素,不动现有元素"的场景(典型:给已有页加图)。

```bash
lark-cli slides +replace-slide --as user \
  --presentation "$PRES_ID" --slide-id "$SID" \
  --parts "$(jq -n --arg token "$FILE_TOKEN" \
    '[{action:"block_insert",insertion:("<img src=\""+$token+"\" topLeftX=\"500\" topLeftY=\"100\" width=\"200\" height=\"150\"/>"),insert_before_block_id:"baa"}]')"
```

字段说明:

| 字段 | 必填 | 说明 |
|------|------|------|
| `action` | 是 | 固定为 `block_insert` |
| `insertion` | 是 | 要插入的完整 XML 片段 |
| `insert_before_block_id` | 否 | 插到这个块之前;省略(不提供此字段)则追加到页面末尾 |

> **`<img>` 必须用 `file_token`**,不能用外链 URL——先 `slides +media-upload --file ./pic.png --presentation $PRES_ID` 拿 token。

### 批量 parts

一次 `--parts` 最多 200 条,按数组顺序串行执行。`block_replace` 和 `block_insert` 可以在同一批次混用。举例:一次性把标题块替换、然后在末尾追加一个装饰图。

```bash
lark-cli slides +replace-slide --as user \
  --presentation "$PRES_ID" --slide-id "$SID" \
  --parts '[{"action":"block_replace","block_id":"bab","replacement":"<shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"800\" height=\"120\"><content textType=\"title\"><p>新标题</p></content></shape>"},{"action":"block_insert","insertion":"<img src=\"<file_token>\" topLeftX=\"700\" topLeftY=\"400\" width=\"180\" height=\"100\"/>"}]'
```

整批作为原子事务:任一条失败整批不生效。失败时后端通常返回 3350001;若响应中带 `failed_part_index` / `failed_reason` 字段,shortcut 会原样透传。

## 大 --parts 用 jq 或 stdin 组装

`--parts` 支持 `@file`(读文件)和 `-`(stdin)作为值来源,适合批量 XML 场景:

```bash
# 从文件读
lark-cli slides +replace-slide --as user --presentation "$PRES_ID" --slide-id "$SID" \
  --parts @parts.json

# 从 stdin 读
cat parts.json | lark-cli slides +replace-slide --as user --presentation "$PRES_ID" --slide-id "$SID" \
  --parts -
```

## 错误排查

| 现象 | 原因 | 对策 |
|------|------|------|
| 3350001,hint 含 "block_id not found" | `parts[i].block_id` 在当前页不存在 | 重新 `slide.get` 拿最新 XML,按里面的 short ID 再填 |
| 3350002 not found | `--revision-id` 传了不存在的版本号 | 用 `-1` 或实际存在的 `revision_id` |
| `<img>` 不显示 / 显示破图 | `src` 写了外链 URL | 换成通过 `+media-upload` 拿到的 `file_token` |
| 3350001(block_replace 返回) | 正常情况下 CLI 已自动注入 `id` 和 `<content/>`;如果仍报错,确认 `block_id` 在当前页存在(重新 `slide.get`),检查 XML 结构是否合法;坐标是否超出 960×540 范围 | — |

## 相关文档

- [lark-slides-replace-slide.md](../cli/lark-slides-replace-slide.md) — +replace-slide shortcut 参数详情
- [lark-slides-update-slide.md](../cli/lark-slides-update-slide.md) — +update-slide shortcut 参数详情(整页覆盖)
- [lark-slides-xml-presentation-slide-get.md](../cli/lark-slides-xml-presentation-slide-get.md) — slide.get 参考(拿 `block_id` / `revision_id`)
- [lark-slides-xml-presentation-slide-replace.md](../cli/lark-slides-xml-presentation-slide-replace.md) — 底层 replace API 参考(一般直接用 shortcut 即可)
- [lark-slides-media-upload.md](../cli/lark-slides-media-upload.md) — 上传图片拿 file_token
- [xml-schema-quick-ref.md](../xml/xml-schema-quick-ref.md) — XML 元素和属性速查
references/workflow/template-editing.md
# PPT Template Rewrite Principles

核心原则:模板不是风格参考,而是必须沿用的编辑底稿。

## Import First

如果用户提供的模板是 PPTX 格式,先把模板导入成 Lark Slides。后续写入目标是导入后的 Slides,不是新建一个脱离模板的 deck,也不是先在本地重画 PPTX 再导入。

直接使用以下命令,不需要先加载 `lark-drive` Skill:

```bash
lark-cli drive +import --as user --file "<template.pptx>" --type slides --json
```

可选参数:用 `--name "<title>"` 指定导入后的 Slides 标题;用 `--folder-token <FOLDER_TOKEN>` 指定目标文件夹。若返回 `ready=false` / `timed_out=true`,直接执行返回值里的 `next_command`;等价形式是:

```bash
lark-cli drive +task_result --scenario import --ticket <TICKET>
```

## Read Before Editing

导入后必须阅读 Slides 内容,理解每页的真实版式、字体、层级、图片、图表、shape、表格和文本容器。阅读结果是后续编辑的事实来源。

阅读页面时至少判断:

- 该页原本承担的角色,例如封面、章节页、目录、流程、对比、数据、总结。
- 该页的主要版式结构,例如图文关系、箭头、时间线、节点、表格、图表、左右对照、背景图或产品图。
- 哪些文本框、shape 标签、表格单元格或图表标签承载内容。
- 原页面的字体、字号、颜色、对齐、层级和留白关系。

## Edit The Imported Slides Directly

理解页面后,直接在导入后的 Slides 上编辑。允许的操作包括:

- 填写、替换、凝练或删除文字。
- 替换或补充图片。
- 更新图表、表格、数字标签或节点标签里的内容。
- 按需复制、删除或重排模板页。
- 在源页面没有合适承载位置时,做局部、小范围新增元素。

新增元素只能补足内容缺口,不能成为新的主版式。页面主体仍应由模板原有版式承载。

## Preserve Design

编辑必须严格沿用原版式和字体,只改内容,不做设计。

默认保留:

- 页面布局、视觉层级、留白和对齐关系。
- 原字体、字号体系、颜色、文本框位置和 shape 顺序。
- 背景图、图片、logo、图表、表格、装饰形状、线条、图标和页面结构。
- 模板中不同页型之间的差异。

不要把模板页改造成统一的通用卡片、空白板式布局、标题栏、三栏、2x2 卡片或大面积遮罩。不要把模板当作背景图后另起一套设计系统。

## Content Only

内容必须优先进入原页面已有的文本框、shape 标签、节点、表格单元格、图表标签或注释容器。

如果原容器空间不足,优先:

- 凝练文字。
- 降低字号但保持原字体体系。
- 拆分到页面已有的邻近容器。
- 使用模板已有的注释、标签或补充说明区域。
- 复制同页或同模板中的原生容器样式做局部补充。

不要为了容纳长文案而重画页面主体结构。不要用新增大卡片遮住原图表、箭头、图片、背景或关键 shape。

## Readback And Tune

完成编辑后必须回读结果,并逐页微调。

回读时重点检查:

- 文字是否溢出、截断、压线或超出容器。
- 文本是否遮挡图片、图表、shape、箭头、节点或其他文字。
- shape 顺序是否导致内容被覆盖或遮住。
- 新内容是否仍然落在模板原有版式中,而不是覆盖模板结构。
- 字体、字号、颜色、对齐和层级是否仍贴近原页。

发现文字溢出时,优先凝练文字或缩减字号。发现遮挡时,调整 shape 顺序、局部位置或复用原有空白区域解决。只有在这些方法都不能满足内容表达时,才做局部新增或删除。

完成标准是“原模板的版式、字体和视觉结构仍清晰存在,内容已经被准确替换,并且回读后没有溢出和遮挡”。
references/workflow/validation-xml.md
# Validation Checklist

创建、大幅改写演示文稿或整页写回后,必须做一次显式验证。目标是发现空白页、XML 损坏、内容截断、明显溢出、弱视觉层级和未验证输出。

小型已有页编辑也要做对应范围的验证:至少读取被改页面或全文 XML,确认目标元素已更新且未破坏周边结构。

## Required Flow

1. 记录创建或编辑返回的 `xml_presentation_id`,以及已知的 `slide_id` / `revision_id`。
2. 用 `slides +xml-get` 回读全文 XML 到本地文件。
3. 检查实际页数是否符合计划或用户要求。
4. 检查每页 `<data>` 内是否有预期主要元素。
5. 检查没有明显空白页、破损页、缺失标题或缺失主视觉。
6. 检查页面不是全部退化为标题加 bullet list。
7. 检查视觉层级:标题、主视觉、支撑信息三者可区分。
8. 检查明显溢出和布局风险:重叠、越界、底部拥挤、长文本框。
9. 在最终回复中给出简短验证记录。

回读命令:

```bash
lark-cli slides +xml-get --as user \
  --presentation "YOUR_ID" \
  --output .lark-slides/plan/<deck-or-task-id>/readback.xml \
  --json
```

## Automated XML Layout Lint

`slides +xml-get` 保存 XML 后,只运行统一版式准出入口。先取得当前已加载 `lark-slides/SKILL.md` 的父目录,记为 `<lark-slides-skill-dir>`;不要猜测全局安装路径。

```bash
python3 "<lark-slides-skill-dir>/scripts/xml_lint.py" --input <presentation.xml>
```

它一次检查 XML/SXSD 合法性、元素越界、文本重叠、空白页、文本高度风险、整页内容稀疏和大卡片内容覆盖率。大卡片自身 `<content>` 的估算文本面积与卡片内平级元素一起参与覆盖率并集计算。

准出规则:

- `summary.error_count > 0` 或 `summary.release_ready == false`:阻断创建、替换或交付,必须先修复。
- `summary.warning_count > 0`:静态检查不直接阻断,但 `summary.screenshot_review_required == true`,必须复核对应页面截图。
- `slides[].status` 为 `blocked`、`needs_screenshot_review` 或 `passed`,可直接决定逐页后续动作。
- CLI 在存在 `error` 时退出码为 1;只有 `warning` 时仍输出 JSON 并退出 0,供截图复核链路继续执行。

每条 `error` / `warning` 都包含:

- `element_ids`:相关 XML 元素 ID;
- `rule`:规则 ID、名称、阈值和比较关系;
- `measurement`:越界量、交叠面积、覆盖率等实测值;
- `related_objects`:相关对象的类型、坐标框,以及指向当前输入 XML 节点的 XPath-like `xml_path`;
- `target`、`message`、`hint`:页码、语义说明和处理建议。

当 `sparse_container_content.measurement.content_coverage_ratio < rule.threshold` 时,需要结合同页截图判断留白是否有意设计;不要仅凭 warning 自动扩充内容。

常见 code 的处理方向:

| code | 含义 | 处理方式 |
|------|------|----------|
| `xml_not_well_formed` | XML 语法错误或文本未转义 | 修复标签闭合、属性引号、`&` / `<` / `>` 转义 |
| `sml_prefixed_tag` | SML 元素使用了命名空间前缀,如 `<ns0:slide>` 或 `<sml:shape>` | 使用 `<slide xmlns="https://www.larkoffice.com/sml/2.0">` 的默认命名空间,或使用无前缀标签 |
| `sxsd_unsupported_tag` | 使用了 SXSD 不支持的标签 | 按 lint `hint` 替换为受支持标签;常见如 `textbox -> <shape type="text">`、`image -> <img>` |
| `sxsd_unsupported_attr` | 支持的标签上使用了不支持的属性 | 按 lint `hint` 改为支持的属性;常见如 `x -> topLeftX`、`fontColor -> color` |
| `iconpark_unsupported_icon_type` | `<icon>` 使用了 `iconpark-index.json` 中不存在的 `iconType` | 按 lint `hint` 改为名单内的 `iconType`,或先用 `scripts/iconpark_tool.py` 搜索 |
| `icon_missing_fill_color` | 视觉规范要求 `<icon>` 设置 `<fill><fillColor color="..."/></fill>`,避免图标不可见 | 给 `<icon>` 添加显式非透明填充色,例如 `rgba(37, 99, 235, 1)` |
| `icon_transparent_fill_color` | `<icon>` 的 `fillColor` 是透明色,不满足视觉可见性要求 | 改成与背景有足够对比的非透明颜色 |
| `bbox_overlap` | 文本元素的估算绘制区域明显重叠 | 拉开文本坐标、缩小文本框/字号,或改成明确的分栏/分组结构 |
| `*_out_of_canvas` | 元素边界超出页面画布 | 根据 `measurement.overflow` 移回画布或缩小尺寸 |
| `blank_slide` | 页面没有画布内可见内容 | 补充主体内容;仅有空背景或空形状不能准出 |
| `sparse_container_content` | 大卡片内容覆盖率低于阈值 | 按 `related_objects[].xml_path` 定位卡片;`element_id` 存在且唯一时也可按 ID 定位,再结合截图判断是否补充或放大内容 |
| `sparse_slide_content` | 全页有效内容覆盖率偏低 | 复核截图,确认是否为有意留白 |

## Screenshot QA

获取页面截图后,必须做视觉验收;不要只凭 XML 回读或静态 lint 结论声称截图验收通过。验收时假设页面存在问题,主动寻找并报告所有风险,包括轻微问题。

```text
请逐页目视检查这些幻灯片截图。先假设存在问题,并尽量找出它们。

重点检查:
- 元素重叠:文字与形状、图片或图表互相遮挡,线条穿过文字,卡片或标签堆叠。
- 文本溢出或被裁切:靠近页面边缘、文本框边界或卡片边界处被截断。
- 装饰元素位置错误:分割线、强调线或标签底板按单行文字布置,但标题或正文换行后压住文字或距离异常。
- 来源标注、页脚或页码与上方内容碰撞。
- 元素距离过近:相邻元素间距明显不足,卡片或分区几乎贴在一起;按 960x540 画布估算,小于约 15 px 的间隔通常要标记。
- 间距不均:局部留白过大,另一处过于拥挤。
- 页面边距不足:主体内容贴近幻灯片边缘;按 960x540 画布估算,小于约 30 px 的外边距通常要标记。
- 列、卡片、图标或同类元素没有稳定对齐。
- 图片或图表渲染异常:空白、变形、低清、关键内容不可读或预期图形缺失。
- 文本对比度不足,例如浅灰文字放在米色或浅色背景上。
- 图标对比度不足,例如深色图标放在深色背景上,且没有浅色圆形或底板承托。
- 文本框过窄,导致不必要的频繁换行。
- 残留占位符、模板默认文字或未替换内容。

对每一页分别列出发现的问题或可疑区域,即使只是轻微问题也要记录。

报告所有发现的问题,包括轻微问题。
```

必须根据问题严重度决定是否修复:空白页、破图、文字遮挡、明显裁切、低对比不可读、占位符残留等必须先修复再交付;轻微间距或对齐问题如果不修复,最终验证记录要说明已知风险。

## Page Count And Structure

- 实际页数必须等于用户要求或 `slide_plan.json` 的页数。
- 如果创建过程部分失败,先记录已创建的 `xml_presentation_id`,再回读确认哪些页已写入。
- 每页都应包含 `<data>`,且 `<data>` 内至少有一个非背景主体元素。
- 封面、章节页、总结页可以文字较少,但不能只有空背景。
- 技术解释页、对比页、流程页、架构页必须有匹配的结构元素,例如分组框、连线、时间轴、表格或图形化区域。

## Expected Elements

按 `slide_plan.json` 和用户要求逐页核对:

- 标题或主结论存在,并能对应 `key_message`。
- `layout_type` 对应的主要结构已生成。
- `visual_focus` 是页面中最醒目或最大的信息区域之一。
- `text_density` 影响了文本量,没有用长 bullet 框替代规划。
- `asset_need` 有真实素材时已放入正确区域;没有真实素材时,`fallback_if_missing` 已用 XML 形状、线条、标签、表格或图表兜底。

如果用户指定了关键页,例如“架构解释”“Self-Attention 机制解释”“对比或演进视角”“总结页”,最终验证记录必须逐项说明这些页已存在。

## Blank Or Broken Page Signals

把下面情况视为需要修复后再交付:

- `<data/>` 为空,或只有背景、装饰线、空 `<content/>`。
- 关键文本没有出现在回读 XML 中。
- 图片仍是 `@./path`,或 `<img src>` 是 http(s) 外链。
- 页面依赖的图片区域为空,且没有 fallback visual。
- 返回 XML 缺页、页序明显错误,或某页内容被 shell 截断。
- 大量形状坐标完全相同,导致主体内容重叠。
- 渐变背景回退成空白或白底,导致文字不可读。

## Layout And Overflow Risk

优先修复这些明显风险:

- 正文或标签框高度不足,文本很可能被截断。
- 多个主体元素在同一区域重叠,而不是有意叠加背景。
- 重要内容越过画布边界,或贴近底部超过 `y=500`。
- 高密度页使用单个长 bullet list,没有分栏、表格或分组。
- 标题、主视觉、正文的字号和颜色差异太弱,视觉层级不清。
- 所有内容页都是同一套标题加 bullets 坐标。

## Verification Record

最终回复必须包含简短验证记录,建议格式:

```text
验证记录:
- 回读:已执行 slides +xml-get,实际页数 N / 预期 N。
- 关键页:架构解释 / Self-Attention / 对比或演进 / 总结页均存在。
- 结构:检查了主要 shape/img/table/chart 元素,无明显空白页或破损页。
- 布局:检查了标题层级、主视觉、重叠/越界/文本溢出风险。
```

不要声称完成了人工视觉验收,除非确实打开或获取了可视化结果。仅从 XML 静态检查得出的结论,应表述为“静态检查未发现明显问题”。
references/xml-schema-quick-ref.md
# XML Schema 快速参考(兼容入口)

本文档已迁移至 [`xml/xml-schema-quick-ref.md`](xml/xml-schema-quick-ref.md)。

此文件仅保留旧路径兼容性;后续引用请使用新路径。
references/xml/iconpark.md
# IconPark 图标

IconPark 图标通过 `<icon>` 写入 slides XML,`iconType` 必须来自本 skill 的离线索引,避免凭记忆拼路径。

## 机器优先流程

```bash
python3 skills/lark-slides/scripts/iconpark_tool.py search --query "增长趋势" --limit 8
python3 skills/lark-slides/scripts/iconpark_tool.py resolve --name chart-line
python3 skills/lark-slides/scripts/iconpark_tool.py list-categories
```

`search` 返回 JSON 数组,每项包含 `iconType`、`category`、`name`、`tags`、`score`。直接把选中的 `iconType` 写入 XML,并为图标指定可见颜色:

```xml
<icon iconType="iconpark/Charts/chart-line.svg" topLeftX="80" topLeftY="120" width="32" height="32">
  <fill>
    <fillColor color="rgba(37, 99, 235, 1)"/>
  </fill>
</icon>
```

## 使用规则

- 默认先检索:语义图标需求必须先用 `iconpark_tool.py search --limit 8` 或 `--limit 10`,让 agent 从候选里结合版面语义二次判断;不要阅读全文索引,也不要编造不存在的 `iconType`。
- 图标用于概念提示、步骤、状态、指标、角色和导航;不要用无关装饰图标填充版面。
- 常用尺寸:行内状态图标 16-24px,卡片标题图标 28-40px,主视觉图标 56-96px。
- 图标必须填充颜色并和背景有足够对比;深色背景优先放在浅色圆形/方形底上,或使用 `rgba(255, 255, 255, 1)` 作为图标填充色。
- 查不到合适图标时,从高频示例里选择替代图标(随机选择,不要千篇一律),不留空图标位。

## 高频示例

| 语义 | iconType |
|---|---|
| 设置/配置 | `iconpark/Base/setting.svg` |
| 目标 | `iconpark/Base/aiming.svg` |
| 增长趋势 | `iconpark/Charts/positive-dynamics.svg` |
| 折线趋势 | `iconpark/Charts/chart-line.svg` |
| 占比 | `iconpark/Charts/chart-proportion.svg` |
| 数据看板 | `iconpark/Charts/data-screen.svg` |
| 成功 | `iconpark/Character/check-one.svg` |
| 失败/风险 | `iconpark/Character/close-one.svg` |
| 团队/用户 | `iconpark/Peoples/peoples.svg` |
| 安全防护 | `iconpark/Safe/protect.svg` |
| 全球/市场 | `iconpark/Travel/world.svg` |
| 邮件/联系 | `iconpark/Office/envelope-one.svg` |
references/xml/slides_chart_demo.xml
<presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
  <title>原生图表 Chart Demo</title>
  <theme>
    <textStyles>
      <headline fontColor="rgba(31, 35, 41, 1)"/>
      <sub-headline fontColor="rgba(31, 35, 41, 1)"/>
      <body fontColor="rgba(31, 35, 41, 1)"/>
      <caption fontColor="rgba(155, 158, 162, 1)" fontSize="14"/>
    </textStyles>
  </theme>
  <slide>
    <style>
      <fill>
        <fillColor color="rgba(248, 249, 251, 1)"/>
      </fill>
    </style>
    <data>
      <shape width="960" height="72" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(15, 30, 58, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="8" height="72" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(240, 129, 54, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="700" height="30" topLeftX="32" topLeftY="14" type="text">
        <content textType="headline" fontSize="22" fontFamily="思源黑体" color="rgba(255, 255, 255, 1)" bold="true">
          <p>柱状图 · Column Chart</p>
        </content>
      </shape>
      <shape width="700" height="20" topLeftX="32" topLeftY="46" type="text">
        <content textType="caption" fontSize="12" fontFamily="思源黑体" color="rgba(180, 192, 210, 1)">
          <p>季度出货量对比 · GROUPED / STACKED / 100% STACKED</p>
        </content>
      </shape>
      <shape width="160" height="22" topLeftX="770" topLeftY="26" type="text">
        <content textType="caption" fontSize="11" fontFamily="思源黑体" color="rgba(220, 228, 240, 1)" textAlign="right">
          <p>SECTION 01 / COLUMN</p>
        </content>
      </shape>
      <shape width="290" height="400" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(255, 255, 255, 1)"/>
        </fill>
        <border color="rgba(226, 232, 240, 1)" width="1"/>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="290" height="32" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(240, 244, 249, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="270" height="22" topLeftX="44" topLeftY="98" type="text">
        <content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
          <p>① 分组柱状图 · Grouped</p>
        </content>
      </shape>
      <chart width="270" height="350" topLeftX="42" topLeftY="132">
        <chartPlotArea>
          <chartPlot type="column">
            <chartExtra/>
          </chartPlot>
          <chartAxes>
            <chartAxis type="x">
              <chartLabel fontSize="9"/>
            </chartAxis>
            <chartAxis type="y" position="left">
              <chartGridLine color="rgb(226, 232, 240)"/>
              <chartLabel fontSize="9"/>
            </chartAxis>
          </chartAxes>
        </chartPlotArea>
        <chartLegend position="bottom" fontSize="10"/>
        <chartData>
          <dim1>
            <chartField name="季度">2024Q1,2024Q2,2024Q3,2024Q4</chartField>
          </dim1>
          <dim2>
            <chartField name="Apple">52,48,55,68</chartField>
            <chartField name="Samsung">60,58,63,72</chartField>
          </dim2>
        </chartData>
        <chartTitle fontSize="12" color="rgba(15, 30, 58, 1)" bold="true">智能手机季度出货(百万台)</chartTitle>
        <chartStyle>
          <chartBackground color="rgba(0, 0, 0, 0)"/>
          <chartBorder color="rgb(222, 224, 227)" width="0"/>
          <chartColorTheme>
            <color value="rgb(28, 71, 120)"/>
            <color value="rgb(240, 129, 54)"/>
          </chartColorTheme>
        </chartStyle>
      </chart>
      <shape width="290" height="400" topLeftX="335" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(255, 255, 255, 1)"/>
        </fill>
        <border color="rgba(226, 232, 240, 1)" width="1"/>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="290" height="32" topLeftX="335" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(240, 244, 249, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="270" height="22" topLeftX="347" topLeftY="98" type="text">
        <content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
          <p>② 堆叠柱状图 · Stacked</p>
        </content>
      </shape>
      <chart width="270" height="350" topLeftX="345" topLeftY="132">
        <chartPlotArea>
          <chartPlot type="column">
            <chartExtra>
              <chartStack/>
            </chartExtra>
          </chartPlot>
          <chartAxes>
            <chartAxis type="x">
              <chartLabel fontSize="9"/>
            </chartAxis>
            <chartAxis type="y" position="left">
              <chartGridLine color="rgb(226, 232, 240)"/>
              <chartLabel fontSize="9"/>
            </chartAxis>
          </chartAxes>
        </chartPlotArea>
        <chartLegend position="bottom" fontSize="10"/>
        <chartData>
          <dim1>
            <chartField name="季度">2024Q1,2024Q2,2024Q3,2024Q4</chartField>
          </dim1>
          <dim2>
            <chartField name="硬件">80,85,92,105</chartField>
            <chartField name="服务">45,52,58,66</chartField>
          </dim2>
        </chartData>
        <chartTitle fontSize="12" color="rgba(15, 30, 58, 1)" bold="true">收入结构(亿美元)</chartTitle>
        <chartStyle>
          <chartBackground color="rgba(0, 0, 0, 0)"/>
          <chartBorder color="rgb(222, 224, 227)" width="0"/>
          <chartColorTheme>
            <color value="rgb(28, 71, 120)"/>
            <color value="rgb(240, 129, 54)"/>
          </chartColorTheme>
        </chartStyle>
      </chart>
      <shape width="290" height="400" topLeftX="638" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(255, 255, 255, 1)"/>
        </fill>
        <border color="rgba(226, 232, 240, 1)" width="1"/>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="290" height="32" topLeftX="638" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(240, 244, 249, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="270" height="22" topLeftX="650" topLeftY="98" type="text">
        <content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
          <p>③ 百分比堆叠 · 100% Stacked</p>
        </content>
      </shape>
      <chart width="270" height="350" topLeftX="648" topLeftY="132">
        <chartPlotArea>
          <chartPlot type="column">
            <chartExtra>
              <chartStack percentage="true"/>
            </chartExtra>
            <chartSeriesList>
              <chartSeries index="1">
                <chartTooltip format="0%"/>
              </chartSeries>
              <chartSeries index="2">
                <chartTooltip format="0%"/>
              </chartSeries>
            </chartSeriesList>
          </chartPlot>
          <chartAxes>
            <chartAxis type="x">
              <chartLabel fontSize="9"/>
            </chartAxis>
            <chartAxis type="y" position="left">
              <chartGridLine color="rgb(226, 232, 240)"/>
              <chartLabel fontSize="9" format="0%"/>
            </chartAxis>
          </chartAxes>
        </chartPlotArea>
        <chartLegend position="bottom" fontSize="10"/>
        <chartData>
          <dim1>
            <chartField name="区域">北美,欧洲,亚太,拉美</chartField>
          </dim1>
          <dim2>
            <chartField name="Apple">0.55,0.42,0.3,0.28</chartField>
            <chartField name="Samsung">0.45,0.58,0.7,0.72</chartField>
          </dim2>
        </chartData>
        <chartTitle fontSize="12" color="rgba(15, 30, 58, 1)" bold="true">区域市场份额构成</chartTitle>
        <chartStyle>
          <chartBackground color="rgba(0, 0, 0, 0)"/>
          <chartBorder color="rgb(222, 224, 227)" width="0"/>
          <chartColorTheme>
            <color value="rgb(28, 71, 120)"/>
            <color value="rgb(240, 129, 54)"/>
          </chartColorTheme>
        </chartStyle>
        <chartTooltip format="0%"/>
      </chart>
      <line startX="32" startY="506" endX="928.0005580355405" endY="506">
        <border color="rgba(226, 232, 240, 1)" width="1"/>
      </line>
      <shape width="500" height="18" topLeftX="32" topLeftY="512" type="text">
        <content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)">
          <p>Source: Consulting Insights Research · 数据仅用于示意</p>
        </content>
      </shape>
      <shape width="128" height="18" topLeftX="800" topLeftY="512" type="text">
        <content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)" textAlign="right">
          <p>03 / 12</p>
        </content>
      </shape>
    </data>
    <note>
      <content/>
    </note>
  </slide>
  <slide>
    <style>
      <fill>
        <fillColor color="rgba(248, 249, 251, 1)"/>
      </fill>
    </style>
    <data>
      <shape width="960" height="72" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(15, 30, 58, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="8" height="72" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(240, 129, 54, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="700" height="30" topLeftX="32" topLeftY="14" type="text">
        <content textType="headline" fontSize="22" fontFamily="思源黑体" color="rgba(255, 255, 255, 1)" bold="true">
          <p>折线图 · Line Chart</p>
        </content>
      </shape>
      <shape width="700" height="20" topLeftX="32" topLeftY="46" type="text">
        <content textType="caption" fontSize="12" fontFamily="思源黑体" color="rgba(180, 192, 210, 1)">
          <p>用户规模趋势 · STRAIGHT / SMOOTH / STEP</p>
        </content>
      </shape>
      <shape width="160" height="22" topLeftX="770" topLeftY="26" type="text">
        <content textType="caption" fontSize="11" fontFamily="思源黑体" color="rgba(220, 228, 240, 1)" textAlign="right">
          <p>SECTION 02 / LINE</p>
        </content>
      </shape>
      <shape width="290" height="400" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(255, 255, 255, 1)"/>
        </fill>
        <border color="rgba(226, 232, 240, 1)" width="1"/>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="290" height="32" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(240, 244, 249, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="270" height="22" topLeftX="44" topLeftY="98" type="text">
        <content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
          <p>① 直线折线图 · Straight</p>
        </content>
      </shape>
      <chart width="270" height="350" topLeftX="42" topLeftY="132">
        <chartPlotArea>
          <chartPlot type="line">
            <chartExtra/>
          </chartPlot>
          <chartAxes>
            <chartAxis type="x">
              <chartLabel fontSize="9"/>
            </chartAxis>
            <chartAxis type="y" position="left">
              <chartGridLine color="rgb(226, 232, 240)"/>
              <chartLabel fontSize="9"/>
            </chartAxis>
          </chartAxes>
        </chartPlotArea>
        <chartLegend position="bottom" fontSize="10"/>
        <chartData>
          <dim1>
            <chartField name="月份">1月,2月,3月,4月,5月,6月</chartField>
          </dim1>
          <dim2>
            <chartField name="iOS">120,135,128,152,168,180</chartField>
            <chartField name="Android">200,215,232,228,250,265</chartField>
          </dim2>
        </chartData>
        <chartTitle fontSize="12" color="rgba(15, 30, 58, 1)" bold="true">MAU 月活跃用户(百万)</chartTitle>
        <chartStyle>
          <chartBackground color="rgba(0, 0, 0, 0)"/>
          <chartBorder color="rgb(222, 224, 227)" width="0"/>
          <chartColorTheme>
            <color value="rgb(28, 71, 120)"/>
            <color value="rgb(240, 129, 54)"/>
          </chartColorTheme>
        </chartStyle>
      </chart>
      <shape width="290" height="400" topLeftX="335" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(255, 255, 255, 1)"/>
        </fill>
        <border color="rgba(226, 232, 240, 1)" width="1"/>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="290" height="32" topLeftX="335" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(240, 244, 249, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="270" height="22" topLeftX="347" topLeftY="98" type="text">
        <content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
          <p>② 平滑折线图 · Smooth</p>
        </content>
      </shape>
      <chart width="270" height="350" topLeftX="345" topLeftY="132">
        <chartPlotArea>
          <chartPlot type="line">
            <chartExtra>
              <chartSmooth/>
            </chartExtra>
          </chartPlot>
          <chartAxes>
            <chartAxis type="x">
              <chartLabel fontSize="9"/>
            </chartAxis>
            <chartAxis type="y" position="left">
              <chartGridLine color="rgb(226, 232, 240)"/>
              <chartLabel fontSize="9"/>
            </chartAxis>
          </chartAxes>
        </chartPlotArea>
        <chartLegend position="bottom" fontSize="10"/>
        <chartData>
          <dim1>
            <chartField name="月份">1月,2月,3月,4月,5月,6月</chartField>
          </dim1>
          <dim2>
            <chartField name="iOS">42,58,55,72,88,96</chartField>
            <chartField name="Android">78,82,96,102,118,132</chartField>
          </dim2>
        </chartData>
        <chartTitle fontSize="12" color="rgba(15, 30, 58, 1)" bold="true">DAU 日活跃用户(百万)</chartTitle>
        <chartStyle>
          <chartBackground color="rgba(0, 0, 0, 0)"/>
          <chartBorder color="rgb(222, 224, 227)" width="0"/>
          <chartColorTheme>
            <color value="rgb(28, 71, 120)"/>
            <color value="rgb(240, 129, 54)"/>
          </chartColorTheme>
        </chartStyle>
      </chart>
      <shape width="290" height="400" topLeftX="638" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(255, 255, 255, 1)"/>
        </fill>
        <border color="rgba(226, 232, 240, 1)" width="1"/>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="290" height="32" topLeftX="638" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(240, 244, 249, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="270" height="22" topLeftX="650" topLeftY="98" type="text">
        <content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
          <p>③ 阶梯折线图 · Step</p>
        </content>
      </shape>
      <chart width="270" height="350" topLeftX="648" topLeftY="132">
        <chartPlotArea>
          <chartPlot type="line">
            <chartExtra>
              <chartStep/>
            </chartExtra>
          </chartPlot>
          <chartAxes>
            <chartAxis type="x">
              <chartLabel fontSize="9"/>
            </chartAxis>
            <chartAxis type="y" position="left">
              <chartGridLine color="rgb(226, 232, 240)"/>
              <chartLabel fontSize="9"/>
            </chartAxis>
          </chartAxes>
        </chartPlotArea>
        <chartLegend position="bottom" fontSize="10"/>
        <chartData>
          <dim1>
            <chartField name="月份">1月,2月,3月,4月,5月,6月</chartField>
          </dim1>
          <dim2>
            <chartField name="Pro 版">999,999,899,899,799,799</chartField>
            <chartField name="标准版">699,699,649,599,599,549</chartField>
          </dim2>
        </chartData>
        <chartTitle fontSize="12" color="rgba(15, 30, 58, 1)" bold="true">定价调整轨迹(美元)</chartTitle>
        <chartStyle>
          <chartBackground color="rgba(0, 0, 0, 0)"/>
          <chartBorder color="rgb(222, 224, 227)" width="0"/>
          <chartColorTheme>
            <color value="rgb(28, 71, 120)"/>
            <color value="rgb(240, 129, 54)"/>
          </chartColorTheme>
        </chartStyle>
      </chart>
      <line startX="32" startY="506" endX="928.0005580355405" endY="506">
        <border color="rgba(226, 232, 240, 1)" width="1"/>
      </line>
      <shape width="500" height="18" topLeftX="32" topLeftY="512" type="text">
        <content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)">
          <p>Source: Consulting Insights Research · 数据仅用于示意</p>
        </content>
      </shape>
      <shape width="128" height="18" topLeftX="800" topLeftY="512" type="text">
        <content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)" textAlign="right">
          <p>04 / 12</p>
        </content>
      </shape>
    </data>
    <note>
      <content/>
    </note>
  </slide>
  <slide>
    <style>
      <fill>
        <fillColor color="rgba(248, 249, 251, 1)"/>
      </fill>
    </style>
    <data>
      <shape width="960" height="72" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(15, 30, 58, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="8" height="72" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(240, 129, 54, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="700" height="30" topLeftX="32" topLeftY="14" type="text">
        <content textType="headline" fontSize="22" fontFamily="思源黑体" color="rgba(255, 255, 255, 1)" bold="true">
          <p>饼图 · Pie Chart</p>
        </content>
      </shape>
      <shape width="700" height="20" topLeftX="32" topLeftY="46" type="text">
        <content textType="caption" fontSize="12" fontFamily="思源黑体" color="rgba(180, 192, 210, 1)">
          <p>市场份额结构 · PIE / DONUT</p>
        </content>
      </shape>
      <shape width="160" height="22" topLeftX="770" topLeftY="26" type="text">
        <content textType="caption" fontSize="11" fontFamily="思源黑体" color="rgba(220, 228, 240, 1)" textAlign="right">
          <p>SECTION 03 / PIE</p>
        </content>
      </shape>
      <shape width="440" height="400" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(255, 255, 255, 1)"/>
        </fill>
        <border color="rgba(226, 232, 240, 1)" width="1"/>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="440" height="32" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(240, 244, 249, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="420" height="22" topLeftX="44" topLeftY="98" type="text">
        <content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
          <p>① 饼图 · Pie</p>
        </content>
      </shape>
      <chart width="420" height="350" topLeftX="42" topLeftY="132">
        <chartPlotArea>
          <chartPlot type="pie" yAxisPosition="right">
            <chartExtra/>
            <chartLabels position="outside" value="false" percentage="true" fontSize="10"/>
          </chartPlot>
        </chartPlotArea>
        <chartLegend position="right" fontSize="10"/>
        <chartData>
          <dim1>
            <chartField name="品牌">Apple,Samsung,Xiaomi,Huawei,Others</chartField>
          </dim1>
          <dim2>
            <chartField name="份额">28,24,15,12,21</chartField>
          </dim2>
        </chartData>
        <chartTitle fontSize="12" color="rgba(15, 30, 58, 1)" bold="true">全球智能手机品牌份额 2026H1</chartTitle>
        <chartStyle>
          <chartBackground color="rgba(0, 0, 0, 0)"/>
          <chartBorder color="rgb(222, 224, 227)" width="0"/>
          <chartColorTheme>
            <color value="rgb(28, 71, 120)"/>
            <color value="rgb(240, 129, 54)"/>
            <color value="rgb(56, 142, 60)"/>
            <color value="rgb(148, 163, 184)"/>
            <color value="rgb(120, 144, 180)"/>
          </chartColorTheme>
        </chartStyle>
      </chart>
      <shape width="440" height="400" topLeftX="488" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(255, 255, 255, 1)"/>
        </fill>
        <border color="rgba(226, 232, 240, 1)" width="1"/>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="440" height="32" topLeftX="488" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(240, 244, 249, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="420" height="22" topLeftX="500" topLeftY="98" type="text">
        <content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
          <p>② 环形图 · Donut</p>
        </content>
      </shape>
      <chart width="420" height="350" topLeftX="498" topLeftY="132">
        <chartPlotArea>
          <chartPlot type="pie" yAxisPosition="right">
            <chartExtra/>
            <chartLabels position="outside" category="true" value="false" percentage="true" fontSize="10"/>
            <chartSeriesList>
              <chartSeries index="1">
                <chartSectors innerRadius="0.55" offsetRadius="0" startAngle="0"/>
              </chartSeries>
            </chartSeriesList>
          </chartPlot>
        </chartPlotArea>
        <chartData>
          <dim1>
            <chartField name="业务">硬件,服务,可穿戴,其他</chartField>
          </dim1>
          <dim2>
            <chartField name="占比">52,26,15,7</chartField>
          </dim2>
        </chartData>
        <chartTitle fontSize="12" color="rgba(15, 30, 58, 1)" bold="true">收入结构(按业务线)</chartTitle>
        <chartStyle>
          <chartBackground color="rgba(0, 0, 0, 0)"/>
          <chartBorder color="rgb(222, 224, 227)" width="0"/>
          <chartColorTheme>
            <color value="rgb(28, 71, 120)"/>
            <color value="rgb(240, 129, 54)"/>
            <color value="rgb(56, 142, 60)"/>
            <color value="rgb(148, 163, 184)"/>
          </chartColorTheme>
        </chartStyle>
      </chart>
      <line startX="32" startY="506" endX="928.0005580355405" endY="506">
        <border color="rgba(226, 232, 240, 1)" width="1"/>
      </line>
      <shape width="500" height="18" topLeftX="32" topLeftY="512" type="text">
        <content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)">
          <p>Source: Consulting Insights Research · 数据仅用于示意</p>
        </content>
      </shape>
      <shape width="128" height="18" topLeftX="800" topLeftY="512" type="text">
        <content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)" textAlign="right">
          <p>05 / 12</p>
        </content>
      </shape>
    </data>
    <note>
      <content/>
    </note>
  </slide>
  <slide>
    <style>
      <fill>
        <fillColor color="rgba(248, 249, 251, 1)"/>
      </fill>
    </style>
    <data>
      <shape width="960" height="72" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(15, 30, 58, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="8" height="72" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(240, 129, 54, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="700" height="30" topLeftX="32" topLeftY="14" type="text">
        <content textType="headline" fontSize="22" fontFamily="思源黑体" color="rgba(255, 255, 255, 1)" bold="true">
          <p>条形图 · Bar Chart</p>
        </content>
      </shape>
      <shape width="700" height="20" topLeftX="32" topLeftY="46" type="text">
        <content textType="caption" fontSize="12" fontFamily="思源黑体" color="rgba(180, 192, 210, 1)">
          <p>横向排名对比 · GROUPED / STACKED / 100% STACKED</p>
        </content>
      </shape>
      <shape width="160" height="22" topLeftX="770" topLeftY="26" type="text">
        <content textType="caption" fontSize="11" fontFamily="思源黑体" color="rgba(220, 228, 240, 1)" textAlign="right">
          <p>SECTION 04 / BAR</p>
        </content>
      </shape>
      <shape width="290" height="400" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(255, 255, 255, 1)"/>
        </fill>
        <border color="rgba(226, 232, 240, 1)" width="1"/>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="290" height="32" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(240, 244, 249, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="270" height="22" topLeftX="44" topLeftY="98" type="text">
        <content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
          <p>① 分组条形图 · Grouped</p>
        </content>
      </shape>
      <chart width="270" height="350" topLeftX="42" topLeftY="132">
        <chartPlotArea>
          <chartPlot type="bar">
            <chartExtra/>
          </chartPlot>
          <chartAxes>
            <chartAxis type="y" position="left">
              <chartLabel fontSize="9"/>
            </chartAxis>
            <chartAxis type="x">
              <chartGridLine color="rgb(226, 232, 240)"/>
              <chartLabel fontSize="9"/>
            </chartAxis>
          </chartAxes>
        </chartPlotArea>
        <chartLegend position="bottom" fontSize="10"/>
        <chartData>
          <dim1>
            <chartField name="品类">手机,平板,手表,耳机</chartField>
          </dim1>
          <dim2>
            <chartField name="2025">180,68,42,96</chartField>
            <chartField name="2026">210,82,58,132</chartField>
          </dim2>
        </chartData>
        <chartTitle fontSize="12" color="rgba(15, 30, 58, 1)" bold="true">品类销售对比(万台)</chartTitle>
        <chartStyle>
          <chartBackground color="rgba(0, 0, 0, 0)"/>
          <chartBorder color="rgb(222, 224, 227)" width="0"/>
          <chartColorTheme>
            <color value="rgb(28, 71, 120)"/>
            <color value="rgb(240, 129, 54)"/>
          </chartColorTheme>
        </chartStyle>
      </chart>
      <shape width="290" height="400" topLeftX="335" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(255, 255, 255, 1)"/>
        </fill>
        <border color="rgba(226, 232, 240, 1)" width="1"/>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="290" height="32" topLeftX="335" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(240, 244, 249, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="270" height="22" topLeftX="347" topLeftY="98" type="text">
        <content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
          <p>② 堆叠条形图 · Stacked</p>
        </content>
      </shape>
      <chart width="270" height="350" topLeftX="345" topLeftY="132">
        <chartPlotArea>
          <chartPlot type="bar">
            <chartExtra>
              <chartStack/>
            </chartExtra>
          </chartPlot>
          <chartAxes>
            <chartAxis type="y" position="left">
              <chartLabel fontSize="9"/>
            </chartAxis>
            <chartAxis type="x">
              <chartGridLine color="rgb(226, 232, 240)"/>
              <chartLabel fontSize="9"/>
            </chartAxis>
          </chartAxes>
        </chartPlotArea>
        <chartLegend position="bottom" fontSize="10"/>
        <chartData>
          <dim1>
            <chartField name="品类">手机,平板,手表,耳机</chartField>
          </dim1>
          <dim2>
            <chartField name="线上">125,45,32,78</chartField>
            <chartField name="线下">85,37,26,54</chartField>
          </dim2>
        </chartData>
        <chartTitle fontSize="12" color="rgba(15, 30, 58, 1)" bold="true">渠道构成(万台)</chartTitle>
        <chartStyle>
          <chartBackground color="rgba(0, 0, 0, 0)"/>
          <chartBorder color="rgb(222, 224, 227)" width="0"/>
          <chartColorTheme>
            <color value="rgb(28, 71, 120)"/>
            <color value="rgb(240, 129, 54)"/>
          </chartColorTheme>
        </chartStyle>
      </chart>
      <shape width="290" height="400" topLeftX="638" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(255, 255, 255, 1)"/>
        </fill>
        <border color="rgba(226, 232, 240, 1)" width="1"/>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="290" height="32" topLeftX="638" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(240, 244, 249, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="270" height="22" topLeftX="650" topLeftY="98" type="text">
        <content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
          <p>③ 百分比堆叠 · 100% Stacked</p>
        </content>
      </shape>
      <chart width="270" height="350" topLeftX="648" topLeftY="132">
        <chartPlotArea>
          <chartPlot type="bar">
            <chartExtra>
              <chartStack percentage="true"/>
            </chartExtra>
            <chartSeriesList>
              <chartSeries index="1">
                <chartTooltip format="0%"/>
              </chartSeries>
              <chartSeries index="2">
                <chartTooltip format="0%"/>
              </chartSeries>
            </chartSeriesList>
          </chartPlot>
          <chartAxes>
            <chartAxis type="y" position="left">
              <chartLabel fontSize="9"/>
            </chartAxis>
            <chartAxis type="x">
              <chartGridLine color="rgb(226, 232, 240)"/>
              <chartLabel fontSize="9" format="0%"/>
            </chartAxis>
          </chartAxes>
        </chartPlotArea>
        <chartLegend position="bottom" fontSize="10"/>
        <chartData>
          <dim1>
            <chartField name="品类">手机,平板,手表,耳机</chartField>
          </dim1>
          <dim2>
            <chartField name="线上">1.25,0.45,0.32,0.78</chartField>
            <chartField name="线下">0.85,0.37,0.26,0.54</chartField>
          </dim2>
        </chartData>
        <chartTitle fontSize="12" color="rgba(15, 30, 58, 1)" bold="true">渠道占比结构</chartTitle>
        <chartStyle>
          <chartBackground color="rgba(0, 0, 0, 0)"/>
          <chartBorder color="rgb(222, 224, 227)" width="0"/>
          <chartColorTheme>
            <color value="rgb(28, 71, 120)"/>
            <color value="rgb(240, 129, 54)"/>
          </chartColorTheme>
        </chartStyle>
        <chartTooltip format="0%"/>
      </chart>
      <line startX="32" startY="506" endX="928.0005580355405" endY="506">
        <border color="rgba(226, 232, 240, 1)" width="1"/>
      </line>
      <shape width="500" height="18" topLeftX="32" topLeftY="512" type="text">
        <content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)">
          <p>Source: Consulting Insights Research · 数据仅用于示意</p>
        </content>
      </shape>
      <shape width="128" height="18" topLeftX="800" topLeftY="512" type="text">
        <content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)" textAlign="right">
          <p>06 / 12</p>
        </content>
      </shape>
    </data>
    <note>
      <content/>
    </note>
  </slide>
  <slide>
    <style>
      <fill>
        <fillColor color="rgba(248, 249, 251, 1)"/>
      </fill>
    </style>
    <data>
      <shape width="960" height="72" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(15, 30, 58, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="8" height="72" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(240, 129, 54, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="700" height="30" topLeftX="32" topLeftY="14" type="text">
        <content textType="headline" fontSize="22" fontFamily="思源黑体" color="rgba(255, 255, 255, 1)" bold="true">
          <p>面积图 · Area Chart</p>
        </content>
      </shape>
      <shape width="700" height="20" topLeftX="32" topLeftY="46" type="text">
        <content textType="caption" fontSize="12" fontFamily="思源黑体" color="rgba(180, 192, 210, 1)">
          <p>体量与结构演进 · OVERLAY / STACKED / 100% STACKED</p>
        </content>
      </shape>
      <shape width="160" height="22" topLeftX="770" topLeftY="26" type="text">
        <content textType="caption" fontSize="11" fontFamily="思源黑体" color="rgba(220, 228, 240, 1)" textAlign="right">
          <p>SECTION 05 / AREA</p>
        </content>
      </shape>
      <shape width="290" height="400" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(255, 255, 255, 1)"/>
        </fill>
        <border color="rgba(226, 232, 240, 1)" width="1"/>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="290" height="32" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(240, 244, 249, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="270" height="22" topLeftX="44" topLeftY="98" type="text">
        <content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
          <p>① 重叠面积图 · Overlay</p>
        </content>
      </shape>
      <chart width="270" height="350" topLeftX="42" topLeftY="132">
        <chartPlotArea>
          <chartPlot type="area">
            <chartExtra/>
          </chartPlot>
          <chartAxes>
            <chartAxis type="x">
              <chartLabel fontSize="9"/>
            </chartAxis>
            <chartAxis type="y" position="left">
              <chartGridLine color="rgb(226, 232, 240)"/>
              <chartLabel fontSize="9"/>
            </chartAxis>
          </chartAxes>
        </chartPlotArea>
        <chartLegend position="bottom" fontSize="10"/>
        <chartData>
          <dim1>
            <chartField name="年份">2021,2022,2023,2024,2025,2026</chartField>
          </dim1>
          <dim2>
            <chartField name="北美">180,210,245,268,295,322</chartField>
            <chartField name="亚太">120,155,180,220,258,296</chartField>
          </dim2>
        </chartData>
        <chartTitle fontSize="12" color="rgba(15, 30, 58, 1)" bold="true">全年营收走势(亿美元)</chartTitle>
        <chartStyle>
          <chartBackground color="rgba(0, 0, 0, 0)"/>
          <chartBorder color="rgb(222, 224, 227)" width="0"/>
          <chartColorTheme>
            <color value="rgb(28, 71, 120)"/>
            <color value="rgb(240, 129, 54)"/>
          </chartColorTheme>
        </chartStyle>
      </chart>
      <shape width="290" height="400" topLeftX="335" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(255, 255, 255, 1)"/>
        </fill>
        <border color="rgba(226, 232, 240, 1)" width="1"/>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="290" height="32" topLeftX="335" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(240, 244, 249, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="270" height="22" topLeftX="347" topLeftY="98" type="text">
        <content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
          <p>② 堆叠面积图 · Stacked</p>
        </content>
      </shape>
      <chart width="270" height="350" topLeftX="345" topLeftY="132">
        <chartPlotArea>
          <chartPlot type="area">
            <chartExtra>
              <chartStack/>
            </chartExtra>
          </chartPlot>
          <chartAxes>
            <chartAxis type="x">
              <chartLabel fontSize="9"/>
            </chartAxis>
            <chartAxis type="y" position="left">
              <chartGridLine color="rgb(226, 232, 240)"/>
              <chartLabel fontSize="9"/>
            </chartAxis>
          </chartAxes>
        </chartPlotArea>
        <chartLegend position="bottom" fontSize="10"/>
        <chartData>
          <dim1>
            <chartField name="年份">2021,2022,2023,2024,2025,2026</chartField>
          </dim1>
          <dim2>
            <chartField name="北美">180,210,245,268,295,322</chartField>
            <chartField name="亚太">120,155,180,220,258,296</chartField>
          </dim2>
        </chartData>
        <chartTitle fontSize="12" color="rgba(15, 30, 58, 1)" bold="true">总营收堆叠视图</chartTitle>
        <chartStyle>
          <chartBackground color="rgba(0, 0, 0, 0)"/>
          <chartBorder color="rgb(222, 224, 227)" width="0"/>
          <chartColorTheme>
            <color value="rgb(28, 71, 120)"/>
            <color value="rgb(240, 129, 54)"/>
          </chartColorTheme>
        </chartStyle>
      </chart>
      <shape width="290" height="400" topLeftX="638" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(255, 255, 255, 1)"/>
        </fill>
        <border color="rgba(226, 232, 240, 1)" width="1"/>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="290" height="32" topLeftX="638" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(240, 244, 249, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="270" height="22" topLeftX="650" topLeftY="98" type="text">
        <content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
          <p>③ 百分比堆叠 · 100% Stacked</p>
        </content>
      </shape>
      <chart width="270" height="350" topLeftX="648" topLeftY="132">
        <chartPlotArea>
          <chartPlot type="area">
            <chartExtra>
              <chartStack percentage="true"/>
            </chartExtra>
          </chartPlot>
          <chartAxes>
            <chartAxis type="x">
              <chartLabel fontSize="9"/>
            </chartAxis>
            <chartAxis type="y" position="left">
              <chartGridLine color="rgb(226, 232, 240)"/>
              <chartLabel fontSize="9" format="0%"/>
            </chartAxis>
          </chartAxes>
        </chartPlotArea>
        <chartLegend position="bottom" fontSize="10"/>
        <chartData>
          <dim1>
            <chartField name="年份">2021,2022,2023,2024,2025,2026</chartField>
          </dim1>
          <dim2>
            <chartField name="北美">180,210,245,268,295,322</chartField>
            <chartField name="亚太">120,155,180,220,258,296</chartField>
          </dim2>
        </chartData>
        <chartTitle fontSize="12" color="rgba(15, 30, 58, 1)" bold="true">区域营收占比演进</chartTitle>
        <chartStyle>
          <chartBackground color="rgba(0, 0, 0, 0)"/>
          <chartBorder color="rgb(222, 224, 227)" width="0"/>
          <chartColorTheme>
            <color value="rgb(28, 71, 120)"/>
            <color value="rgb(240, 129, 54)"/>
          </chartColorTheme>
        </chartStyle>
        <chartTooltip format="0%"/>
      </chart>
      <line startX="32" startY="506" endX="928.0005580355405" endY="506">
        <border color="rgba(226, 232, 240, 1)" width="1"/>
      </line>
      <shape width="500" height="18" topLeftX="32" topLeftY="512" type="text">
        <content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)">
          <p>Source: Consulting Insights Research · 数据仅用于示意</p>
        </content>
      </shape>
      <shape width="128" height="18" topLeftX="800" topLeftY="512" type="text">
        <content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)" textAlign="right">
          <p>07 / 12</p>
        </content>
      </shape>
    </data>
    <note>
      <content/>
    </note>
  </slide>
  <slide>
    <style>
      <fill>
        <fillColor color="rgba(248, 249, 251, 1)"/>
      </fill>
    </style>
    <data>
      <shape width="960" height="72" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(15, 30, 58, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="8" height="72" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(240, 129, 54, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="700" height="30" topLeftX="32" topLeftY="14" type="text">
        <content textType="headline" fontSize="22" fontFamily="思源黑体" color="rgba(255, 255, 255, 1)" bold="true">
          <p>组合图 · Combo Chart</p>
        </content>
      </shape>
      <shape width="700" height="20" topLeftX="32" topLeftY="46" type="text">
        <content textType="caption" fontSize="12" fontFamily="思源黑体" color="rgba(180, 192, 210, 1)">
          <p>营收规模 &amp; 增长率双轴视图 · COLUMN + LINE</p>
        </content>
      </shape>
      <shape width="160" height="22" topLeftX="770" topLeftY="26" type="text">
        <content textType="caption" fontSize="11" fontFamily="思源黑体" color="rgba(220, 228, 240, 1)" textAlign="right">
          <p>SECTION 07 / COMBO</p>
        </content>
      </shape>
      <shape width="576" height="400" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(255, 255, 255, 1)"/>
        </fill>
        <border color="rgba(226, 232, 240, 1)" width="1"/>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="576" height="32" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(240, 244, 249, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="560" height="22" topLeftX="44" topLeftY="98" type="text">
        <content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
          <p>八季度营收与同比增速</p>
        </content>
      </shape>
      <chart width="556" height="350" topLeftX="42" topLeftY="132">
        <chartPlotArea>
          <chartPlot type="combo">
            <chartExtra/>
            <chartSeriesList>
              <chartSeries index="1" comboType="column"/>
              <chartSeries index="2" comboType="line" yAxisPosition="right">
                <chartTooltip format="0%"/>
              </chartSeries>
            </chartSeriesList>
          </chartPlot>
          <chartAxes>
            <chartAxis type="x">
              <chartLabel fontSize="10"/>
            </chartAxis>
            <chartAxis type="y" position="left">
              <chartGridLine color="rgb(226, 232, 240)"/>
              <chartLabel fontSize="10"/>
            </chartAxis>
            <chartAxis type="y" position="right">
              <chartLabel fontSize="10" format="0%"/>
            </chartAxis>
          </chartAxes>
        </chartPlotArea>
        <chartLegend position="bottom" fontSize="11"/>
        <chartData>
          <dim1>
            <chartField name="季度">24Q1,24Q2,24Q3,24Q4,25Q1,25Q2,25Q3,25Q4</chartField>
          </dim1>
          <dim2>
            <chartField name="营收">180,195,210,245,220,238,258,296</chartField>
            <chartField name="增速">0.08,0.12,0.15,0.18,0.22,0.22,0.23,0.21</chartField>
          </dim2>
        </chartData>
        <chartTitle fontSize="12" color="rgba(15, 30, 58, 1)" bold="true">营收(亿美元, 左轴) · 同比增速(%, 右轴)</chartTitle>
        <chartStyle>
          <chartBackground color="rgba(0, 0, 0, 0)"/>
          <chartBorder color="rgb(222, 224, 227)" width="0"/>
          <chartColorTheme>
            <color value="rgb(28, 71, 120)"/>
            <color value="rgb(240, 129, 54)"/>
          </chartColorTheme>
        </chartStyle>
      </chart>
      <shape width="304" height="400" topLeftX="624" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(15, 30, 58, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="304" height="4" topLeftX="624" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(240, 129, 54, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="264" height="26" topLeftX="644" topLeftY="118" type="text">
        <content textType="caption" fontSize="12" fontFamily="思源黑体" color="rgba(240, 129, 54, 1)" bold="true">
          <p>KEY TAKEAWAY</p>
        </content>
      </shape>
      <shape width="264" height="90" topLeftX="644" topLeftY="146" type="text">
        <content textType="headline" fontSize="18" fontFamily="思源黑体" color="rgba(255, 255, 255, 1)" bold="true">
          <p>营收连续 6 季度双位数增长</p>
        </content>
      </shape>
      <shape width="264" height="80" topLeftX="644" topLeftY="248" type="text">
        <content fontSize="13" fontFamily="思源黑体" color="rgba(220, 228, 240, 1)">
          <p>24Q4 - 25Q4 期间同比增速稳定在 18-23%,营收规模从 245 亿扩张至 296 亿美元。</p>
        </content>
      </shape>
      <line startX="644" startY="352" endX="720.0065786626394" endY="352">
        <border color="rgba(240, 129, 54, 1)"/>
      </line>
      <shape width="264" height="26" topLeftX="644" topLeftY="360" type="text">
        <content textType="caption" fontSize="11" fontFamily="思源黑体" color="rgba(240, 129, 54, 1)" bold="true">
          <p>WHAT TO WATCH</p>
        </content>
      </shape>
      <shape width="264" height="90" topLeftX="644" topLeftY="386" type="text">
        <content fontSize="12" fontFamily="思源黑体" color="rgba(220, 228, 240, 1)">
          <p>· 25Q4 增速首次微降 2pp</p>
          <p>· 高基数效应即将显现</p>
          <p>· 需关注亚太区库存周期</p>
        </content>
      </shape>
      <line startX="32" startY="506" endX="928.0005580355405" endY="506">
        <border color="rgba(226, 232, 240, 1)" width="1"/>
      </line>
      <shape width="500" height="18" topLeftX="32" topLeftY="512" type="text">
        <content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)">
          <p>Source: Consulting Insights Research · 数据仅用于示意</p>
        </content>
      </shape>
      <shape width="128" height="18" topLeftX="800" topLeftY="512" type="text">
        <content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)" textAlign="right">
          <p>09 / 12</p>
        </content>
      </shape>
    </data>
    <note>
      <content/>
    </note>
  </slide>
  <slide>
    <style>
      <fill>
        <fillColor color="rgba(248, 249, 251, 1)"/>
      </fill>
    </style>
    <data>
      <shape width="960" height="72" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(15, 30, 58, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="8" height="72" topLeftX="0" topLeftY="0" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(240, 129, 54, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="700" height="30" topLeftX="32" topLeftY="14" type="text">
        <content textType="headline" fontSize="22" fontFamily="思源黑体" color="rgba(255, 255, 255, 1)" bold="true">
          <p>雷达图 · Radar Chart</p>
        </content>
      </shape>
      <shape width="700" height="20" topLeftX="32" topLeftY="46" type="text">
        <content textType="caption" fontSize="12" fontFamily="思源黑体" color="rgba(180, 192, 210, 1)">
          <p>产品能力多维对比 · POLYGON / CIRCLE / OUTLINE</p>
        </content>
      </shape>
      <shape width="160" height="22" topLeftX="770" topLeftY="26" type="text">
        <content textType="caption" fontSize="11" fontFamily="思源黑体" color="rgba(220, 228, 240, 1)" textAlign="right">
          <p>SECTION 08 / RADAR</p>
        </content>
      </shape>
      <shape width="290" height="400" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(255, 255, 255, 1)"/>
        </fill>
        <border color="rgba(226, 232, 240, 1)" width="1"/>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="290" height="32" topLeftX="32" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(240, 244, 249, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="270" height="22" topLeftX="44" topLeftY="98" type="text">
        <content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
          <p>① 多边形雷达 · Polygon</p>
        </content>
      </shape>
      <chart width="270" height="350" topLeftX="42" topLeftY="132">
        <chartPlotArea>
          <chartPlot type="radar">
            <chartExtra>
              <chartRadar/>
            </chartExtra>
            <chartAreas/>
          </chartPlot>
          <chartAxes>
            <chartAxis type="angle">
              <chartLabel fontSize="9"/>
            </chartAxis>
            <chartAxis type="radius" max="10">
              <chartGridLine color="rgb(226, 232, 240)"/>
              <chartLabel fontSize="8"/>
            </chartAxis>
          </chartAxes>
        </chartPlotArea>
        <chartLegend position="bottom" fontSize="10"/>
        <chartData>
          <dim1>
            <chartField name="维度">性能,续航,拍照,屏幕,系统,生态</chartField>
          </dim1>
          <dim2>
            <chartField name="品牌 A">9,7,8,9,8,9</chartField>
            <chartField name="品牌 B">7,9,9,7,8,6</chartField>
          </dim2>
        </chartData>
        <chartTitle fontSize="12" color="rgba(15, 30, 58, 1)" bold="true">旗舰机型能力评估</chartTitle>
        <chartStyle>
          <chartBackground color="rgba(0, 0, 0, 0)"/>
          <chartBorder color="rgb(222, 224, 227)" width="0"/>
          <chartColorTheme>
            <color value="rgb(28, 71, 120)"/>
            <color value="rgb(240, 129, 54)"/>
          </chartColorTheme>
        </chartStyle>
      </chart>
      <shape width="290" height="400" topLeftX="335" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(255, 255, 255, 1)"/>
        </fill>
        <border color="rgba(226, 232, 240, 1)" width="1"/>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="290" height="32" topLeftX="335" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(240, 244, 249, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="270" height="22" topLeftX="347" topLeftY="98" type="text">
        <content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
          <p>② 圆形雷达 · Circle</p>
        </content>
      </shape>
      <chart width="270" height="350" topLeftX="345" topLeftY="132">
        <chartPlotArea>
          <chartPlot type="radar">
            <chartExtra>
              <chartSmooth/>
              <chartRadar shape="circle"/>
            </chartExtra>
            <chartAreas/>
          </chartPlot>
          <chartAxes>
            <chartAxis type="angle">
              <chartLabel fontSize="9"/>
            </chartAxis>
            <chartAxis type="radius" max="10">
              <chartGridLine color="rgb(226, 232, 240)"/>
              <chartLabel fontSize="8"/>
            </chartAxis>
          </chartAxes>
        </chartPlotArea>
        <chartLegend position="bottom" fontSize="10"/>
        <chartData>
          <dim1>
            <chartField name="维度">性能,续航,拍照,屏幕,系统,生态</chartField>
          </dim1>
          <dim2>
            <chartField name="品牌 A">7,8,6,8,7,7</chartField>
            <chartField name="品牌 B">8,6,7,7,8,8</chartField>
          </dim2>
        </chartData>
        <chartTitle fontSize="12" color="rgba(15, 30, 58, 1)" bold="true">中端机型能力评估</chartTitle>
        <chartStyle>
          <chartBackground color="rgba(0, 0, 0, 0)"/>
          <chartBorder color="rgb(222, 224, 227)" width="0"/>
          <chartColorTheme>
            <color value="rgb(28, 71, 120)"/>
            <color value="rgb(240, 129, 54)"/>
          </chartColorTheme>
        </chartStyle>
      </chart>
      <shape width="290" height="400" topLeftX="638" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(255, 255, 255, 1)"/>
        </fill>
        <border color="rgba(226, 232, 240, 1)" width="1"/>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="290" height="32" topLeftX="638" topLeftY="92" presetHandlers="0" type="rect">
        <fill>
          <fillColor color="rgba(240, 244, 249, 1)"/>
        </fill>
        <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
      </shape>
      <shape width="270" height="22" topLeftX="650" topLeftY="98" type="text">
        <content fontSize="12" fontFamily="思源黑体" color="rgba(15, 30, 58, 1)" bold="true">
          <p>③ 无填充雷达 · Outline</p>
        </content>
      </shape>
      <chart width="270" height="350" topLeftX="648" topLeftY="132">
        <chartPlotArea>
          <chartPlot type="radar">
            <chartExtra>
              <chartRadar area="false"/>
            </chartExtra>
          </chartPlot>
          <chartAxes>
            <chartAxis type="angle">
              <chartLabel fontSize="9"/>
            </chartAxis>
            <chartAxis type="radius" max="10">
              <chartGridLine color="rgb(226, 232, 240)"/>
              <chartLabel fontSize="8"/>
            </chartAxis>
          </chartAxes>
        </chartPlotArea>
        <chartLegend position="bottom" fontSize="10"/>
        <chartData>
          <dim1>
            <chartField name="维度">性能,续航,拍照,屏幕,系统,生态</chartField>
          </dim1>
          <dim2>
            <chartField name="品牌 A">6,7,5,6,6,5</chartField>
            <chartField name="品牌 B">5,6,6,5,7,6</chartField>
          </dim2>
        </chartData>
        <chartTitle fontSize="12" color="rgba(15, 30, 58, 1)" bold="true">入门机型能力评估</chartTitle>
        <chartStyle>
          <chartBackground color="rgba(0, 0, 0, 0)"/>
          <chartBorder color="rgb(222, 224, 227)" width="0"/>
          <chartColorTheme>
            <color value="rgb(28, 71, 120)"/>
            <color value="rgb(240, 129, 54)"/>
          </chartColorTheme>
        </chartStyle>
      </chart>
      <line startX="32" startY="506" endX="928.0005580355405" endY="506">
        <border color="rgba(226, 232, 240, 1)" width="1"/>
      </line>
      <shape width="500" height="18" topLeftX="32" topLeftY="512" type="text">
        <content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)">
          <p>Source: Consulting Insights Research · 数据仅用于示意</p>
        </content>
      </shape>
      <shape width="128" height="18" topLeftX="800" topLeftY="512" type="text">
        <content textType="caption" fontSize="10" fontFamily="思源黑体" color="rgba(148, 163, 184, 1)" textAlign="right">
          <p>10 / 12</p>
        </content>
      </shape>
    </data>
    <note>
      <content/>
    </note>
  </slide>
</presentation>
references/xml/slides_xml_schema_definition.xml
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema
        xmlns:xs="http://www.w3.org/2001/XMLSchema"
        xmlns:sml="https://www.larkoffice.com/sml/2.0"
        targetNamespace="https://www.larkoffice.com/sml/2.0"
        elementFormDefault="qualified"
        attributeFormDefault="unqualified">
    <xs:annotation>
        <xs:documentation xml:lang="zh-CN">
            飞书 Slides 结构化标记语言 (Structured Markup Language, SML) 2.0

            版本信息:
            - Schema 版本: 2.0.0
            - 命名空间: https://www.larkoffice.com/sml/2.0
            - 发布日期: 2025-11-03
        </xs:documentation>
    </xs:annotation>

    <!-- ==================== 命名规范说明 ==================== -->
    <!--
    元素名:小写, HTML 兼容优先(p, ul, span)
    属性名:camelCase(textType, fontSize, textAlign)
    类型名:PascalCase(TextType, FontFamilyType)
    枚举值:kebab-case(sub-headline, left-right-arrow)
  -->

    <!-- ==================== 基础类型定义 ==================== -->
    <!-- Color: 颜色类型 -->
    <xs:simpleType name="Color">
        <xs:annotation>
            <xs:documentation>
                颜色格式, 支持纯色和渐变色
            </xs:documentation>
        </xs:annotation>
        <xs:union memberTypes="sml:SolidColor sml:GradientColor"/>
    </xs:simpleType>


    <!-- SolidColor: 纯色 -->
    <xs:simpleType name="SolidColor">
        <xs:annotation>
            <xs:documentation>
                支持 RGB/RGBA 两种颜色格式:
                - RGB: rgb(r,g,b)
                - 说明:r,g,b 为 [0,255] 的整数
                - 示例:rgb(255,0,0)
                - RGBA: rgba(r,g,b,a)
                - 说明:r,g,b 为 [0,255] 的整数,a 为 0 或 0.x ... 1 或 1.0 的浮点数
                - 示例:rgba(255,0,0,0.5)
                注意:允许空格,如 rgb(255, 0, 0) 也是有效格式
            </xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:string">
            <!-- 仅支持 rgb(r,g,b) 和 rgba(r,g,b,a) 格式 -->
            <xs:pattern value="(rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)|rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(0(\.\d+)?|1(\.0+)?)\s*\))"/>
        </xs:restriction>
    </xs:simpleType>


    <!-- 渐变色类型定义 -->
    <xs:simpleType name="GradientColor">
        <xs:annotation>
            <xs:documentation>
                渐变色格式支持以下类型:
                - 线性渐变:
                语法:linear-gradient(角度, 颜色停靠点, 颜色停靠点, ...)
                角度:[0,360)数字 + deg,如 90deg、180deg,表示渐变方向
                颜色停靠点:定义渐变中每个颜色的位置(参见“颜色停靠点说明”)
                示例:linear-gradient(90deg, rgb(255, 0, 0) 0%, rgb(0, 0, 255) 100%)

                - 射线渐变:
                语法:radial-gradient(circle [at 中心位置], 颜色停靠点, 颜色停靠点, ...)
                形状:circle,仅支持 circle 形状
                中心位置:定义渐变中心的位置(参见“中心位置说明”)
                颜色停靠点:定义颜色在半径方向上的分布(参见“颜色停靠点说明”)
                示例:radial-gradient(circle at 50% 50%, rgb(255, 0, 0) 0%, rgb(0, 0, 255) 100%)

                - 矩形渐变:
                语法:rect-gradient(circle [at 中心位置], 颜色停靠点, 颜色停靠点, ...)
                形状:circle,仅支持 circle 形状
                中心位置:定义渐变中心的位置(参见“中心位置说明”)
                颜色停靠点:定义颜色在矩形范围内的分布(参见“颜色停靠点说明”)
                示例:rect-gradient(circle at 0% 0%, rgb(255, 0, 0) 0%, rgb(0, 0, 255) 100%)

                - 形状渐变:
                语法:shape-gradient(circle [at 中心位置], 颜色停靠点, 颜色停靠点, ...)
                形状:circle,仅支持 circle 形状
                中心位置:仅支持 50% 50%,若输入其他位置,将回退为 50% 50%
                颜色停靠点:定义颜色分布(参见“颜色停靠点说明”)
                示例:shape-gradient(circle at 50% 50%, rgb(255, 0, 0) 0%, rgb(0, 0, 255) 100%)

                --------------------------------------------------------
                中心位置说明:
                所有非线性渐变(射线渐变、矩形渐变、形状渐变)均可指定渐变中心位置:
                - 格式:x% y%,为百分比格式 (0-100% 0-100%),表示相对于容器宽高的比例位置
                - 支持以下枚举值:
                0% 0%:左上角
                100% 0%:右上角
                50% 50%:中心
                0% 100%:左下角
                100% 100%:右下角
                - 若位置超出支持范围或格式错误,将会被回退为 50% 50%

                颜色停靠点说明:
                - 格式:颜色 位置%
                - 颜色:必须为 rgb(r,g,b) 或 rgba(r,g,b,a) 格式
                - 无透明通道: rgb(r,g,b),其中 r,g,b 为 [0,255] 的整数
                - 含透明通道: rgba(r,g,b,a),其中 r,g,b 为 [0,255] 的整数,a 为 0 或 0.x ... 1 或 1.0 的浮点数
                - 颜色停靠点(color stop):
                - 定义:颜色在渐变路径上的位置,至少需要 2 个颜色停靠点
                - 位置:整数百分比,取值范围 [0, 100],后跟 % 符号
            </xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:string">
            <!-- 线性渐变:仅支持角度(deg) -->
            <xs:pattern value="linear-gradient\(\s*\d+deg\s*,\s*((rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)|rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(0(\.\d+)?|1(\.0+)?)\s*\))\s+\d{1,3}%)(\s*,\s*(rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)|rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(0(\.\d+)?|1(\.0+)?)\s*\))\s+\d{1,3}%)+\s*\)"/>

            <!-- 射线渐变:circle, 位置支持0-100%的百分比 -->
            <xs:pattern value="radial-gradient\(\s*circle(\s+at\s+\d{1,3}%\s+\d{1,3}%)?\s*,\s*((rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)|rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(0(\.\d+)?|1(\.0+)?)\s*\))\s+\d{1,3}%)(\s*,\s*(rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)|rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(0(\.\d+)?|1(\.0+)?)\s*\))\s+\d{1,3}%)+\s*\)"/>

            <!-- 矩形渐变:circle,位置支持0-100%的百分比 -->
            <xs:pattern value="rect-gradient\(\s*circle(\s+at\s+\d{1,3}%\s+\d{1,3}%)?\s*,\s*((rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)|rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(0(\.\d+)?|1(\.0+)?)\s*\))\s+\d{1,3}%)(\s*,\s*(rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)|rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(0(\.\d+)?|1(\.0+)?)\s*\))\s+\d{1,3}%)+\s*\)"/>

            <!-- 形状渐变:circle, 位置支持0-100%的百分比 -->
            <xs:pattern value="shape-gradient\(\s*circle(\s+at\s+\d{1,3}%\s+\d{1,3}%)?\s*,\s*((rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)|rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(0(\.\d+)?|1(\.0+)?)\s*\))\s+\d{1,3}%)(\s*,\s*(rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)|rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(0(\.\d+)?|1(\.0+)?)\s*\))\s+\d{1,3}%)+\s*\)"/>
        </xs:restriction>
    </xs:simpleType>

    <!-- RotationType: 旋转角度,取值范围 [0, 360) 度,支持小数 -->
    <xs:simpleType name="RotationType">
        <xs:restriction base="xs:double">
            <xs:minInclusive value="0"/>
            <xs:maxExclusive value="360"/>
        </xs:restriction>
    </xs:simpleType>

    <!-- 非负整数(包含0, 适用于坐标) -->
    <xs:simpleType name="NonNegativeInt">
        <xs:restriction base="xs:nonNegativeInteger"/>
    </xs:simpleType>

    <!-- 正小数(>0), 适用于宽高、长度等 -->
    <xs:simpleType name="PositiveSize">
        <xs:restriction base="xs:double">
            <xs:minExclusive value="0"/>
        </xs:restriction>
    </xs:simpleType>

    <!-- 字体族类型定义 -->
    <xs:simpleType name="FontFamilyType">
        <xs:annotation>
            <xs:documentation>
                字体族名称, 支持任意字体。

                常用中文字体:
                思源宋体、寒蝉德黑体、标小智无界黑、寒蝉锦书宋、站酷小薇体、
                寒蝉团圆体 圆体、寒蝉团圆体 黑体、荆南缘默体、寒蝉端黑宋、
                资源圆体、钟齐流江毛草、寒蝉端黑体、站酷庆科黄油体、寒蝉云墨黑、
                有字库龙藏体、寒蝉全圆体、思源黑体、钟齐志莽行书、抖音美好体、
                马善政毛笔楷体、霞鹜 975 圆体、阿里妈妈刀隶体、阿里妈妈东方大楷、
                精品点阵体、站酷文艺体、寒蝉正楷体、朱雀仿宋

                常用拉丁字体:
                Francois One、Heebo、Lobster、Roboto Slab、Varela Round、
                PT Serif、Signika、Vollkorn、Mulish、Rokkitt、Inconsolata、
                PT Sans Caption、EB Garamond、Dancing Script、Rajdhani、Poppins、
                Merriweather、PT Sans Narrow、Libre Baskerville、Slabo 27px、
                Inter、Noto Serif、Yanone Kaffeesatz、Merriweather Sans、Lato、
                Source Code Pro、Mukta、Teko、Hind Siliguri、Catamaran、Arvo、
                Alegreya Sans、Titillium Web、Roboto Mono、Play、Indie Flower、
                Ubuntu Condensed、Libre Franklin、Barlow、PT Sans、Acme、Cuprum、
                Josefin Sans、DM Sans、Playfair Display、Rubik、Questrial、Anton、
                Oswald、Cabin、Ubuntu、Abel、Exo 2、Bree Serif、Roboto Condensed、
                Amatic SC、Abril Fatface、Comfortaa、IBM Plex Sans、Work Sans、
                Kanit、Noto Sans、Alegreya、Shadows Into Light、Barlow Condensed、
                Nunito Sans、Quicksand、Overpass、Bebas Neue、Raleway、Exo、
                Archivo Narrow、Hind、Open Sans、Poiret One、Asap、Roboto、Nunito、
                Bitter、Dosis、Oxygen、Prompt、Karla、Fjalla One、Fira Sans、
                Crimson Text、Pacifico、Arimo、Maven Pro、Cairo、Montserrat、
                Righteous、Lora、ZCOOL Addict Italic

                其他语言字体:
                源ノ角ゴシック、본고딕、Nanum Gothic

                系统字体:
                Arial、Arial Black、Calibri、Comic Sans Ms、Sans Serif、Serif、
                Times New Roman、Tahoma、Trebuchet MS、Verdana、Georgia、Garamond、
                黑体、宋体、楷体、Hiragino Mincho
            </xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:string"/>
    </xs:simpleType>

    <!-- 字号类型定义 -->
    <xs:simpleType name="FontSizeType">
        <xs:annotation>
            <xs:documentation>
                字体大小, 浮点数, 范围 [1, 4000], 单位px
                示例:10, 10.5, 12, 14, 16, 18, 20, 24, 28, 32 等
            </xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:double">
            <xs:minInclusive value="1"/>
            <xs:maxInclusive value="4000"/>
        </xs:restriction>
    </xs:simpleType>

    <xs:simpleType name="NonNegativeDouble">
        <xs:restriction base="xs:double">
            <xs:minInclusive value="0.0"/>
        </xs:restriction>
    </xs:simpleType>

    <xs:simpleType name="AutoStartAtType">
        <xs:annotation>
            <xs:documentation>
                有序列表起始编号, 取值范围 [1, 32767]
            </xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:positiveInteger">
            <xs:minInclusive value="1"/>
            <xs:maxInclusive value="32767"/>
        </xs:restriction>
    </xs:simpleType>

    <xs:simpleType name="BulletSizeType">
        <xs:annotation>
            <xs:documentation>
                列表符号大小, 二选一:
                - 百分比字符串(相对于文本字号), 取值范围 25%-400%, 如 "100%"
                - 绝对像素值, 取值范围 6-400, 如 "14"
            </xs:documentation>
        </xs:annotation>
        <xs:union>
            <xs:simpleType>
                <xs:restriction base="xs:string">
                    <xs:pattern value="(2[5-9]|[3-9][0-9]|[1-3][0-9]{2}|400)%"/>
                </xs:restriction>
            </xs:simpleType>
            <xs:simpleType>
                <xs:restriction base="xs:string">
                    <xs:pattern value="[6-9]|[1-9][0-9]|[1-3][0-9]{2}|400"/>
                </xs:restriction>
            </xs:simpleType>
        </xs:union>
    </xs:simpleType>

    <xs:simpleType name="BulletCharType">
        <xs:annotation>
            <xs:documentation>
                自定义列表符号, 如 "★", "→", "✓", "◆", 也支持 emoji
            </xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:string">
            <xs:minLength value="1"/>
            <xs:maxLength value="8"/>
        </xs:restriction>
    </xs:simpleType>

    <!-- 文本类型枚举 -->
    <xs:simpleType name="TextType">
        <xs:annotation>
            <xs:documentation>
                文本语义层级, 影响默认字号和样式
                title = 大标题
                headline = 标题
                sub-headline = 副标题
                body = 正文
                caption = 小号正文
            </xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:string">
            <xs:enumeration value="title"/>
            <xs:enumeration value="headline"/>
            <xs:enumeration value="sub-headline"/>
            <xs:enumeration value="body"/>
            <xs:enumeration value="caption"/>
        </xs:restriction>
    </xs:simpleType>

    <!-- 动态文本字段类型枚举 -->
    <xs:simpleType name="FieldType">
        <xs:annotation>
            <xs:documentation>
                动态文本字段类型:
                - slidenum: 当前幻灯片页码
                - datetime: 默认日期时间格式
                - datetime1-datetime13: 预定义日期时间格式
            </xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:string">
            <xs:enumeration value="slidenum"><xs:annotation><xs:documentation>当前幻灯片页码</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="datetime"><xs:annotation><xs:documentation>浏览器默认日期格式, 例如 2026/7/14</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="datetime1"><xs:annotation><xs:documentation>日期格式 M/D/YYYY, 例如 10/12/2007</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="datetime2"><xs:annotation><xs:documentation>日期格式 dddd, MMMM D, YYYY, 例如 Friday, October 12, 2007</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="datetime3"><xs:annotation><xs:documentation>日期格式 D MMMM YYYY, 例如 12 October 2007</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="datetime4"><xs:annotation><xs:documentation>日期格式 MMMM D, YYYY, 例如 October 12, 2007</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="datetime5"><xs:annotation><xs:documentation>日期格式 D-MMM-YY, 例如 12-Oct-07</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="datetime6"><xs:annotation><xs:documentation>日期格式 MMMM YY, 例如 October 07</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="datetime7"><xs:annotation><xs:documentation>日期格式 MMM-YY, 例如 Oct-07</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="datetime8"><xs:annotation><xs:documentation>日期时间格式 M/D/YYYY h:mm A, 例如 10/12/2007 4:28 PM</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="datetime9"><xs:annotation><xs:documentation>日期时间格式 M/D/YYYY h:mm:ss A, 例如 10/12/2007 4:28:34 PM</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="datetime10"><xs:annotation><xs:documentation>时间格式 HH:mm, 例如 16:28</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="datetime11"><xs:annotation><xs:documentation>时间格式 HH:mm:ss, 例如 16:28:34</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="datetime12"><xs:annotation><xs:documentation>时间格式 h:mm A, 例如 4:28 PM</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="datetime13"><xs:annotation><xs:documentation>时间格式 h:mm:ss A, 例如 4:28:34 PM</xs:documentation></xs:annotation></xs:enumeration>
        </xs:restriction>
    </xs:simpleType>

    <!-- 文本对齐 -->
    <xs:simpleType name="TextAlignType">
        <xs:restriction base="xs:string">
            <xs:enumeration value="left"><xs:annotation><xs:documentation>左对齐</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="center"><xs:annotation><xs:documentation>居中对齐</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="right"><xs:annotation><xs:documentation>右对齐</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="justify"><xs:annotation><xs:documentation>两端对齐</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="dist"><xs:annotation><xs:documentation>分散对齐</xs:documentation></xs:annotation></xs:enumeration>
        </xs:restriction>
    </xs:simpleType>

    <!-- 垂直对齐 -->
    <xs:simpleType name="VerticalAlignType">
        <xs:restriction base="xs:string">
            <xs:enumeration value="top"/>
            <xs:enumeration value="middle"/>
            <xs:enumeration value="bottom"/>
        </xs:restriction>
    </xs:simpleType>

    <!-- 直线线条类型枚举 -->
    <xs:simpleType name="LineType">
        <xs:annotation><xs:documentation>直线线条类型枚举</xs:documentation></xs:annotation>
        <xs:restriction base="xs:string">
            <xs:enumeration value="straight-connector1"><xs:annotation><xs:documentation>直线连接符</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="line"><xs:annotation><xs:documentation>普通的、非连接功能的线条</xs:documentation></xs:annotation></xs:enumeration>
        </xs:restriction>
    </xs:simpleType>

    <!-- 曲线线条类型枚举 -->
    <xs:simpleType name="PolylineType">
        <xs:annotation><xs:documentation>曲线线条类型枚举</xs:documentation></xs:annotation>
        <xs:restriction base="xs:string">
            <xs:enumeration value="bent-connector2"><xs:annotation><xs:documentation>包含2个线段的折线连接符</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="bent-connector3"><xs:annotation><xs:documentation>包含3个线段的折线连接符</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="bent-connector4"><xs:annotation><xs:documentation>包含4个线段的折线连接符</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="bent-connector5"><xs:annotation><xs:documentation>包含5个线段的折线连接符</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="curved-connector2"><xs:annotation><xs:documentation>包含2个线段的曲线连接符</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="curved-connector3"><xs:annotation><xs:documentation>包含3个线段的曲线连接符</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="curved-connector4"><xs:annotation><xs:documentation>包含4个线段的曲线连接符</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="curved-connector5"><xs:annotation><xs:documentation>包含5个线段的曲线连接符</xs:documentation></xs:annotation></xs:enumeration>
        </xs:restriction>
    </xs:simpleType>


    <!-- 溢出时处理文本策略类型 -->
    <xs:simpleType name="AutoFitType">
        <xs:restriction base="xs:string">
            <xs:enumeration value="shape-auto-fit"><xs:annotation><xs:documentation>自动调整尺寸</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="no-auto-fit"><xs:annotation><xs:documentation>不自动调整</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="normal-auto-fit"><xs:annotation><xs:documentation>缩排文本</xs:documentation></xs:annotation></xs:enumeration>
        </xs:restriction>
    </xs:simpleType>


    <!-- 图形类型 -->
    <xs:simpleType name="ShapeType">
        <xs:annotation><xs:documentation>图形形状类型枚举</xs:documentation></xs:annotation>
        <xs:restriction base="xs:string">
            <!-- 基础形状 -->
            <xs:enumeration value="custom"><xs:annotation><xs:documentation>自定义形状</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="rect"><xs:annotation><xs:documentation>矩形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="ellipse"><xs:annotation><xs:documentation>椭圆</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="text"><xs:annotation><xs:documentation>文本框</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="triangle"><xs:annotation><xs:documentation>三角形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="rt-triangle"><xs:annotation><xs:documentation>直角三角形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="slides-lt-triangle"><xs:annotation><xs:documentation>幻灯片左三角形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="pentagon"><xs:annotation><xs:documentation>五边形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="hexagon"><xs:annotation><xs:documentation>六边形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="heptagon"><xs:annotation><xs:documentation>七边形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="octagon"><xs:annotation><xs:documentation>八边形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="decagon"><xs:annotation><xs:documentation>十边形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="dodecagon"><xs:annotation><xs:documentation>十二边形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="diamond"><xs:annotation><xs:documentation>菱形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="parallelogram"><xs:annotation><xs:documentation>平行四边形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="trapezoid"><xs:annotation><xs:documentation>梯形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="non-isosceles-trapezoid"><xs:annotation><xs:documentation>非等腰梯形</xs:documentation></xs:annotation></xs:enumeration>

            <!-- 圆角矩形系列 -->
            <xs:enumeration value="round-rect"><xs:annotation><xs:documentation>圆角矩形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="slides-full-round-rect"><xs:annotation><xs:documentation>幻灯片全圆角矩形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="round2diag-rect"><xs:annotation><xs:documentation>对角双圆角矩形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="round1rect"><xs:annotation><xs:documentation>单圆角矩形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="round2same-rect"><xs:annotation><xs:documentation>同侧双圆角矩形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="snip-round-rect"><xs:annotation><xs:documentation>切角圆角矩形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="snip1rect"><xs:annotation><xs:documentation>单切角矩形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="snip2diag-rect"><xs:annotation><xs:documentation>对角双切角矩形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="snip2same-rect"><xs:annotation><xs:documentation>同侧双切角矩形</xs:documentation></xs:annotation></xs:enumeration>

            <!-- 星形系列 -->
            <xs:enumeration value="slides-star"><xs:annotation><xs:documentation>幻灯片星形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="star4"><xs:annotation><xs:documentation>四角星</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="star5"><xs:annotation><xs:documentation>五角星</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="star6"><xs:annotation><xs:documentation>六角星</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="star7"><xs:annotation><xs:documentation>七角星</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="star8"><xs:annotation><xs:documentation>八角星</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="star10"><xs:annotation><xs:documentation>十角星</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="star12"><xs:annotation><xs:documentation>十二角星</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="star16"><xs:annotation><xs:documentation>十六角星</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="star24"><xs:annotation><xs:documentation>二十四角星</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="star32"><xs:annotation><xs:documentation>三十二角星</xs:documentation></xs:annotation></xs:enumeration>

            <!-- 箭头系列 -->
            <xs:enumeration value="right-arrow"><xs:annotation><xs:documentation>右箭头</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="left-arrow"><xs:annotation><xs:documentation>左箭头</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="up-arrow"><xs:annotation><xs:documentation>上箭头</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="down-arrow"><xs:annotation><xs:documentation>下箭头</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="left-right-arrow"><xs:annotation><xs:documentation>左右箭头</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="slides-left-right-arrow"><xs:annotation><xs:documentation>幻灯片左右箭头</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="up-down-arrow"><xs:annotation><xs:documentation>上下箭头</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="quad-arrow"><xs:annotation><xs:documentation>四向箭头</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="left-right-up-arrow"><xs:annotation><xs:documentation>左右上箭头</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="left-up-arrow"><xs:annotation><xs:documentation>左上箭头</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="bent-arrow"><xs:annotation><xs:documentation>弯曲箭头</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="bent-up-arrow"><xs:annotation><xs:documentation>向上弯曲箭头</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="curved-right-arrow"><xs:annotation><xs:documentation>右弯箭头</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="curved-left-arrow"><xs:annotation><xs:documentation>左弯箭头</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="curved-up-arrow"><xs:annotation><xs:documentation>上弯箭头</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="curved-down-arrow"><xs:annotation><xs:documentation>下弯箭头</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="striped-right-arrow"><xs:annotation><xs:documentation>条纹右箭头</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="notched-right-arrow"><xs:annotation><xs:documentation>缺口右箭头</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="circular-arrow"><xs:annotation><xs:documentation>环形箭头</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="left-circular-arrow"><xs:annotation><xs:documentation>左环形箭头</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="left-right-circular-arrow"><xs:annotation><xs:documentation>左右环形箭头</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="swoosh-arrow"><xs:annotation><xs:documentation>飞驰箭头</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="uturn-arrow"><xs:annotation><xs:documentation>U型转弯箭头</xs:documentation></xs:annotation></xs:enumeration>

            <!-- 标注系列 -->
            <xs:enumeration value="slides-round-rect-callout1"><xs:annotation><xs:documentation>幻灯片圆角矩形标注1</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="slides-round-rect-callout2"><xs:annotation><xs:documentation>幻灯片圆角矩形标注2</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="slides-round-rect-callout3"><xs:annotation><xs:documentation>幻灯片圆角矩形标注3</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="slides-wedge-rect-callout"><xs:annotation><xs:documentation>幻灯片楔形矩形标注</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="slides-wedge-round-rect-callout"><xs:annotation><xs:documentation>幻灯片楔形圆角矩形标注</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="callout1"><xs:annotation><xs:documentation>标注1</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="callout2"><xs:annotation><xs:documentation>标注2</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="callout3"><xs:annotation><xs:documentation>标注3</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="accent-callout1"><xs:annotation><xs:documentation>强调标注1</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="accent-callout2"><xs:annotation><xs:documentation>强调标注2</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="accent-callout3"><xs:annotation><xs:documentation>强调标注3</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="border-callout1"><xs:annotation><xs:documentation>边框标注1</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="border-callout2"><xs:annotation><xs:documentation>边框标注2</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="border-callout3"><xs:annotation><xs:documentation>边框标注3</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="accent-border-callout1"><xs:annotation><xs:documentation>强调边框标注1</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="accent-border-callout2"><xs:annotation><xs:documentation>强调边框标注2</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="accent-border-callout3"><xs:annotation><xs:documentation>强调边框标注3</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="wedge-rect-callout"><xs:annotation><xs:documentation>楔形矩形标注</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="wedge-round-rect-callout"><xs:annotation><xs:documentation>楔形圆角矩形标注</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="wedge-ellipse-callout"><xs:annotation><xs:documentation>楔形椭圆标注</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="cloud-callout"><xs:annotation><xs:documentation>云形标注</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="right-arrow-callout"><xs:annotation><xs:documentation>右箭头标注</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="left-arrow-callout"><xs:annotation><xs:documentation>左箭头标注</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="up-arrow-callout"><xs:annotation><xs:documentation>上箭头标注</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="down-arrow-callout"><xs:annotation><xs:documentation>下箭头标注</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="left-right-arrow-callout"><xs:annotation><xs:documentation>左右箭头标注</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="up-down-arrow-callout"><xs:annotation><xs:documentation>上下箭头标注</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="quad-arrow-callout"><xs:annotation><xs:documentation>四向箭头标注</xs:documentation></xs:annotation></xs:enumeration>

            <!-- 流程图系列 -->
            <xs:enumeration value="flow-chart-process"><xs:annotation><xs:documentation>流程图:处理</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-alternate-process"><xs:annotation><xs:documentation>流程图:可选处理</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-decision"><xs:annotation><xs:documentation>流程图:决策</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-input-output"><xs:annotation><xs:documentation>流程图:输入输出</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-predefined-process"><xs:annotation><xs:documentation>流程图:预定义处理</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-internal-storage"><xs:annotation><xs:documentation>流程图:内部存储</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-document"><xs:annotation><xs:documentation>流程图:文档</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-multidocument"><xs:annotation><xs:documentation>流程图:多文档</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-terminator"><xs:annotation><xs:documentation>流程图:终止</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-preparation"><xs:annotation><xs:documentation>流程图:准备</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-manual-input"><xs:annotation><xs:documentation>流程图:手动输入</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-manual-operation"><xs:annotation><xs:documentation>流程图:手动操作</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-connector"><xs:annotation><xs:documentation>流程图:连接器</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-offpage-connector"><xs:annotation><xs:documentation>流程图:跨页连接器</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-punched-card"><xs:annotation><xs:documentation>流程图:穿孔卡片</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-punched-tape"><xs:annotation><xs:documentation>流程图:穿孔纸带</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-summing-junction"><xs:annotation><xs:documentation>流程图:汇总结点</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-or"><xs:annotation><xs:documentation>流程图:或</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-collate"><xs:annotation><xs:documentation>流程图:对照</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-sort"><xs:annotation><xs:documentation>流程图:排序</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-extract"><xs:annotation><xs:documentation>流程图:摘录</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-merge"><xs:annotation><xs:documentation>流程图:合并</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-offline-storage"><xs:annotation><xs:documentation>流程图:脱机存储</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-online-storage"><xs:annotation><xs:documentation>流程图:联机存储</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-magnetic-tape"><xs:annotation><xs:documentation>流程图:磁带</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-magnetic-disk"><xs:annotation><xs:documentation>流程图:磁盘</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-magnetic-drum"><xs:annotation><xs:documentation>流程图:磁鼓</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-display"><xs:annotation><xs:documentation>流程图:显示</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="flow-chart-delay"><xs:annotation><xs:documentation>流程图:延迟</xs:documentation></xs:annotation></xs:enumeration>

            <!-- 操作按钮系列 -->
            <xs:enumeration value="action-button-blank"><xs:annotation><xs:documentation>操作按钮:空白</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="action-button-home"><xs:annotation><xs:documentation>操作按钮:主页</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="action-button-help"><xs:annotation><xs:documentation>操作按钮:帮助</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="action-button-information"><xs:annotation><xs:documentation>操作按钮:信息</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="action-button-forward-next"><xs:annotation><xs:documentation>操作按钮:前进/下一个</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="action-button-back-previous"><xs:annotation><xs:documentation>操作按钮:后退/上一个</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="action-button-end"><xs:annotation><xs:documentation>操作按钮:结束</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="action-button-beginning"><xs:annotation><xs:documentation>操作按钮:开始</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="action-button-return"><xs:annotation><xs:documentation>操作按钮:返回</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="action-button-document"><xs:annotation><xs:documentation>操作按钮:文档</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="action-button-sound"><xs:annotation><xs:documentation>操作按钮:声音</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="action-button-movie"><xs:annotation><xs:documentation>操作按钮:影片</xs:documentation></xs:annotation></xs:enumeration>

            <!-- 其他特殊形状 -->
            <xs:enumeration value="slides-home-plate"><xs:annotation><xs:documentation>幻灯片本垒板形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="home-plate"><xs:annotation><xs:documentation>本垒板形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="slides-chevron"><xs:annotation><xs:documentation>幻灯片V形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="chevron"><xs:annotation><xs:documentation>V形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="pie"><xs:annotation><xs:documentation>饼形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="pie-wedge"><xs:annotation><xs:documentation>饼状楔形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="donut"><xs:annotation><xs:documentation>圆环</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="arc"><xs:annotation><xs:documentation>弧形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="slides-block-arc"><xs:annotation><xs:documentation>幻灯片块弧形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="block-arc"><xs:annotation><xs:documentation>块弧形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="chord"><xs:annotation><xs:documentation>弦形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="left-brace"><xs:annotation><xs:documentation>左大括号</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="right-brace"><xs:annotation><xs:documentation>右大括号</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="left-bracket"><xs:annotation><xs:documentation>左中括号</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="right-bracket"><xs:annotation><xs:documentation>右中括号</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="brace-pair"><xs:annotation><xs:documentation>大括号对</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="bracket-pair"><xs:annotation><xs:documentation>中括号对</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="teardrop"><xs:annotation><xs:documentation>泪滴形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="can"><xs:annotation><xs:documentation>圆柱形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="cube"><xs:annotation><xs:documentation>立方体</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="bevel"><xs:annotation><xs:documentation>斜面</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="folded-corner"><xs:annotation><xs:documentation>折角</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="smiley-face"><xs:annotation><xs:documentation>笑脸</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="heart"><xs:annotation><xs:documentation>心形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="lightning-bolt"><xs:annotation><xs:documentation>闪电</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="sun"><xs:annotation><xs:documentation>太阳</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="moon"><xs:annotation><xs:documentation>月亮</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="cloud"><xs:annotation><xs:documentation>云</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="irregular-seal1"><xs:annotation><xs:documentation>不规则印章1</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="irregular-seal2"><xs:annotation><xs:documentation>不规则印章2</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="ribbon"><xs:annotation><xs:documentation>缎带</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="ribbon2"><xs:annotation><xs:documentation>缎带2</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="ellipse-ribbon"><xs:annotation><xs:documentation>椭圆形缎带</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="ellipse-ribbon2"><xs:annotation><xs:documentation>椭圆形缎带2</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="left-right-ribbon"><xs:annotation><xs:documentation>左右缎带</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="vertical-scroll"><xs:annotation><xs:documentation>垂直卷轴</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="horizontal-scroll"><xs:annotation><xs:documentation>水平卷轴</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="wave"><xs:annotation><xs:documentation>波形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="double-wave"><xs:annotation><xs:documentation>双波形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="plus"><xs:annotation><xs:documentation>加号</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="math-plus"><xs:annotation><xs:documentation>数学加号</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="math-minus"><xs:annotation><xs:documentation>数学减号</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="math-multiply"><xs:annotation><xs:documentation>数学乘号</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="math-divide"><xs:annotation><xs:documentation>数学除号</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="math-equal"><xs:annotation><xs:documentation>数学等号</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="math-not-equal"><xs:annotation><xs:documentation>数学不等号</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="corner"><xs:annotation><xs:documentation>直角</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="diag-stripe"><xs:annotation><xs:documentation>对角条纹</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="frame"><xs:annotation><xs:documentation>框架</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="half-frame"><xs:annotation><xs:documentation>半框架</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="funnel"><xs:annotation><xs:documentation>漏斗</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="gear6"><xs:annotation><xs:documentation>6齿齿轮</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="gear9"><xs:annotation><xs:documentation>9齿齿轮</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="plaque"><xs:annotation><xs:documentation>匾额</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="plaque-tabs"><xs:annotation><xs:documentation>匾额标签</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="corner-tabs"><xs:annotation><xs:documentation>角标签</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="square-tabs"><xs:annotation><xs:documentation>方形标签</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="chart-plus"><xs:annotation><xs:documentation>图表加号</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="chart-star"><xs:annotation><xs:documentation>图表星形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="chart-x"><xs:annotation><xs:documentation>图表X</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="no-smoking"><xs:annotation><xs:documentation>禁止吸烟</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="line-inv"><xs:annotation><xs:documentation>反转线</xs:documentation></xs:annotation></xs:enumeration>
        </xs:restriction>
    </xs:simpleType>

    <!-- 箭头类型 -->
    <xs:simpleType name="ArrowType">
        <xs:annotation>
            <xs:documentation>箭头类型枚举定义</xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:string">
            <xs:enumeration value="none"> <xs:annotation><xs:documentation>无箭头</xs:documentation></xs:annotation> </xs:enumeration>
            <xs:enumeration value="arrow"> <xs:annotation><xs:documentation>基本箭头</xs:documentation></xs:annotation> </xs:enumeration>
            <xs:enumeration value="empty-triangle"> <xs:annotation><xs:documentation>空心三角形箭头</xs:documentation></xs:annotation> </xs:enumeration>
            <xs:enumeration value="solid-triangle"> <xs:annotation><xs:documentation>实心三角形箭头</xs:documentation></xs:annotation> </xs:enumeration>
            <xs:enumeration value="empty-diamond"> <xs:annotation><xs:documentation>空心菱形箭头</xs:documentation></xs:annotation> </xs:enumeration>
            <xs:enumeration value="solid-diamond"> <xs:annotation><xs:documentation>实心菱形箭头</xs:documentation></xs:annotation> </xs:enumeration>
            <xs:enumeration value="empty-circle">  <xs:annotation><xs:documentation>空心圆形箭头</xs:documentation></xs:annotation> </xs:enumeration>
            <xs:enumeration value="solid-circle"> <xs:annotation><xs:documentation>实心圆形箭头</xs:documentation></xs:annotation> </xs:enumeration>
        </xs:restriction>
    </xs:simpleType>

    <!-- 箭头大小类型 -->
    <xs:simpleType name="ArrowScaleType">
        <xs:annotation>
            <xs:documentation>箭头缩放等级</xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:string">
            <xs:enumeration value="sm"><xs:annotation><xs:documentation>小, 对应于 ECMA-376 ST_LineEndLength / ST_LineEndWidth sm</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="med"><xs:annotation><xs:documentation>中, 对应于 ECMA-376 ST_LineEndLength / ST_LineEndWidth med</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="lg"><xs:annotation><xs:documentation>大, 对应于 ECMA-376 ST_LineEndLength / ST_LineEndWidth lg</xs:documentation></xs:annotation></xs:enumeration>
        </xs:restriction>
    </xs:simpleType>

    <!-- 不透明度类型, 范围 [0,1] -->
    <xs:simpleType name="AlphaType">
        <xs:restriction base="xs:double">
            <xs:minInclusive value="0"/>
            <xs:maxInclusive value="1"/>
        </xs:restriction>
    </xs:simpleType>

    <!-- 比例类型 [0,1] -->
    <xs:simpleType name="RatioType">
        <xs:restriction base="xs:double">
            <xs:minInclusive value="0"/>
            <xs:maxInclusive value="1"/>
        </xs:restriction>
    </xs:simpleType>

    <!-- 整数范围类型:[0,200] -->
    <xs:simpleType name="Range0To200Type">
        <xs:annotation>
            <xs:documentation>
                整数范围类型:[0,200]
            </xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:nonNegativeInteger">
            <xs:minInclusive value="0"/>
            <xs:maxInclusive value="200"/>
        </xs:restriction>
    </xs:simpleType>

    <!-- 小数范围类型:[0,200] -->
    <xs:simpleType name="Range0To200DotType">
        <xs:annotation>
            <xs:documentation>
                小数范围类型:[0,200]
            </xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:double">
            <xs:minInclusive value="0"/>
            <xs:maxInclusive value="200"/>
        </xs:restriction>
    </xs:simpleType>

    <!-- 顶点X坐标 -->
    <xs:simpleType name="XType">
        <xs:restriction base="xs:double">
            <xs:minInclusive value="-8640"/>
            <xs:maxInclusive value="10560"/>
        </xs:restriction>
    </xs:simpleType>

    <!-- 顶点Y坐标 -->
    <xs:simpleType name="YType">
        <xs:restriction base="xs:double">
            <xs:minInclusive value="-4860"/>
            <xs:maxInclusive value="5940"/>
        </xs:restriction>
    </xs:simpleType>

    <!-- 虚线类型 -->
    <xs:simpleType name="DashArrayType">
        <xs:restriction base="xs:string">
            <xs:enumeration value="solid"><xs:annotation><xs:documentation>实线</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="dash"><xs:annotation><xs:documentation>短划线</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="dot"><xs:annotation><xs:documentation>方点</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="long-dash"><xs:annotation><xs:documentation>长划线</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="round-dot"><xs:annotation><xs:documentation>圆点</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="sys-dot"><xs:annotation><xs:documentation>系统圆点</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="sys-dash"><xs:annotation><xs:documentation>系统方点</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="dash-dot"><xs:annotation><xs:documentation>短划线-点</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="long-dash-dot"><xs:annotation><xs:documentation>长划线-点</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="long-dash-dot-dot"><xs:annotation><xs:documentation>长划线-点-点</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="sys-dash-dot"><xs:annotation><xs:documentation>系统划线-点</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="sys-dash-dot-dot"><xs:annotation><xs:documentation>系统划线-点-点</xs:documentation></xs:annotation></xs:enumeration>
        </xs:restriction>
    </xs:simpleType>

    <!-- Chart值类型:只支持number和string -->
    <xs:simpleType name="ChartValueType">
        <xs:restriction base="xs:string">
            <xs:enumeration value="number"/>
            <xs:enumeration value="string"/>
        </xs:restriction>
    </xs:simpleType>

    <!-- 图表类型枚举 -->
    <xs:simpleType name="ChartType">
        <xs:restriction base="xs:string">
            <xs:enumeration value="pie"><xs:annotation><xs:documentation>饼图</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="column"><xs:annotation><xs:documentation>柱状图</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="bar"><xs:annotation><xs:documentation>条形图</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="line"><xs:annotation><xs:documentation>折线图</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="area"><xs:annotation><xs:documentation>面积图</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="radar"><xs:annotation><xs:documentation>雷达图</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="combo"><xs:annotation><xs:documentation>组合图</xs:documentation></xs:annotation></xs:enumeration>
        </xs:restriction>
    </xs:simpleType>

    <!-- 填充样式类型 -->
    <xs:complexType name="FillType">
        <xs:annotation>
            <xs:documentation>
                填充类型:
                - color: 颜色填充 (RGB/RGBA格式)
                - fillImg: 填充图片
                - fillPattern: 填充图案
                优先级: fillPattern > fillImg > color
            </xs:documentation>
        </xs:annotation>
        <xs:choice minOccurs="0">
            <xs:element name="fillImg" type="sml:FillImgType"/>
            <xs:element name="fillPattern" type="sml:FillPatternType"/>
            <xs:element name="fillColor" type="sml:FillColorType"/>
        </xs:choice>
    </xs:complexType>

    <!-- Pattern 填充类型 -->
    <xs:complexType name="FillPatternType">
        <xs:annotation>
            <xs:documentation>
                背景图案配置
                基础属性:
                - type: 图案类型, 可选, 默认值为pct5
                - foregroundColor: 前景色(纯色), 可选, 默认跟随字体颜色
                - backgroundColor: 背景色(纯色), 可选, 默认为白色 rgb(255, 255, 255)
                - alpha: 不透明度
            </xs:documentation>
        </xs:annotation>
        <xs:attribute name="type" type="sml:PatternType" use="optional" default="pct5"/>
        <xs:attribute name="foregroundColor" type="sml:SolidColor" use="optional"/>
        <xs:attribute name="backgroundColor" type="sml:SolidColor" use="optional" default="rgb(255, 255, 255)"/>
        <xs:attribute name="alpha" type="sml:AlphaType" use="optional" default="1"/>
    </xs:complexType>


    <!-- Color 填充类型 -->
    <xs:complexType name="FillColorType">
        <xs:annotation>
            <xs:documentation>
                颜色填充配置
                - color: 纯色和渐变色
                - rotateWithShape: 是否随形状旋转, 只对渐变色有效
            </xs:documentation>
        </xs:annotation>
        <xs:attribute name="color" type="sml:Color" use="optional"/>
        <xs:attribute name="rotateWithShape" type="xs:boolean" use="optional" default="true"/>
    </xs:complexType>


    <!-- 填充图片类型 -->
    <xs:complexType name="FillImgType">
        <xs:annotation>
            <xs:documentation>
                图片填充配置
                基础属性:
                - src: 图片资源标识, 必填
                - alt: 图片替代文本/名称, 可选, 默认为空
                - rotateWithShape: 是否随形状旋转, 可选, 默认为true
                - alpha: 图片不透明度, 可选, 范围[0, 1], 默认为1

                拉伸填充属性:
                - stretchLeft/stretchRight: 左右拉伸比例, 可选, 范围[-1000, 1000], (对应 -100000% 到 100000%)
                - stretchTop/stretchBottom: 上下拉伸比例, 可选, 范围[-1000, 1000], (对应 -100000% 到 100000%)

                平铺填充属性:
                - flip: 平铺翻转方向, 可选
                - align: 平铺对齐方式, 可选
                - horizontalOffset/verticalOffset: 水平/垂直偏移, 可选
                - horizontalRatio/verticalRatio: 水平/垂直缩放比例, 可选, 范围[0, 1]

                注意: 拉伸和平铺属性不能同时使用
            </xs:documentation>
        </xs:annotation>

        <!-- 基础属性 -->
        <xs:attribute name="src" type="xs:string" use="required"/>
        <xs:attribute name="alt" type="xs:string" use="optional"/>
        <xs:attribute name="rotateWithShape" type="xs:boolean" use="optional" default="true"/>
        <xs:attribute name="alpha" type="sml:AlphaType" use="optional" default="1"/>

        <!-- 拉伸填充属性 -->
        <xs:attribute name="stretchLeft" type="xs:double" use="optional" />
        <xs:attribute name="stretchRight" type="xs:double" use="optional" />
        <xs:attribute name="stretchTop" type="xs:double" use="optional" />
        <xs:attribute name="stretchBottom" type="xs:double" use="optional" />

        <!-- 平铺填充属性 -->
        <xs:attribute name="flip" type="sml:TileFlipType" use="optional" />
        <xs:attribute name="align" type="sml:TileAlignType" use="optional"/>
        <xs:attribute name="horizontalOffset" type="xs:double" use="optional"/>
        <xs:attribute name="verticalOffset" type="xs:double" use="optional"/>
        <xs:attribute name="horizontalRatio" type="sml:RatioType" use="optional"/>
        <xs:attribute name="verticalRatio" type="sml:RatioType" use="optional"/>
    </xs:complexType>

    <!-- 边框样式类型 -->
    <xs:complexType name="BorderType">
        <xs:annotation>
            <xs:documentation>
                通用边框样式类型
                - color: 边框颜色
                - width: 边框宽度(像素)
                - dashArray: 虚线样式
                - compound: 复合边框样式
                - lineCap: 线端样式
                - lineJoin: 线连接样式
                - miterLimit: 斜接限制
                - rotateWithShape: 是否随形状旋转
                注意: 空边框标签表示默认无边框, 默认样式跟随不同的元素
            </xs:documentation>
        </xs:annotation>
        <xs:attribute name="color" type="sml:Color" use="optional"/>
        <!-- 边框宽度(像素), 只支持整数 -->
        <xs:attribute name="width" type="xs:nonNegativeInteger" use="optional"/>
        <xs:attribute name="dashArray" type="sml:DashArrayType" use="optional" default="solid"/>
        <xs:attribute name="compound" type="sml:CompoundType" use="optional" default="single"/>
        <xs:attribute name="lineCap" type="sml:LineCapType" use="optional" default="butt"/>
        <xs:attribute name="lineJoin" type="sml:LineJoinType" use="optional" default="round"/>
        <xs:attribute name="miterLimit" type="xs:nonNegativeInteger" use="optional" default="8"/>
        <xs:attribute name="rotateWithShape" type="xs:boolean" use="optional" default="true"/>
    </xs:complexType>

    <!-- 起始箭头样式类型 -->
    <xs:complexType name="StartArrowStyleType">
        <xs:annotation>
            <xs:documentation>
                起始箭头样式类型
                - type: 起始箭头类型
                - widthScale: 箭头宽度
                - heightScale: 箭头高度
                如果type为none, 则表示无箭头, 其他属性无效
            </xs:documentation>
        </xs:annotation>
        <xs:attribute name="type" type="sml:ArrowType" use="optional" default="none"/>
        <xs:attribute name="widthScale" type="sml:ArrowScaleType" use="optional" />
        <xs:attribute name="heightScale" type="sml:ArrowScaleType" use="optional" />
    </xs:complexType>

    <!-- 结束箭头样式类型 -->
    <xs:complexType name="EndArrowStyleType">
        <xs:annotation>
            <xs:documentation>
                结束箭头样式类型
                - type: 结束箭头类型, 可选, 默认arrow
                - widthScale: 箭头宽度
                - heightScale: 箭头高度
                如果type为none, 则表示无箭头, 其他属性无效
            </xs:documentation>
        </xs:annotation>
        <xs:attribute name="type" type="sml:ArrowType" use="optional" default="none"/>
        <xs:attribute name="widthScale" type="sml:ArrowScaleType" use="optional" />
        <xs:attribute name="heightScale" type="sml:ArrowScaleType" use="optional"/>
    </xs:complexType>

    <!-- 裁剪方位枚举类型 -->
    <xs:simpleType name="CropAnchorType">
        <xs:annotation>
            <xs:documentation>
                裁剪方位枚举, 用于指定保留原图的哪个区域

                - top: 保留顶部, 裁掉底部多余部分
                - bottom: 保留底部, 裁掉顶部多余部分
                - left: 保留左侧, 裁掉右侧多余部分
                - right: 保留右侧, 裁掉左侧多余部分

                居中场景不需要设置 anchor, 不设置 offset 即为默认居中裁剪
            </xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:string">
            <xs:enumeration value="top"><xs:annotation><xs:documentation>保留顶部, 裁掉底部多余部分</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="bottom"><xs:annotation><xs:documentation>保留底部, 裁掉顶部多余部分</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="left"><xs:annotation><xs:documentation>保留左侧, 裁掉右侧多余部分</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="right"><xs:annotation><xs:documentation>保留右侧, 裁掉左侧多余部分</xs:documentation></xs:annotation></xs:enumeration>
        </xs:restriction>
    </xs:simpleType>

    <!-- 裁剪类型定义 -->
    <xs:complexType name="CropType">
        <xs:annotation>
            <xs:documentation>
                裁剪配置: 将原图裁剪到目标尺寸 (img 元素的 width × height)

                可选属性:
                type: 裁剪形状, 默认 rect
                path: 自定义裁剪路径, type="custom" 时生效, SVG path 字符串, 坐标系为裁剪框本地坐标系
                anchor: 裁剪方位 (top/bottom/left/right), 参见 CropAnchorType
                leftOffset / rightOffset / topOffset / bottomOffset: 四向偏移量 (px), 正值向内裁剪、负值向外扩展留白、0对齐边缘
                presetHandlers: 控制点配置, 对应 ECMA 预设形状的控制点。单个或多个数字, 多个用逗号分隔。
                示例: type="rect" 且 presetHandlers="60" 时为圆角矩形, 圆角半径 60px

                【推荐用法】使用 anchor 指定裁剪方位:
                - 不设置 anchor 时: 默认按图片居中裁剪
                - 设置 anchor 时: 按指定方位裁剪, 例如 anchor="top" 表示保留顶部、裁掉底部多余部分
                - 使用 anchor 后, 不需要再设置 offset
                - anchor 模式下原图按等比缩放后裁剪, 不会发生拉伸或压缩

                【进阶用法】使用 offset 精细控制裁剪边界:
                - 适用于用户在编辑器中手动调整裁剪、或从外部协议导入的场景
                - 原图先填充到预裁剪区域, 再根据 offset 从四边裁出最终尺寸
                - 注意: 若预裁剪尺寸与原图比例不一致会产生拉伸变形; 无法确定原图比例时, 不要指定 offset

                【优先级】
                如果同时设置了 anchor 和 offset, 以 anchor 为准, offset 被忽略

                典型用法:
                <crop/>                                      居中裁剪 (默认行为)
                <crop anchor="top"/>                        保留顶部
                <crop anchor="left"/>                       保留左侧
                <crop type="rect" presetHandlers="60"/>     圆角矩形裁剪, 默认居中
            </xs:documentation>
        </xs:annotation>
        <xs:attribute name="type" type="sml:ShapeType" use="optional" default="rect"/>
        <xs:attribute name="path" type="xs:string" use="optional"/>
        <xs:attribute name="anchor" type="sml:CropAnchorType" use="optional"/>
        <xs:attribute name="leftOffset" type="xs:double" use="optional"/>
        <xs:attribute name="rightOffset" type="xs:double" use="optional"/>
        <xs:attribute name="topOffset" type="xs:double" use="optional"/>
        <xs:attribute name="bottomOffset" type="xs:double" use="optional"/>
        <xs:attribute name="presetHandlers" type="xs:string" use="optional" />
    </xs:complexType>

    <!-- 倒影效果类型 -->
    <xs:complexType name="ReflectionType">
        <xs:annotation>
            <xs:documentation>
                倒影效果配置
                - alpha: 倒影不透明度, 取值范围 [0,1]
                - offset: 倒影偏移量, 取值范围 [0,200](倒影与原始元素的间距, 单位为像素)
                - size: 倒影大小, 取值范围 [0,1](相对于原始元素高度的比例)
                注意:空标签表示默认阴影效果(alpha=1, offset=8, size=0.3)
            </xs:documentation>
        </xs:annotation>
        <xs:attribute name="alpha" type="sml:AlphaType" use="optional" default="1" />
        <xs:attribute name="offset" type="sml:Range0To200Type" use="optional" default="8" />
        <xs:attribute name="size" type="sml:RatioType" use="optional" default="0.3"/>
    </xs:complexType>

    <!-- 柔化边缘效果类型 -->
    <xs:complexType name="SoftEdgeType">
        <xs:annotation>
            <xs:documentation>
                柔化边缘效果配置
                - radius: 柔化边缘半径(像素), 取值范围 [0, +∞)
                注意: 无softEdge标签表示无柔化边缘效果, 空softEdge标签表示默认柔化边缘效果(radius=2)
            </xs:documentation>
        </xs:annotation>
        <xs:attribute name="radius" type="sml:NonNegativeDouble" use="optional" default="2"/>
    </xs:complexType>

    <!-- 阴影效果类型 -->
    <xs:complexType name="ShadowType">
        <xs:annotation>
            <xs:documentation>
                阴影效果配置
                基础属性:
                - color: 阴影颜色
                - offset: 阴影偏移距离(像素), 取值范围 [0, 200](阴影距离元素的距离, 单位为像素)
                - angle: 阴影角度, 取值范围 [0, 360)(0度为向右, 90度为向下, 顺时针)
                - blur: 模糊半径(像素), 取值范围 [0, 100], 值越大越模糊
                变形属性(可选):
                - hScale: 水平缩放比例, 取值范围 [-2, 2](1为原始大小, 小于1缩小, 大于1放大)
                - vScale: 垂直缩放比例, 取值范围 [-2, 2](1为原始大小, 小于1缩小, 大于1放大)
                - hSkew: 水平斜切角度, 取值范围 [-90, 90](负值向左倾斜, 正值向右倾斜)
                - vSkew: 垂直斜切角度, 取值范围 [-90, 90](负值向上倾斜, 正值向下倾斜)
                对齐方式:
                - shadowAlign: 阴影相对于元素的对齐方式(默认为top-left左上对齐)
                注意:空标签表示默认阴影效果(color=rgba(0, 0, 0, 0.25), offset=15, blur=35, angle=45)
            </xs:documentation>
        </xs:annotation>
        <xs:attribute name="color" type="sml:Color" use="optional" default="rgba(0, 0, 0, 0.25)"/>
        <xs:attribute name="offset" type="sml:Range0To200DotType" use="optional" default="15" />
        <xs:attribute name="blur" type="sml:PercentageType" use="optional" default="35"/>
        <xs:attribute name="angle" type="sml:RotationType" use="optional" default="45"/>
        <xs:attribute name="hScale" type="sml:ScaleType" use="optional" default="1"/>
        <xs:attribute name="vScale" type="sml:ScaleType" use="optional" default="1"/>
        <xs:attribute name="hSkew" type="sml:SkewType" use="optional" default="0"/>
        <xs:attribute name="vSkew" type="sml:SkewType" use="optional" default="0"/>
        <xs:attribute name="align" type="sml:ShadowAlignType" use="optional" default="top-left"/>
    </xs:complexType>

    <!-- 文本轮廓类型 -->
    <xs:complexType name="OutlineType">
        <xs:annotation>
            <xs:documentation>
                文本轮廓配置
                属性:
                - color: 轮廓颜色
                - width: 轮廓宽度
                - dashArray: 虚线样式
                - compound: 复合边框样式
                - lineCap: 线端样式
                - lineJoin: 线连接样式
                - miterLimit: 斜接限制
            </xs:documentation>
        </xs:annotation>
        <xs:attribute name="color" type="sml:Color" use="optional" default="rgba(0, 0, 0, 0.25)"/>
        <xs:attribute name="width" type="sml:NonNegativeDouble" use="optional" default="2"/>
        <xs:attribute name="dashArray" type="sml:DashArrayType" use="optional" default="solid"/>
        <xs:attribute name="compound" type="sml:CompoundType" use="optional" default="single"/>
        <xs:attribute name="lineCap" type="sml:LineCapType" use="optional" default="butt"/>
        <xs:attribute name="lineJoin" type="sml:LineJoinType" use="optional" default="round"/>
        <xs:attribute name="miterLimit" type="xs:nonNegativeInteger" use="optional" default="8"/>
    </xs:complexType>

    <!-- ==================== 主题相关类型 ==================== -->

    <!-- 文本样式集合类型 -->
    <xs:complexType name="TextStylesType">
        <xs:sequence>
            <xs:element name="title" minOccurs="0">
                <xs:complexType>
                    <xs:attribute name="fontFamily" type="sml:FontFamilyType" default="思源黑体"/>
                    <xs:attribute name="fontSize" type="sml:FontSizeType" default="54"/>
                    <xs:attribute name="fontColor" type="sml:Color" default="rgba(0, 0, 0, 0.25)"/>
                </xs:complexType>
            </xs:element>

            <xs:element name="headline" minOccurs="0">
                <xs:complexType>
                    <xs:attribute name="fontFamily" type="sml:FontFamilyType" default="思源黑体"/>
                    <xs:attribute name="fontSize" type="sml:FontSizeType" default="38"/>
                    <xs:attribute name="fontColor" type="sml:Color" default="rgba(0, 0, 0, 0.25)"/>
                </xs:complexType>
            </xs:element>

            <xs:element name="sub-headline" minOccurs="0">
                <xs:complexType>
                    <xs:attribute name="fontFamily" type="sml:FontFamilyType" default="思源黑体"/>
                    <xs:attribute name="fontSize" type="sml:FontSizeType" default="32"/>
                    <xs:attribute name="fontColor" type="sml:Color" default="rgba(0, 0, 0, 0.25)"/>
                </xs:complexType>
            </xs:element>

            <xs:element name="body" minOccurs="0">
                <xs:complexType>
                    <xs:attribute name="fontFamily" type="sml:FontFamilyType" default="思源黑体"/>
                    <xs:attribute name="fontSize" type="sml:FontSizeType" default="16"/>
                    <xs:attribute name="fontColor" type="sml:Color" default="rgba(0, 0, 0, 0.25)"/>
                </xs:complexType>
            </xs:element>

            <xs:element name="caption" minOccurs="0">
                <xs:complexType>
                    <xs:attribute name="fontFamily" type="sml:FontFamilyType" default="思源黑体"/>
                    <xs:attribute name="fontSize" type="sml:FontSizeType" default="12"/>
                    <xs:attribute name="fontColor" type="sml:Color" default="rgba(128, 128, 128, 1)"/>
                </xs:complexType>
            </xs:element>
        </xs:sequence>
    </xs:complexType>

    <!-- 主题类型 -->
    <xs:complexType name="ThemeType">
        <xs:annotation>
            <xs:documentation>
                主题类型
                子元素:
                - background: 背景填充, 可选, 默认白色rgba(255, 255, 255, 1)
                - textStyles: 文本样式集合
            </xs:documentation>
        </xs:annotation>
        <xs:sequence>
            <xs:element name="background" type="sml:FillType" minOccurs="0"/>
            <xs:element name="textStyles" type="sml:TextStylesType" minOccurs="0"/>
        </xs:sequence>
    </xs:complexType>

    <!-- ==================== 幻灯片类型 ==================== -->
    <xs:complexType name="SlideType">
        <xs:annotation>
            <xs:documentation>
                单页幻灯片结构
                子元素:
                - style: 页面样式(背景色等), style的fill默认颜色为白色rgba(255, 255, 255, 1)
                - data: 页面元素容器(shape/line/polyline/img/table/icon/embed/chart/undefined)
                - note: 演讲者备注
            </xs:documentation>
        </xs:annotation>
        <xs:sequence>
            <!-- 样式 -->
            <xs:element name="style" minOccurs="0">
                <xs:complexType>
                    <xs:all>
                        <xs:element name="fill" type="sml:FillType" minOccurs="0"/>
                    </xs:all>
                </xs:complexType>
            </xs:element>

            <!-- 数据元素 -->
            <xs:element name="data" minOccurs="0">
                <xs:complexType>
                    <xs:choice minOccurs="0" maxOccurs="unbounded">
                        <xs:element ref="sml:shape"/>
                        <xs:element ref="sml:line"/>
                        <xs:element ref="sml:polyline"/>
                        <xs:element ref="sml:img"/>
                        <xs:element ref="sml:table"/>
                        <xs:element ref="sml:icon"/>
                        <xs:element ref="sml:embed"/>
                        <xs:element ref="sml:chart"/>
                        <xs:element ref="sml:undefined"/>
                    </xs:choice>
                </xs:complexType>
            </xs:element>

            <!-- 演讲者备注 -->
            <xs:element name="note" minOccurs="0">
                <xs:annotation>
                    <xs:documentation>
                        演讲者备注, 仅在演示模式显示
                        支持完整的内容结构
                        属性:
                        - id: 备注唯一标识符

                        子元素:
                        - content: 备注文本内容容器
                    </xs:documentation>
                </xs:annotation>
                <xs:complexType>
                    <xs:all>
                        <xs:element ref="sml:content" minOccurs="0"/>
                    </xs:all>
                    <xs:attribute name="id" type="xs:string" use="optional"/>
                </xs:complexType>
            </xs:element>
        </xs:sequence>
        <!-- id属性:幻灯片唯一标识符, 用途:便于引用和链接到特定幻灯片 -->
        <xs:attribute name="id" type="xs:string" use="optional"/>
    </xs:complexType>

    <!-- ==================== 演示文稿类型(根元素) ==================== -->

    <xs:complexType name="PresentationType">
        <xs:annotation>
            <xs:documentation>
                演示文稿根类型, 包含整个幻灯片文档结构
                - title: 演示文稿标题(可选)
                - theme: 全局主题设置(可选)
                - slide: 幻灯片页面(1-100页)
                属性:
                - id: 演示文稿唯一标识符(可选)
                - width: 演示文稿宽度像素值(必选)
                - height: 演示文稿高度像素值(必选)
            </xs:documentation>
        </xs:annotation>
        <xs:sequence>
            <xs:element name="title" type="xs:string" minOccurs="0"/>
            <xs:element name="theme" type="sml:ThemeType" minOccurs="0"/>
            <xs:element name="slide" type="sml:SlideType" minOccurs="1" maxOccurs="100"/>
        </xs:sequence>
        <xs:attribute name="id" type="xs:string" use="optional"/>
        <xs:attribute name="width" type="xs:positiveInteger" use="required"/>
        <xs:attribute name="height" type="xs:positiveInteger" use="required"/>
    </xs:complexType>

    <!-- ==================== 根元素定义 ==================== -->

    <xs:element name="presentation" type="sml:PresentationType"/>

    <!-- ==================== 内容元素定义 ==================== -->
    <xs:element name="content">
        <xs:annotation>
            <xs:documentation>
                文本内容根容器, 用于描述结构化文本

                属性说明:
                - textType: 控制整体文本样式 (title/headline/sub-headline/body/caption)
                - verticalAlign: 内容在容器中的垂直对齐方式
                - paddingTop/Right/Bottom/Left: 内容与容器边缘的间距, 当位于shape中, shape的type为text时, padding默认值各为0, 当shape的type为其他时, padding默认值各为5, 当位于td中, padding默认值各为8
                - lineSpacing: Block 级别行间距
                - beforeLineSpacing/afterLineSpacing: 段落前/后的行间距, 可选, 默认值为fixed:0
                - letterSpacing:  content 级别字间距
                - textAlign: content 级别文本对齐方式, 可选, 当位于shape中, shape的type为text时, textAlign默认值为left, 当shape的type为其他时, textAlign默认值为center
                - fontSize: content 级别字体大小
                - fontFamily: content 级别字体族
                - color: content 级别字体颜色
                - backgroundColor: content 级别背景颜色
                - bold: content 级别是否加粗
                - italic: content 级别是否斜体
                - strikethrough: content 级别是否删除线
                - underline: content 级别是否下划线
                - list: content 级别列表类型 bullet/number
                - listStyle: content 级别列表样式
                - bulletColor: 列表符号颜色(纯色), 可选
                - bulletSize: 列表符号大小, 可选, 百分比字符串(相对于文本字号, 取值范围 25%-400%)如 "100%", 或绝对像素值(取值范围 6-400)如 "14"
                - autoStartAt: 有序列表起始编号, 可选, 取值范围 [1, 32767]; 作为后代段落的初始计数器, 子元素 &lt;p&gt;/&lt;ol&gt; 可通过自身 autoStartAt 重置
                - bulletChar: 自定义列表符号字符, 可选, 如 "★", "→" 等, 设置后覆盖 listStyle 的符号
                - tabStopPos/defaultTabSize: 后代段落的默认 Tab 停止位和步长, p 同名属性可覆盖
                - anchorCenter: 控制文本对齐方式, 优先级高于 textAlign
                - autoFit: 控制文本编辑溢出时处理策略
                - baseline: 上标/下标, 相较于文本基线的偏移量
                - wrap: 是否自动换行

                注意:如果content子元素不指定属性, 默认继承content的属性值, 如果局部子元素指定了属性, 则使用局部属性值

                autoStartAt 运行计数器示例(显式指定重置, 未指定沿用前序计数器):
                &lt;content autoStartAt="5"&gt;
                &lt;p list="number"&gt;A&lt;/p&gt;                    &lt;!-- A=5, 继承 content 初始值 --&gt;
                &lt;p list="number" autoStartAt="10"&gt;B&lt;/p&gt;    &lt;!-- B=10, 本段显式重置 --&gt;
                &lt;p list="number"&gt;C&lt;/p&gt;                    &lt;!-- C=11, 沿用前序计数器递增 --&gt;
                &lt;/content&gt;

                子元素:
                - p: 段落元素
                - ul: 无序列表元素
                - ol: 有序列表元素
            </xs:documentation>
        </xs:annotation>
        <xs:complexType>
            <xs:sequence>
                <xs:choice minOccurs="0" maxOccurs="unbounded">
                    <xs:element ref="sml:p"/>
                    <xs:element ref="sml:ul"/>
                    <xs:element ref="sml:ol"/>
                </xs:choice>
            </xs:sequence>

            <xs:attribute name="textType" type="sml:TextType" default="body"/>
            <xs:attribute name="verticalAlign" type="sml:VerticalAlignType" default="middle"/>
            <xs:attribute name="paddingTop" type="sml:PaddingValueType"/>
            <xs:attribute name="paddingRight" type="sml:PaddingValueType"/>
            <xs:attribute name="paddingBottom" type="sml:PaddingValueType"/>
            <xs:attribute name="paddingLeft" type="sml:PaddingValueType"/>
            <xs:attribute name="lineSpacing" type="sml:LineSpacingType" default="multiple:1.5"/>
            <xs:attribute name="beforeLineSpacing" type="sml:LineSpacingType" default="fixed:0"/>
            <xs:attribute name="afterLineSpacing" type="sml:LineSpacingType" default="fixed:0"/>
            <xs:attribute name="letterSpacing" type="xs:double"/>
            <xs:attribute name="textAlign" type="sml:TextAlignType"/>
            <xs:attribute name="fontSize" type="sml:FontSizeType"/>
            <xs:attribute name="fontFamily" type="sml:FontFamilyType"/>
            <xs:attribute name="color" type="sml:Color"/>
            <xs:attribute name="backgroundColor" type="sml:Color"/>
            <xs:attribute name="bold" type="xs:boolean" />
            <xs:attribute name="italic" type="xs:boolean" />
            <xs:attribute name="strikethrough" type="xs:boolean" />
            <xs:attribute name="underline" type="xs:boolean" />
            <xs:attribute name="list" type="sml:ListType" default="none"/>
            <xs:attribute name="listStyle" type="sml:ListStyleType" />
            <xs:attribute name="bulletColor" type="sml:SolidColor" use="optional"/>
            <xs:attribute name="bulletSize" type="sml:BulletSizeType" use="optional"/>
            <xs:attribute name="autoStartAt" type="sml:AutoStartAtType" use="optional"/>
            <xs:attribute name="bulletChar" type="sml:BulletCharType" use="optional"/>
            <xs:attribute name="tabStopPos" type="sml:TabStopPosType" use="optional"/>
            <xs:attribute name="defaultTabSize" type="sml:TabSizeType" use="optional"/>
            <xs:attribute name="anchorCenter" type="xs:boolean" default="false" /> <!-- 控制竖排文字是否在垂直方向保持居中 -->
            <xs:attribute name="autoFit" type="sml:AutoFitType" default="no-auto-fit" />
            <xs:attribute name="wrap" type="xs:boolean" default="true" />
        </xs:complexType>
    </xs:element>

    <!-- ==================== 段落元素 ==================== -->
    <xs:element name="p">
        <xs:annotation>
            <xs:documentation>
                段落容器, 支持富文本内容
                可包含纯文本和内联格式元素(br/tab/strong/em/u/span/del/a/shadow/outline/formula/field)
                内联元素嵌套:所有内联元素均可包含纯文本或其他内联元素,以实现复杂的格式组合
                元素自嵌套:除a/formula元素外,其余内联元素支持自身嵌套,当shadow和outline自嵌套时,渲染效果遵循就近原则,以内层定义的样式为准

                空格处理规则:
                - 文本内的连续空格会被合并为单个空格
                - 文本开头和结尾的空格会被忽略
                - 需要保留空格时(多个连续、首尾位置),请使用&#32;字符
                - 标签之间的空格和换行符会自动省略,换行请使用<br/>标签

                制表符处理规则:
                - 需要使用制表符时,请使用 tab 元素

                富文本元素说明:
                - br: 换行
                - tab: 制表符
                - strong: 加粗
                - em: 斜体
                - u: 下划线
                - span: 文本样式
                - del: 删除线
                - a: 超链接
                - shadow: 文本阴影
                - outline: 文本轮廓
                - formula: 科学公式(支持数学、物理等)
                - field: 动态文本字段,元素内容作为不支持动态字段时的降级文本
                属性说明:
                - textAlign: 文本对齐方式
                - lineSpacing: 行间距
                - beforeLineSpacing/afterLineSpacing: 段落前/后的行间距
                - letterSpacing: 字间距
                - level: 段落级别, 取值范围 [1,10]
                - list: 列表类型(bullet/number)
                - listStyle: 列表样式
                - bulletColor: 列表符号颜色(纯色), 可选
                - bulletSize: 列表符号大小, 可选, 百分比字符串(相对于文本字号, 取值范围 25%-400%)或绝对像素值(取值范围 6-400)
                - autoStartAt: 有序列表起始编号, 可选, 取值范围 [1, 32767]; 显式指定时从当前段落起重置计数器, 未指定时沿用同一 content 内的前序计数器
                - bulletChar: 自定义列表符号字符, 可选
                - marginLeft: 段落左侧缩进宽度
                - marginRight: 段落右侧缩进宽度
                - indent: 首行缩进宽度
                - tabStopPos/defaultTabSize: 段落级 Tab 配置, 覆盖 content 同名配置
            </xs:documentation>
        </xs:annotation>
        <xs:complexType mixed="true">
            <xs:choice minOccurs="0" maxOccurs="unbounded">
                <xs:element ref="sml:br"/>
                <xs:element ref="sml:tab"/>
                <xs:element ref="sml:formula"/>
                <xs:element ref="sml:strong"/>
                <xs:element ref="sml:em"/>
                <xs:element ref="sml:u"/>
                <xs:element ref="sml:span"/>
                <xs:element ref="sml:del"/>
                <xs:element ref="sml:a"/>
                <xs:element ref="sml:shadow"/>
                <xs:element ref="sml:outline"/>
                <xs:element ref="sml:field"/>
            </xs:choice>
            <xs:attribute name="textAlign" type="sml:TextAlignType" />
            <xs:attribute name="lineSpacing" type="sml:LineSpacingType" default="multiple:1.5"/>
            <xs:attribute name="beforeLineSpacing" type="sml:LineSpacingType" default="fixed:0"/>
            <xs:attribute name="afterLineSpacing" type="sml:LineSpacingType" default="fixed:0"/>
            <xs:attribute name="letterSpacing" type="sml:LetterSpacingType" default="0" />
            <xs:attribute name="level" type="sml:LevelType" default="1"/>
            <xs:attribute name="list" type="sml:ListType" default="none"/>
            <xs:attribute name="listStyle" type="sml:ListStyleType"/>
            <xs:attribute name="bulletColor" type="sml:SolidColor" use="optional"/>
            <xs:attribute name="bulletSize" type="sml:BulletSizeType" use="optional"/>
            <xs:attribute name="autoStartAt" type="sml:AutoStartAtType" use="optional"/>
            <xs:attribute name="bulletChar" type="sml:BulletCharType" use="optional"/>
            <xs:attribute name="marginLeft" type="xs:double" use="optional" />
            <xs:attribute name="marginRight" type="xs:double" use="optional" />
            <xs:attribute name="indent" type="sml:NonNegativeDouble" use="optional"/>
            <xs:attribute name="tabStopPos" type="sml:TabStopPosType" use="optional"/>
            <xs:attribute name="defaultTabSize" type="sml:TabSizeType" use="optional"/>
        </xs:complexType>
    </xs:element>

    <!-- ==================== 列表元素 ==================== -->
    <!-- 无序列表 -->
    <xs:element name="ul">
        <xs:annotation>
            <xs:documentation>
                无序列表
                属性说明:
                - listStyle: 列表样式
                - bulletColor: 列表符号颜色(纯色), 可选
                - bulletSize: 列表符号大小, 可选, 百分比字符串(相对于文本字号, 取值范围 25%-400%)或绝对像素值(取值范围 6-400)
                - bulletChar: 自定义列表符号字符, 可选, 设置后覆盖 listStyle 的符号
            </xs:documentation>
        </xs:annotation>
        <xs:complexType>
            <xs:sequence>
                <xs:element name="li" maxOccurs="unbounded">
                    <xs:complexType>
                        <xs:sequence>
                            <xs:element ref="sml:p"/>
                        </xs:sequence>
                    </xs:complexType>
                </xs:element>
            </xs:sequence>
            <xs:attribute name="listStyle" type="sml:UnorderedListStyle" default="circle-hollow-square"/>
            <xs:attribute name="bulletColor" type="sml:SolidColor" use="optional"/>
            <xs:attribute name="bulletSize" type="sml:BulletSizeType" use="optional"/>
            <xs:attribute name="bulletChar" type="sml:BulletCharType" use="optional"/>
        </xs:complexType>
    </xs:element>

    <!-- 有序列表 -->
    <xs:element name="ol">
        <xs:annotation>
            <xs:documentation>
                有序列表, 可指定序号
                属性说明:
                - listStyle: 列表样式
                - bulletColor: 列表符号颜色(纯色), 可选
                - bulletSize: 列表符号大小, 可选, 百分比字符串(相对于文本字号, 取值范围 25%-400%)或绝对像素值(取值范围 6-400)
                - autoStartAt: 有序列表起始编号, 可选, 取值范围 [1, 32767]; 作用于本列表组的计数器初始值, 子元素 &lt;li@index&gt; 可覆盖单项编号
            </xs:documentation>
        </xs:annotation>
        <xs:complexType>
            <xs:sequence>
                <xs:element name="li" maxOccurs="unbounded">
                    <xs:complexType>
                        <xs:sequence>
                            <xs:element ref="sml:p"/>
                        </xs:sequence>
                        <xs:attribute name="index" type="xs:integer"/>
                    </xs:complexType>
                </xs:element>
            </xs:sequence>
            <xs:attribute name="listStyle" type="sml:OrderedListStyle" default="number-lower-alpha-lower-roman"/>
            <xs:attribute name="bulletColor" type="sml:SolidColor" use="optional"/>
            <xs:attribute name="bulletSize" type="sml:BulletSizeType" use="optional"/>
            <xs:attribute name="autoStartAt" type="sml:AutoStartAtType" use="optional"/>
        </xs:complexType>
    </xs:element>


    <!-- ==================== 页面元素定义 ==================== -->

    <!-- XSD 暂不支持的元素 -->
    <xs:element name="undefined">
        <xs:annotation>
            <xs:documentation>
                未定义元素, 用于处理不支持的形状类型, 当导出时遇到不支持的type数据时, 使用此元素替代
                属性说明:
                - id: 元素唯一标识符(可选)
                - type: 原始的不支持的类型名称, 包括 video(视频), audio(音频), whiteboard(画板)
            </xs:documentation>
        </xs:annotation>
        <xs:complexType>
            <xs:attribute name="id" type="xs:string" use="optional"/>
            <xs:attribute name="type" type="xs:string" use="optional"/>
        </xs:complexType>
    </xs:element>


    <!-- 图形元素 -->
    <xs:element name="shape">
        <xs:annotation>
            <xs:documentation>
                图形元素, 支持多种形状和文本内容

                属性说明:
                - id: 图形唯一标识符(可选)
                - type: 图形类型 (rect/ellipse/triangle等, 其中text是文本框), 必须。
                - topLeftX/topLeftY: 左上角坐标, 必须
                - width/height: 宽高尺寸, 必须
                - rotation: 旋转角度[0, 360)
                - path: 自定义路径(仅当type=custom时有效), 可选
                - presetHandlers: 图形控制点设置(例如:圆角设置), 可选
                - flipX/flipY: 水平/垂直翻转, 可选, 默认false
                - vert: 文本垂直对齐方式, 可选
                - alpha: 不透明度[0,1], 可选
                子元素:
                - fill: 填充样式, 可选, 无fill标签表示无填充, 空fill标签表示默认填充样式(type="text"的默认填充颜色为透明rgba(255, 255, 255, 0), 其他类型的默认填充颜色为rgba(222, 224, 227, 1)))
                - border: 边框样式, 可选
                - 无border标签: 无边框
                - 空border标签: 默认边框
                - type="text": 透明边框 rgba(255, 255, 255, 0)
                - type为其他值: 纯色取 S+10%, B-10%;渐变色各颜色停靠点取 S+10%, B-10%;图片/图案无边框
                - content: 图形内的文本内容, 可选, 富文本
                - reflection: 倒影效果, 可选, 无reflection标签表示无倒影效果, 空reflection标签表示默认倒影效果
                - shadow: 阴影效果, 可选, 无shadow标签表示无阴影效果, 空shadow标签表示默认阴影效果
                - softEdge: 柔化边缘效果, 可选, 无softEdge标签表示无柔化边缘效果, 空softEdge标签表示默认柔化边缘效果(radius=2)
            </xs:documentation>
        </xs:annotation>
        <xs:complexType>
            <xs:all>
                <xs:element name="fill" type="sml:FillType" minOccurs="0"/>
                <xs:element name="border" type="sml:BorderType" minOccurs="0"/>
                <xs:element name="reflection" type="sml:ReflectionType" minOccurs="0"/>
                <xs:element name="shadow" type="sml:ShadowType" minOccurs="0"/>
                <xs:element name="softEdge" type="sml:SoftEdgeType" minOccurs="0"/>
                <xs:element ref="sml:content" minOccurs="0"/>
            </xs:all>

            <xs:attribute name="id" type="xs:string" use="optional"/>
            <xs:attribute name="type" type="sml:ShapeType" use="required"/>
            <xs:attribute name="topLeftX" type="sml:XType" use="required"/>
            <xs:attribute name="topLeftY" type="sml:YType" use="required"/>
            <xs:attribute name="width" type="sml:PositiveSize" use="required"/>
            <xs:attribute name="height" type="sml:PositiveSize" use="required"/>
            <xs:attribute name="rotation" type="sml:RotationType" use="optional" default="0"/>
            <xs:attribute name="path" type="xs:string" use="optional"/>
            <xs:attribute name="presetHandlers" type="xs:string" use="optional"/>
            <xs:attribute name="flipX" type="xs:boolean" use="optional" default="false"/>
            <xs:attribute name="flipY" type="xs:boolean" use="optional" default="false"/>
            <xs:attribute name="vert" type="sml:TextVertical" use="optional" default="horz"/>
            <xs:attribute name="alpha" type="sml:AlphaType" use="optional" default="1"/>
        </xs:complexType>
    </xs:element>

    <!-- 线条元素 -->
    <xs:element name="line">
        <xs:annotation>
            <xs:documentation>
                线条/箭头元素, 支持多种样式和箭头类型

                属性说明:
                - type: 线条类型
                - id: 线条唯一标识符(可选)
                - startX/startY: 起点坐标
                - endX/endY: 终点坐标
                - alpha: 不透明度[0,1]

                坐标说明:
                - startX/startY 和 endX/endY 都是绝对坐标
                - 起点 = (startX, startY)
                - 终点 = (endX, endY)

                子元素:
                - border: 线条样式(颜色/宽度/虚线等), 必需, 线条必须有边框样式, 空border标签表示默认样式(颜色:rgba(43, 47, 54, 1)), 宽度: 2px)
                - startArrow: 起点箭头, 可选
                - endArrow: 终点箭头, 可选
                - shadow: 阴影效果, 可选, 无shadow标签表示无阴影效果, 空shadow标签表示默认阴影效果
                - reflection: 倒影效果, 可选, 无reflection标签表示无倒影效果, 空reflection标签表示默认倒影效果
                - softEdge: 柔化边缘效果, 可选, 无softEdge标签表示无柔化边缘效果, 空softEdge标签表示默认柔化边缘效果(radius=2)
            </xs:documentation>
        </xs:annotation>
        <xs:complexType>
            <xs:all>
                <xs:element name="border" type="sml:BorderType" minOccurs="1"/>
                <xs:element name="startArrow" type="sml:StartArrowStyleType" minOccurs="0"/>
                <xs:element name="endArrow" type="sml:EndArrowStyleType" minOccurs="0"/>
                <xs:element name="shadow" type="sml:ShadowType" minOccurs="0"/>
                <xs:element name="reflection" type="sml:ReflectionType" minOccurs="0" />
                <xs:element name="softEdge" type="sml:SoftEdgeType" minOccurs="0"/>
            </xs:all>
            <xs:attribute name="type" type="sml:LineType" use="optional" default="straight-connector1"/>
            <xs:attribute name="id" type="xs:string" use="optional"/>
            <xs:attribute name="startX" type="sml:XType" use="required"/>
            <xs:attribute name="startY" type="sml:YType" use="required"/>
            <xs:attribute name="endX" type="sml:XType" use="required"/>
            <xs:attribute name="endY" type="sml:YType" use="required"/>
            <xs:attribute name="alpha" type="sml:AlphaType" use="optional" default="1"/>
        </xs:complexType>
    </xs:element>

    <!-- 折线/曲线元素 -->
    <xs:element name="polyline">
        <xs:annotation>
            <xs:documentation>
                折线/曲线元素, 支持多种连接类型和自定义路径

                属性说明:
                - id: 折线唯一标识符(可选)
                - type: 线条类型, 参考 LineType 枚举
                - topLeftX/topLeftY: 外接矩形左上角坐标
                - width/height: 外接矩形宽高尺寸
                - presetHandlers: 控制折线/曲线的调节点位置
                - rotation: 旋转角度 [0,360) 度)
                - flipX/flipY: 水平/垂直翻转
                - alpha: 不透明度[0,1]

                坐标系统说明:
                - topLeftX/topLeftY 定义外接矩形的位置
                - width/height 定义外接矩形的大小

                子元素:
                - border: 线条样式(颜色/宽度/虚线等), 必需, 线条必须有边框样式, 空border标签表示默认样式(颜色:rgba(43, 47, 54, 1)), 宽度: 2px)
                - startArrow: 起点箭头, 可选
                - endArrow: 终点箭头, 可选
                - shadow: 阴影效果, 可选, 无shadow标签表示无阴影效果, 空shadow标签表示默认阴影效果
                - reflection: 倒影效果, 可选, 无reflection标签表示无倒影效果, 空reflection标签表示默认倒影效果
                - softEdge: 柔化边缘效果, 可选, 无softEdge标签表示无柔化边缘效果, 空softEdge标签表示默认柔化边缘效果(radius=2)
            </xs:documentation>
        </xs:annotation>
        <xs:complexType>
            <xs:all>
                <xs:element name="border" type="sml:BorderType" minOccurs="1"/>
                <xs:element name="startArrow" type="sml:StartArrowStyleType" minOccurs="0"/>
                <xs:element name="endArrow" type="sml:EndArrowStyleType" minOccurs="0"/>
                <xs:element name="shadow" type="sml:ShadowType" minOccurs="0"/>
                <xs:element name="reflection" type="sml:ReflectionType" minOccurs="0"/>
                <xs:element name="softEdge" type="sml:SoftEdgeType" minOccurs="0"/>
            </xs:all>

            <xs:attribute name="id" type="xs:string" use="optional"/>
            <xs:attribute name="type" type="sml:PolylineType" use="optional" default="bent-connector2"/>
            <xs:attribute name="topLeftX" type="sml:XType" use="required"/>
            <xs:attribute name="topLeftY" type="sml:YType" use="required"/>
            <xs:attribute name="width" type="sml:PositiveSize" use="required"/>
            <xs:attribute name="height" type="sml:PositiveSize" use="required"/>
            <xs:attribute name="presetHandlers" type="xs:string" use="optional"/>
            <xs:attribute name="rotation" type="sml:RotationType" use="optional" default="0"/>
            <xs:attribute name="flipX" type="xs:boolean" use="optional" default="false"/>
            <xs:attribute name="flipY" type="xs:boolean" use="optional" default="false"/>
            <xs:attribute name="alpha" type="sml:AlphaType" use="optional" default="1"/>
        </xs:complexType>
    </xs:element>

    <!-- 图片元素 -->
    <xs:element name="img">
        <xs:annotation>
            <xs:documentation>
                必需属性:
                src: 图片token
                topLeftX, topLeftY: 显示位置左上角坐标(裁剪后)
                width, height: 显示尺寸(裁剪后)

                可选属性:
                id: 唯一标识符
                alt: 描述文本
                rotation: 旋转角度[0, 360)
                flipX, flipY: 水平/垂直翻转
                exposure, contrast, saturation, temperature: 图像调整参数
                alpha: 不透明度[0, 1]

                可选子元素:
                crop: 裁剪。无标签 / 空标签 / 仅设 anchor 时按等比缩放后裁剪到 width×height; anchor 指定保留方位 (top/bottom/left/right), 不设 anchor 即居中裁剪; offset 用于精细控制
                reflection: 倒影。无标签代表无倒影,空标签代表使用默认样式
                shadow: 阴影。无标签代表无阴影,空标签代表使用默认样式
                softEdge: 柔化边缘。无softEdge标签代表无柔化边缘, 空softEdge标签表示默认柔化边缘(radius=2)
                border: 边框。无标签代表无边框,空标签代表使用默认样式(颜色: rgba(43, 47, 54, 1), 宽度: 2)
                fill: 填充。无标签代表无填充,空标签代表使用默认样式(颜色: rgba(91, 155, 213, 1))
            </xs:documentation>
        </xs:annotation>
        <xs:complexType>
            <xs:all>
                <xs:element name="crop" type="sml:CropType" minOccurs="0"/>
                <xs:element name="border" type="sml:BorderType" minOccurs="0"/>
                <xs:element name="reflection" type="sml:ReflectionType" minOccurs="0"/>
                <xs:element name="shadow" type="sml:ShadowType" minOccurs="0"/>
                <xs:element name="softEdge" type="sml:SoftEdgeType" minOccurs="0"/>
                <xs:element name="fill" type="sml:FillType" minOccurs="0"/>
            </xs:all>
            <xs:attribute name="id" type="xs:string" use="optional"/>
            <xs:attribute name="src" type="xs:string" use="required"/>
            <xs:attribute name="alt" type="xs:string" use="optional"/>
            <xs:attribute name="topLeftX" type="sml:XType" use="required"/>
            <xs:attribute name="topLeftY" type="sml:YType" use="required"/>
            <xs:attribute name="width" type="sml:PositiveSize" use="required"/>
            <xs:attribute name="height" type="sml:PositiveSize" use="required"/>
            <xs:attribute name="rotation" type="sml:RotationType" use="optional" default="0"/>
            <xs:attribute name="flipX" type="xs:boolean" use="optional" default="false"/>
            <xs:attribute name="flipY" type="xs:boolean" use="optional" default="false"/>
            <xs:attribute name="exposure" type="xs:integer" use="optional" default="0"/>
            <xs:attribute name="contrast" type="xs:integer" use="optional" default="0"/>
            <xs:attribute name="saturation" type="xs:integer" use="optional" default="0"/>
            <xs:attribute name="temperature" type="xs:integer" use="optional" default="0"/>
            <xs:attribute name="alpha" type="sml:AlphaType" use="optional" default="1"/>
        </xs:complexType>
    </xs:element>

    <!-- 图标元素 -->
    <xs:element name="icon">
        <xs:annotation>
            <xs:documentation>
                图标元素, 支持外部图标资源和样式配置
                属性:
                - id: 图标唯一标识符(可选)
                - iconType: 图标类型, 来自iconpark图标库
                - width/height: 显示尺寸
                - rotation: 旋转角度
                - topLeftX/topLeftY: 左上角坐标
                - flipX/flipY: 水平/垂直翻转
                - alpha: 不透明度[0,1]

                子元素:
                - fill: 填充样式, 无fill标签代表不填充, 空fill标签代表使用默认样式(默认颜色填充, 颜色为rgba(208, 211, 214, 1))
                - border: 边框样式, 无border标签代表无边框, 空border标签代表使用默认样式(默认实线边框, 颜色为rgba(51, 51, 51, 1), 宽度为3)
                - reflection: 倒影样式, 无reflection标签代表无倒影, 空reflection标签代表使用默认样式
                - shadow: 阴影样式, 无shadow标签代表无阴影, 空shadow标签代表使用默认样式
                - softEdge: 柔化边缘样式, 无softEdge标签代表无柔化边缘, 空softEdge标签表示默认柔化边缘(radius=2)
            </xs:documentation>
        </xs:annotation>
        <xs:complexType>
            <xs:all>
                <xs:element name="fill" type="sml:FillType" minOccurs="0"/>
                <xs:element name="border" type="sml:BorderType" minOccurs="0"/>
                <xs:element name="reflection" type="sml:ReflectionType" minOccurs="0"/>
                <xs:element name="shadow" type="sml:ShadowType" minOccurs="0"/>
                <xs:element name="softEdge" type="sml:SoftEdgeType" minOccurs="0"/>
            </xs:all>

            <xs:attribute name="id" type="xs:string" use="optional"/>
            <xs:attribute name="iconType" type="xs:string" use="optional" default="iconpark/Base/setting.svg"/>
            <xs:attribute name="topLeftX" type="sml:XType" use="required"/>
            <xs:attribute name="topLeftY" type="sml:YType" use="required"/>
            <xs:attribute name="width" type="sml:PositiveSize" use="required"/>
            <xs:attribute name="height" type="sml:PositiveSize" use="required"/>
            <xs:attribute name="rotation" type="sml:RotationType" use="optional" default="0"/>
            <xs:attribute name="flipX" type="xs:boolean" use="optional" default="false"/>
            <xs:attribute name="flipY" type="xs:boolean" use="optional" default="false"/>
            <xs:attribute name="alpha" type="sml:AlphaType" use="optional" default="1"/>
        </xs:complexType>
    </xs:element>

    <!-- 嵌入内容元素 -->
    <xs:element name="embed">
        <xs:annotation>
            <xs:documentation>
                嵌入内容容器, 外层承载 Slides 摆放和效果属性, 内层承载外部标准内容。
                当前内层内容为标准 SVG, 必须使用 http://www.w3.org/2000/svg 命名空间。
                后续可扩展承载 HTML 等其他外部内容。

                必需属性:
                - topLeftX/topLeftY: 左上角坐标
                - width/height: 显示尺寸

                可选属性:
                - id: 唯一标识符
                - rotation: 旋转角度[0, 360)
                - flipX/flipY: 水平/垂直翻转
                - alpha: 不透明度[0,1]

                子元素:
                - svg: 标准 SVG 根元素, 仅描述嵌入内容, 不承载 Slides 布局属性
                - reflection: 倒影样式, 无reflection标签代表无倒影, 空reflection标签代表使用默认样式
                - shadow: 阴影样式, 无shadow标签代表无阴影, 空shadow标签代表使用默认样式
                - softEdge: 柔化边缘样式, 无softEdge标签代表无柔化边缘, 空softEdge标签表示默认柔化边缘(radius=2)
            </xs:documentation>
        </xs:annotation>
        <xs:complexType>
            <xs:sequence>
                <xs:any namespace="http://www.w3.org/2000/svg" processContents="lax" minOccurs="1" maxOccurs="1"/>
                <xs:element name="reflection" type="sml:ReflectionType" minOccurs="0"/>
                <xs:element name="shadow" type="sml:ShadowType" minOccurs="0"/>
                <xs:element name="softEdge" type="sml:SoftEdgeType" minOccurs="0"/>
            </xs:sequence>
            <xs:attribute name="id" type="xs:string" use="optional"/>
            <xs:attribute name="topLeftX" type="sml:XType" use="required"/>
            <xs:attribute name="topLeftY" type="sml:YType" use="required"/>
            <xs:attribute name="width" type="sml:PositiveSize" use="required"/>
            <xs:attribute name="height" type="sml:PositiveSize" use="required"/>
            <xs:attribute name="rotation" type="sml:RotationType" use="optional" default="0"/>
            <xs:attribute name="flipX" type="xs:boolean" use="optional" default="false"/>
            <xs:attribute name="flipY" type="xs:boolean" use="optional" default="false"/>
            <xs:attribute name="alpha" type="sml:AlphaType" use="optional" default="1"/>
        </xs:complexType>
    </xs:element>

    <!-- 表格元素 -->
    <xs:element name="table">
        <xs:annotation>
            <xs:documentation>
                表格元素, 用于展示结构化数据
                宽高分配规则参考 HTML table 的整体值 / 子项值处理逻辑, 但在 SXSD 中做确定化约束, 以保证跨端实现一致。
                边框规则:
                - 后设置优先:相邻单元格线条只有一个颜色, 如果均设置, 则右下单元格的设置覆盖左上单元格
                - 不设置边框属性时使用默认样式

                填充规则:
                - 单元格可设置独立填充样式
                - 不设置填充属性时使用表格默认样式

                table 属性:
                - id: 表格唯一标识符(可选)
                - topLeftX/topLeftY: 左上角坐标
                - flipX/flipY: 水平/垂直翻转
                - width: 表格目标总宽度(可选)。若设置, 优先用于为未填写列宽的列分配剩余宽度; 当所有列宽均为空时, 所有列均分该值; 当所有列均已填写且该值大于已填写列宽总和时, 多出空间继续分配给现有列, 默认按各列当前宽度作为权重分配; 当已填写列宽之和超过或无法容纳该值时, 保留已填写列宽, 并以最终列宽总和回写 table.width
                - height: 表格目标总高度(可选)。若设置, 优先用于为未填写行高的行分配剩余高度; 当所有行高均为空时, 所有行均分该值; 当所有行均已填写且该值大于已填写行高总和时, 多出空间继续分配给现有行, 默认按各行当前高度作为权重分配; 当已填写行高之和超过或无法容纳该值时, 保留已填写行高, 并以最终行高总和回写 table.height
                table 子元素:
                - colgroup: 列组元素, 用于定义列的宽度
                - tr: 行元素, 包含多个单元格

                colgroup 子元素:
                - col: 列元素
                col 属性:
                - span: 列跨数, 默认为1, 可选
                - width: 列宽度输入值, 默认值为110, 可选。无 table.width 时, 已填写列保持原值, 空列使用默认值; 有 table.width 时, 已填写列保持原值, 空列优先均分剩余宽度; 若不存在空列且 table.width 大于已填写列宽总和, 则多出空间按各列当前宽度作为权重分配到所有列; 若剩余宽度不足则空列回退为默认值, 并以最终列宽总和作为 table.width

                tr 属性:
                - height: 行高输入值, 默认值为37, 可选。无 table.height 时, 已填写行保持原值, 空行使用默认值; 有 table.height 时, 已填写行保持原值, 空行优先均分剩余高度; 若不存在空行且 table.height 大于已填写行高总和, 则多出空间按各行当前高度作为权重分配到所有行; 若剩余高度不足则空行回退为默认值, 并以最终行高总和作为 table.height。若行高低于内容高度, 需要手动修改行高
                tr 子元素:
                - td: 单元格元素, 用于显示数据

                td 属性:
                - id: 单元格唯一标识符(可选)
                - colspan: 单元格跨列数, 默认为1
                - rowspan: 单元格跨行数, 默认为1
                td 子元素:
                - borderTop/borderRight/borderBottom/borderLeft: 单元格边框样式, 无border标签代表无边框, 空border标签代表使用默认样式(实线边框, 颜色为rgba(221, 222, 223, 1), 宽度为1)
                - fill: 单元格填充样式, 无fill标签代表不填充, 空fill标签代表使用默认样式(默认颜色填充, 颜色为rgba(255, 255, 255, 1))
                - content: 单元格内容。内容默认不反向修改表格几何尺寸; 当内容高度大于当前行高时, 需要手动修改行高
            </xs:documentation>
        </xs:annotation>
        <xs:complexType>
            <xs:sequence>
                <xs:element name="colgroup" minOccurs="0">
                    <xs:complexType>
                        <xs:sequence>
                            <xs:element name="col" maxOccurs="unbounded">
                                <xs:complexType>
                                    <xs:attribute name="span" type="sml:PositiveSize" use="optional" default="1"/>
                                    <xs:attribute name="width" type="sml:PositiveSize" use="optional" default="110"/>
                                </xs:complexType>
                            </xs:element>
                        </xs:sequence>
                    </xs:complexType>
                </xs:element>
                <xs:element name="tr" maxOccurs="unbounded">
                    <xs:complexType>
                        <xs:sequence>
                            <xs:element name="td" maxOccurs="unbounded">
                                <xs:complexType>
                                    <xs:all>
                                        <xs:element name="borderTop" type="sml:BorderType" minOccurs="0"/>
                                        <xs:element name="borderRight" type="sml:BorderType" minOccurs="0"/>
                                        <xs:element name="borderBottom" type="sml:BorderType" minOccurs="0"/>
                                        <xs:element name="borderLeft" type="sml:BorderType" minOccurs="0"/>
                                        <xs:element name="fill" type="sml:FillType" minOccurs="0"/>
                                        <xs:element ref="sml:content" minOccurs="0"/>
                                    </xs:all>
                                    <xs:attribute name="id" type="xs:string" use="optional"/>
                                    <xs:attribute name="colspan" type="sml:PositiveSize" use="optional" default="1"/>
                                    <xs:attribute name="rowspan" type="sml:PositiveSize" use="optional" default="1"/>
                                </xs:complexType>
                            </xs:element>
                        </xs:sequence>
                        <xs:attribute name="height" type="sml:PositiveSize" use="optional" default="37"/>
                    </xs:complexType>
                </xs:element>
            </xs:sequence>
            <xs:attribute name="id" type="xs:string" use="optional"/>
            <xs:attribute name="topLeftX" type="sml:XType" use="required"/>
            <xs:attribute name="topLeftY" type="sml:YType" use="required"/>
            <xs:attribute name="flipX" type="xs:boolean" use="optional" default="false"/>
            <xs:attribute name="flipY" type="xs:boolean" use="optional" default="false"/>
            <xs:attribute name="width" type="sml:PositiveSize" use="optional"/>
            <xs:attribute name="height" type="sml:PositiveSize" use="optional"/>
        </xs:complexType>
    </xs:element>

    <!-- ==================== 内联元素 ==================== -->
    <xs:element name="br">
        <xs:annotation>
            <xs:documentation>换行符, 强制换行, 以<br/>形式出现</xs:documentation>
        </xs:annotation>
        <xs:complexType/>
    </xs:element>

    <xs:element name="field">
        <xs:annotation>
            <xs:documentation>
                动态文本字段。
                type 属性描述动态语义,元素内容是静态降级文本,可包含行内样式元素。
            </xs:documentation>
        </xs:annotation>
        <xs:complexType mixed="true">
            <xs:choice minOccurs="0" maxOccurs="unbounded">
                <xs:element ref="sml:strong"/>
                <xs:element ref="sml:em"/>
                <xs:element ref="sml:u"/>
                <xs:element ref="sml:span"/>
                <xs:element ref="sml:del"/>
                <xs:element ref="sml:a"/>
                <xs:element ref="sml:shadow"/>
                <xs:element ref="sml:outline"/>
            </xs:choice>
            <xs:attribute name="type" type="sml:FieldType" use="required"/>
        </xs:complexType>
    </xs:element>

    <xs:element name="tab">
        <xs:annotation>
            <xs:documentation>
                文本级制表符占位元素, 不携带属性
            </xs:documentation>
        </xs:annotation>
        <xs:complexType/>
    </xs:element>

    <xs:element name="formula">
        <xs:annotation>
            <xs:documentation>
                通用公式元素。
                用于展示各类科学公式。

                结构说明:
                - 必须从支持的公式格式中选择且仅选择一种作为子元素。
                - 当前版本支持格式:&lt;latex&gt;

                示例:
                - 基础公式:
                &lt;formula&gt;
                &lt;latex&gt;&lt;![CDATA[ E = mc^2 ]]&gt;&lt;/latex&gt;
                &lt;/formula&gt;
            </xs:documentation>
        </xs:annotation>
        <xs:complexType>
            <xs:choice minOccurs="1" maxOccurs="1">
                <xs:element name="latex">
                    <xs:annotation>
                        <xs:documentation>
                            LaTeX 格式的公式内容。
                            本元素包含的 LaTeX 字符串必须严格符合附件中定义的宏集范围。

                            内容语法:
                            - 语法范围:仅使用附件白名单中明确支持的宏。
                            - 表达建议:优先使用基础运算符、分式(\frac)、根号(\sqrt)、矩阵(matrix)等标准数学环境。
                            - 格式要求:必须使用 CDATA 包裹内容,且 CDATA 内部严禁进行 XML 转义(如 &amp;lt;, &amp;amp;)。
                            - 空白处理:解析器将保留 CDATA 内的所有换行和缩进,建议利用此特性保持 LaTeX 源码的结构化和可读性。
                        </xs:documentation>
                    </xs:annotation>
                    <xs:simpleType>
                        <xs:restriction base="xs:string"/>
                    </xs:simpleType>
                </xs:element>
            </xs:choice>
        </xs:complexType>
    </xs:element>

    <xs:element name="strong">
        <xs:annotation>
            <xs:documentation>粗体/加重文本</xs:documentation>
        </xs:annotation>
        <xs:complexType mixed="true">
            <xs:choice minOccurs="0" maxOccurs="unbounded">
                <xs:element ref="sml:br"/>
                <xs:element ref="sml:tab"/>
                <xs:element ref="sml:formula"/>
                <xs:element ref="sml:strong"/>
                <xs:element ref="sml:em"/>
                <xs:element ref="sml:u"/>
                <xs:element ref="sml:span"/>
                <xs:element ref="sml:del"/>
                <xs:element ref="sml:a"/>
                <xs:element ref="sml:shadow"/>
                <xs:element ref="sml:outline"/>
                <xs:element ref="sml:field"/>
            </xs:choice>
        </xs:complexType>
    </xs:element>

    <!-- 阴影内联元素 -->
    <xs:element name="shadow">
        <xs:annotation>
            <xs:documentation>
                文本阴影效果内联元素
                可以对文本的特定部分应用阴影效果, 内联元素shadow只支持基础属性
            </xs:documentation>
        </xs:annotation>
        <xs:complexType mixed="true">
            <xs:complexContent>
                <xs:extension base="sml:ShadowType">
                    <xs:choice minOccurs="0" maxOccurs="unbounded">
                        <xs:element ref="sml:br"/>
                        <xs:element ref="sml:tab"/>
                        <xs:element ref="sml:formula"/>
                        <xs:element ref="sml:strong"/>
                        <xs:element ref="sml:em"/>
                        <xs:element ref="sml:u"/>
                        <xs:element ref="sml:span"/>
                        <xs:element ref="sml:del"/>
                        <xs:element ref="sml:a"/>
                        <xs:element ref="sml:shadow"/>
                        <xs:element ref="sml:outline"/>
                        <xs:element ref="sml:field"/>
                    </xs:choice>
                </xs:extension>
            </xs:complexContent>
        </xs:complexType>
    </xs:element>

    <!-- 文本轮廓内联元素 -->
    <xs:element name="outline">
        <xs:annotation>
            <xs:documentation>
                文本轮廓效果内联元素
                可以对文本的特定部分应用轮廓效果
            </xs:documentation>
        </xs:annotation>
        <xs:complexType mixed="true">
            <xs:complexContent>
                <xs:extension base="sml:OutlineType">
                    <xs:choice minOccurs="0" maxOccurs="unbounded">
                        <xs:element ref="sml:br"/>
                        <xs:element ref="sml:tab"/>
                        <xs:element ref="sml:formula"/>
                        <xs:element ref="sml:strong"/>
                        <xs:element ref="sml:em"/>
                        <xs:element ref="sml:u"/>
                        <xs:element ref="sml:span"/>
                        <xs:element ref="sml:del"/>
                        <xs:element ref="sml:a"/>
                        <xs:element ref="sml:shadow"/>
                        <xs:element ref="sml:outline"/>
                        <xs:element ref="sml:field"/>
                    </xs:choice>
                </xs:extension>
            </xs:complexContent>
        </xs:complexType>
    </xs:element>

    <xs:element name="em">
        <xs:annotation>
            <xs:documentation>斜体/强调文本</xs:documentation>
        </xs:annotation>
        <xs:complexType mixed="true">
            <xs:choice minOccurs="0" maxOccurs="unbounded">
                <xs:element ref="sml:br"/>
                <xs:element ref="sml:tab"/>
                <xs:element ref="sml:formula"/>
                <xs:element ref="sml:strong"/>
                <xs:element ref="sml:em"/>
                <xs:element ref="sml:u"/>
                <xs:element ref="sml:span"/>
                <xs:element ref="sml:del"/>
                <xs:element ref="sml:a"/>
                <xs:element ref="sml:shadow"/>
                <xs:element ref="sml:outline"/>
                <xs:element ref="sml:field"/>
            </xs:choice>
        </xs:complexType>
    </xs:element>

    <xs:element name="u">
        <xs:annotation>
            <xs:documentation>下划线文本</xs:documentation>
        </xs:annotation>
        <xs:complexType mixed="true">
            <xs:choice minOccurs="0" maxOccurs="unbounded">
                <xs:element ref="sml:br"/>
                <xs:element ref="sml:tab"/>
                <xs:element ref="sml:formula"/>
                <xs:element ref="sml:strong"/>
                <xs:element ref="sml:em"/>
                <xs:element ref="sml:u"/>
                <xs:element ref="sml:span"/>
                <xs:element ref="sml:del"/>
                <xs:element ref="sml:a"/>
                <xs:element ref="sml:shadow"/>
                <xs:element ref="sml:outline"/>
                <xs:element ref="sml:field"/>
            </xs:choice>
        </xs:complexType>
    </xs:element>

    <xs:element name="del">
        <xs:annotation>
            <xs:documentation>删除线文本</xs:documentation>
        </xs:annotation>
        <xs:complexType mixed="true">
            <xs:choice minOccurs="0" maxOccurs="unbounded">
                <xs:element ref="sml:br"/>
                <xs:element ref="sml:tab"/>
                <xs:element ref="sml:formula"/>
                <xs:element ref="sml:strong"/>
                <xs:element ref="sml:em"/>
                <xs:element ref="sml:u"/>
                <xs:element ref="sml:span"/>
                <xs:element ref="sml:del"/>
                <xs:element ref="sml:a"/>
                <xs:element ref="sml:shadow"/>
                <xs:element ref="sml:outline"/>
                <xs:element ref="sml:field"/>
            </xs:choice>
        </xs:complexType>
    </xs:element>

    <xs:element name="span">
        <xs:annotation>
            <xs:documentation>
                内联样式容器
                属性: color (文本颜色), backgroundColor (背景颜色)
            </xs:documentation>
        </xs:annotation>
        <xs:complexType mixed="true">
            <xs:choice minOccurs="0" maxOccurs="unbounded">
                <xs:element ref="sml:br"/>
                <xs:element ref="sml:tab"/>
                <xs:element ref="sml:formula"/>
                <xs:element ref="sml:strong"/>
                <xs:element ref="sml:em"/>
                <xs:element ref="sml:u"/>
                <xs:element ref="sml:span"/>
                <xs:element ref="sml:del"/>
                <xs:element ref="sml:a"/>
                <xs:element ref="sml:shadow"/>
                <xs:element ref="sml:outline"/>
                <xs:element ref="sml:field"/>
            </xs:choice>
            <xs:attribute name="color" type="sml:Color" use="optional"/>
            <xs:attribute name="backgroundColor" type="sml:Color" use="optional"/>
            <xs:attribute name="fontSize" type="sml:FontSizeType" use="optional"/>
            <xs:attribute name="fontFamily" type="sml:FontFamilyType" use="optional"/>
            <xs:attribute name="bold" type="xs:boolean" use="optional" />
            <xs:attribute name="italic" type="xs:boolean" use="optional" />
            <xs:attribute name="underline" type="xs:boolean" use="optional" />
            <xs:attribute name="strikethrough" type="xs:boolean" use="optional" />
            <xs:attribute name="baseline" type="xs:double" use="optional" />
        </xs:complexType>
    </xs:element>

    <xs:element name="a">
        <xs:annotation>
            <xs:documentation>
                超链接元素
                属性: href (仅支持 http/https)
            </xs:documentation>
        </xs:annotation>
        <xs:complexType mixed="true">
            <xs:choice minOccurs="0" maxOccurs="unbounded">
                <xs:element ref="sml:br"/>
                <xs:element ref="sml:tab"/>
                <xs:element ref="sml:formula"/>
                <xs:element ref="sml:strong"/>
                <xs:element ref="sml:em"/>
                <xs:element ref="sml:u"/>
                <xs:element ref="sml:span"/>
                <xs:element ref="sml:del"/>
                <xs:element ref="sml:shadow"/>
                <xs:element ref="sml:outline"/>
                <xs:element ref="sml:field"/>
            </xs:choice>
            <xs:attribute name="href" use="required">
                <xs:simpleType>
                    <xs:restriction base="xs:anyURI">
                        <xs:pattern value="(https?|s?ftp|ftps|nfs|ssh)://\S+"/>
                        <xs:pattern value="[\w.-]+[.:]\S*"/>
                        <xs:pattern value="(mailto:)?\S+@\S+\.\w{2,}"/>
                    </xs:restriction>
                </xs:simpleType>
            </xs:attribute>
        </xs:complexType>
    </xs:element>

    <!-- 字符间距类型 -->
    <xs:simpleType name="LetterSpacingType">
        <xs:annotation>
            <xs:documentation>字符间距, 单位: px, 取值范围 [-1000, 1000]</xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:double">
            <xs:minInclusive value="-1000"/>
            <xs:maxInclusive value="1000"/>
        </xs:restriction>
    </xs:simpleType>

    <!-- Tab 停止位字符串类型 -->
    <xs:simpleType name="TabStopPosType">
        <xs:annotation>
            <xs:documentation>
                Tab 停止位列表, 用于 content@tabStopPos 或 p@tabStopPos, p 优先级更高。
                格式为 位置+对齐方式, 逗号分隔, 如 "100r,200l,300ctr"; 对齐方式支持 l/ctr/r/dec。
                位置单位 px, 有限正数, 支持小数, 范围 (0,4031]; 列表长度 1-32。
                消费方按位置升序处理; 同一生效配置内位置不重复, 由 Node 层校验。
                遇到 tab 元素时, 使用第一个大于当前行内坐标的停止位; 不存在则按 defaultTabSize 步进。
            </xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:string">
            <xs:pattern value="(0\.\d*[1-9]\d*|([1-9]\d{0,2}|[1-3]\d{3}|40[0-2]\d|4030)(\.\d+)?|4031(\.0+)?)(l|ctr|r|dec)(,(0\.\d*[1-9]\d*|([1-9]\d{0,2}|[1-3]\d{3}|40[0-2]\d|4030)(\.\d+)?|4031(\.0+)?)(l|ctr|r|dec)){0,31}"/>
        </xs:restriction>
    </xs:simpleType>

    <!-- Tab 默认间距类型 -->
    <xs:simpleType name="TabSizeType">
        <xs:annotation>
            <xs:documentation>Tab 默认间距, 单位 px, 有限正数, 支持小数, 取值范围 (0,4031]</xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:decimal">
            <xs:minExclusive value="0"/>
            <xs:maxInclusive value="4031"/>
        </xs:restriction>
    </xs:simpleType>

    <xs:simpleType name="LineSpacingType">
        <xs:annotation>
            <xs:documentation>
                行间距类型, 支持固定行距和多倍行距两种模式。
                - fixed: 固定行距, 单位为px, 例如 "fixed:20" 表示20px固定行距
                - multiple: 多倍行距,相对于行内最大字号的倍数, 例如 "multiple:1.5" 表示1.5倍行距, 表示 1.5 * maxFontSize 行距
            </xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:string">
            <xs:pattern value="fixed:\d+(\.\d+)?|multiple:\d+(\.\d+)?"/>
        </xs:restriction>
    </xs:simpleType>

    <!-- 缩进层级类型 -->
    <xs:simpleType name="LevelType">
        <xs:annotation>
            <xs:documentation>段落缩进层级, 取值范围 [1, 10]</xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:integer">
            <xs:minInclusive value="1"/>
            <xs:maxInclusive value="10"/>
        </xs:restriction>
    </xs:simpleType>

    <!-- 平铺对齐方式枚举 -->
    <xs:simpleType name="TileAlignType">
        <xs:restriction base="xs:string">
            <xs:enumeration value="left-top"/>
            <xs:enumeration value="top"/>
            <xs:enumeration value="right-top"/>
            <xs:enumeration value="left"/>
            <xs:enumeration value="center"/>
            <xs:enumeration value="right"/>
            <xs:enumeration value="left-bottom"/>
            <xs:enumeration value="bottom"/>
            <xs:enumeration value="right-bottom"/>
        </xs:restriction>
    </xs:simpleType>

    <!-- 平铺翻转方向枚举 -->
    <xs:simpleType name="TileFlipType">
        <xs:restriction base="xs:string">
            <xs:enumeration value="none"/>
            <xs:enumeration value="horizontal"/>
            <xs:enumeration value="vertical"/>
            <xs:enumeration value="both"/>
        </xs:restriction>
    </xs:simpleType>

    <!-- 内边距值类型 -->
    <xs:simpleType name="PaddingValueType">
        <xs:annotation>
            <xs:documentation>单个内边距值, 单位: px, 取值范围 [0, 1584]</xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:double">
            <xs:minInclusive value="0"/>
            <xs:maxInclusive value="1584"/>
        </xs:restriction>
    </xs:simpleType>

    <!-- 有序列表样式枚举 -->
    <xs:simpleType name="OrderedListStyle">
        <xs:annotation>
            <xs:documentation>
                有序列表样式

                分为两类:
                1. 复合样式(按层级循环不同格式):如 number-lower-alpha-lower-roman 表示第1级用数字、第2级用小写字母、第3级用小写罗马,超过层级数后循环
                2. 单一样式(所有层级使用同一格式,不循环):以 PPTX 标准 scheme 命名,如 alpha-lc-paren-both 表示所有层级都用 (a)(b)(c) 格式
            </xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:string">
            <!-- 复合样式(按层级循环) -->
            <xs:enumeration value="number-lower-alpha-lower-roman"><xs:annotation><xs:documentation>1. a. i. - 数字/小写字母/小写罗马</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="number-lower-alpha-lower-roman-paren"><xs:annotation><xs:documentation>1) a) i) - 带括号版本</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="hierarchical-number"><xs:annotation><xs:documentation>1. 1.1. 1.1.1. - 多级数字</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="upper-alpha-lower-alpha-lower-roman"><xs:annotation><xs:documentation>A. a. i. - 大写字母/小写字母/小写罗马</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="upper-roman-upper-alpha-number"><xs:annotation><xs:documentation>I. A. 1. - 大写罗马/大写字母/数字</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="zero-padded-lower-alpha-lower-roman"><xs:annotation><xs:documentation>01. a. i. - 补零数字/小写字母/小写罗马</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="circle-number"><xs:annotation><xs:documentation>圆圈数字</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="lower-alpha-paren"><xs:annotation><xs:documentation>a) b) c) - 小写字母带括号</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="lower-alpha-dot"><xs:annotation><xs:documentation>a. b. c. - 小写字母带点</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="chinese-formal"><xs:annotation><xs:documentation>一、二、三、 - 中文数字</xs:documentation></xs:annotation></xs:enumeration>

            <!-- 单一样式(所有层级使用同一格式,不随层级循环) -->
            <!-- 拉丁字母 Latin -->
            <xs:enumeration value="alpha-lc-paren-both"><xs:annotation><xs:documentation>(a) (b) (c) - 小写字母带双括号</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="alpha-uc-paren-both"><xs:annotation><xs:documentation>(A) (B) (C) - 大写字母带双括号</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="alpha-lc-paren-r"><xs:annotation><xs:documentation>a) b) c) - 小写字母带右括号</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="alpha-uc-paren-r"><xs:annotation><xs:documentation>A) B) C) - 大写字母带右括号</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="alpha-lc-period"><xs:annotation><xs:documentation>a. b. c. - 小写字母带点</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="alpha-uc-period"><xs:annotation><xs:documentation>A. B. C. - 大写字母带点</xs:documentation></xs:annotation></xs:enumeration>
            <!-- 阿拉伯数字 Arabic Numeral -->
            <xs:enumeration value="arabic-paren-both"><xs:annotation><xs:documentation>(1) (2) (3) - 数字带双括号</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="arabic-paren-r"><xs:annotation><xs:documentation>1) 2) 3) - 数字带右括号</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="arabic-period"><xs:annotation><xs:documentation>1. 2. 3. - 数字带点</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="arabic-plain"><xs:annotation><xs:documentation>1 2 3 - 纯数字</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="arabic-db-period"><xs:annotation><xs:documentation>1.2.3.- 全角数字带点</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="arabic-db-plain"><xs:annotation><xs:documentation>1 2 3 - 全角纯数字</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="arabic1-minus"><xs:annotation><xs:documentation>أ- ب- ت- - 阿拉伯语字母(现代序)带后横线</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="arabic2-minus"><xs:annotation><xs:documentation>-أ- -ب- -ج- - 阿拉伯语字母(Abjadi序)带双横线</xs:documentation></xs:annotation></xs:enumeration>
            <!-- 罗马数字 Roman -->
            <xs:enumeration value="roman-lc-paren-both"><xs:annotation><xs:documentation>(i) (ii) (iii) - 小写罗马带双括号</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="roman-uc-paren-both"><xs:annotation><xs:documentation>(I) (II) (III) - 大写罗马带双括号</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="roman-lc-paren-r"><xs:annotation><xs:documentation>i) ii) iii) - 小写罗马带右括号</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="roman-uc-paren-r"><xs:annotation><xs:documentation>I) II) III) - 大写罗马带右括号</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="roman-lc-period"><xs:annotation><xs:documentation>i. ii. iii. - 小写罗马带点</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="roman-uc-period"><xs:annotation><xs:documentation>I. II. III. - 大写罗马带点</xs:documentation></xs:annotation></xs:enumeration>
            <!-- 圆圈数字 Circle -->
            <xs:enumeration value="circle-num-db-plain"><xs:annotation><xs:documentation>① ② ③ - 圆圈数字</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="circle-num-wd-black-plain"><xs:annotation><xs:documentation>❶ ❷ ❸ - 实心圆圈数字</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="circle-num-wd-white-plain"><xs:annotation><xs:documentation>① ② ③ - 圆圈数字(1-10 循环, 字形与 circle-num-db-plain 相同但超过 10 后不降级为纯数字)</xs:documentation></xs:annotation></xs:enumeration>
            <!-- 东亚 East Asian -->
            <xs:enumeration value="ea1-chs-period"><xs:annotation><xs:documentation>一. 二. 三. - 简体中文带点</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="ea1-chs-plain"><xs:annotation><xs:documentation>一 二 三 - 简体中文</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="ea1-cht-period"><xs:annotation><xs:documentation>一. 二. 三. - 繁体中文带点</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="ea1-cht-plain"><xs:annotation><xs:documentation>一 二 三 - 繁体中文</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="ea1-jpn-chs-db-period"><xs:annotation><xs:documentation>一.二.三.- CJK汉字数字带全角点</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="ea1-jpn-kor-plain"><xs:annotation><xs:documentation>一 二 三 - CJK汉字数字</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="ea1-jpn-kor-period"><xs:annotation><xs:documentation>一. 二. 三. - CJK汉字数字带半角点</xs:documentation></xs:annotation></xs:enumeration>
            <!-- 希伯来语 Hebrew -->
            <xs:enumeration value="hebrew2-minus"><xs:annotation><xs:documentation>א- ב- ג- - 希伯来字母带横线</xs:documentation></xs:annotation></xs:enumeration>
            <!-- 泰语 Thai -->
            <xs:enumeration value="thai-alpha-period"><xs:annotation><xs:documentation>ก. ข. ค. - 泰语字母带点(跳过 ฃ/ฅ/ฆ)</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="thai-alpha-paren-r"><xs:annotation><xs:documentation>ก) ข) ค) - 泰语字母带右括号(跳过 ฃ/ฅ/ฆ)</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="thai-alpha-paren-both"><xs:annotation><xs:documentation>(ก) (ข) (ค) - 泰语字母带双括号(跳过 ฃ/ฅ/ฆ)</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="thai-num-period"><xs:annotation><xs:documentation>๑. ๒. ๓. - 泰语数字带点</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="thai-num-paren-r"><xs:annotation><xs:documentation>๑) ๒) ๓) - 泰语数字带右括号</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="thai-num-paren-both"><xs:annotation><xs:documentation>(๑) (๒) (๓) - 泰语数字带双括号</xs:documentation></xs:annotation></xs:enumeration>
            <!-- 印地语 Hindi -->
            <xs:enumeration value="hindi-alpha-period"><xs:annotation><xs:documentation>अ. आ. इ. - 印地语元音字母带点</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="hindi-num-period"><xs:annotation><xs:documentation>१. २. ३. - 印地语数字带点</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="hindi-num-paren-r"><xs:annotation><xs:documentation>१) २) ३) - 印地语数字带右括号</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="hindi-alpha1-period"><xs:annotation><xs:documentation>क. ख. ग. - 印地语辅音字母带点</xs:documentation></xs:annotation></xs:enumeration>
        </xs:restriction>
    </xs:simpleType>
    <!-- 无序列表样式枚举 -->
    <xs:simpleType name="UnorderedListStyle">
        <xs:annotation>
            <xs:documentation>
                无序列表样式

                分为两类:
                1. 复合样式(按层级循环不同图标):如 circle-hollow-square 表示第1级实心圆、第2级空心圆、第3级实心方形,超过层级数后循环
                2. 单一样式(所有层级使用同一图标,不循环):以 pptx- 前缀命名,如 pptx-circle 表示所有层级都用 ● 实心圆
            </xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:string">
            <!-- 复合样式(按层级循环) -->
            <xs:enumeration value="circle-hollow-square"><xs:annotation><xs:documentation>实心圆 空心圆 实心方形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="diamond-triangle-square"><xs:annotation><xs:documentation>菱形 三角形 实心方形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="hollow-square-all"><xs:annotation><xs:documentation>空心方形 空心方形 空心方形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="arrow-diamond-circle"><xs:annotation><xs:documentation>右箭头 实心菱形 实心圆形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="star-hollow-circle-square"><xs:annotation><xs:documentation>实心五角星 空心圆形 实心方形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="triangle-hollow-circle-square"><xs:annotation><xs:documentation>三角形 空心圆形 实心方形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="solid-square-all"><xs:annotation><xs:documentation>实心方形 实心方形 实心方形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="solid-diamond-all"><xs:annotation><xs:documentation>实心菱形 实心菱形 实心菱形</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="check-all"><xs:annotation><xs:documentation>对勾 对勾 对勾</xs:documentation></xs:annotation></xs:enumeration>

            <!-- 单一样式(所有层级使用同一图标,不随层级循环) -->
            <xs:enumeration value="pptx-circle"><xs:annotation><xs:documentation>● 实心圆(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="pptx-square"><xs:annotation><xs:documentation>■ 方块(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="pptx-diamond"><xs:annotation><xs:documentation>◆ 菱形(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="pptx-square-empty"><xs:annotation><xs:documentation>□ 空心方框(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="pptx-check"><xs:annotation><xs:documentation>✓ 对勾(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="pptx-triangle"><xs:annotation><xs:documentation>► 右三角(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="pptx-bullet"><xs:annotation><xs:documentation>• 小圆点(所有层级)</xs:documentation></xs:annotation></xs:enumeration>

            <xs:enumeration value="pptx-circle-empty"><xs:annotation><xs:documentation>○ 空心圆(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="pptx-diamond-empty"><xs:annotation><xs:documentation>◇ 空心菱形(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="pptx-arrow-right"><xs:annotation><xs:documentation>➔ 右箭头(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="pptx-star"><xs:annotation><xs:documentation>★ 星形(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="pptx-square-shadow"><xs:annotation><xs:documentation>❑ 带右下阴影的 3D 方框(所有层级,对应 PPTX Wingdings 'q')</xs:documentation></xs:annotation></xs:enumeration>
        </xs:restriction>
    </xs:simpleType>

    <!-- 列表样式(二选一) -->
    <xs:simpleType name="ListStyleType">
        <xs:union memberTypes="sml:OrderedListStyle sml:UnorderedListStyle"/>
    </xs:simpleType>

    <!-- 百分比类型 [0,100],支持小数 -->
    <xs:simpleType name="PercentageType">
        <xs:restriction base="xs:double">
            <xs:minInclusive value="0"/>
            <xs:maxInclusive value="100"/>
        </xs:restriction>
    </xs:simpleType>

    <!-- 复合类型 -->
    <xs:simpleType name="CompoundType">
        <xs:restriction base="xs:string">
            <xs:enumeration value="single"><xs:annotation><xs:documentation>单线</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="double"><xs:annotation><xs:documentation>双线, 每条线宽度相等, 都是1/3边框宽, 中间间隔1/3边框宽</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="thin-thick"><xs:annotation><xs:documentation>双线, 细 - 粗, 粗线=3/5边框宽, 细线=1/5边框宽, 间隔=1/5边框宽</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="thick-thin"><xs:annotation><xs:documentation>双线, 粗 - 细, 粗线=3/5边框宽, 细线=1/5边框宽, 间隔=1/5边框宽</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="three"><xs:annotation><xs:documentation>三线, 细 - 粗 - 细, 粗线=1/3边框宽, 细线=1/6边框宽, 间隔=1/6边框宽</xs:documentation></xs:annotation></xs:enumeration>
        </xs:restriction>
    </xs:simpleType>

    <!-- 线端类型 -->
    <xs:simpleType name="LineCapType">
        <xs:restriction base="xs:string">
            <xs:enumeration value="butt"><xs:annotation><xs:documentation>平</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="square"><xs:annotation><xs:documentation>方</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="round"><xs:annotation><xs:documentation>圆</xs:documentation></xs:annotation></xs:enumeration>
        </xs:restriction>
    </xs:simpleType>

    <!-- 连接类型 -->
    <xs:simpleType name="LineJoinType">
        <xs:restriction base="xs:string">
            <xs:enumeration value="round"><xs:annotation><xs:documentation>圆</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="bevel"><xs:annotation><xs:documentation>斜</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="miter"><xs:annotation><xs:documentation>尖</xs:documentation></xs:annotation></xs:enumeration>
        </xs:restriction>
    </xs:simpleType>

    <!-- 阴影对齐方式枚举 -->
    <xs:simpleType name="ShadowAlignType">
        <xs:restriction base="xs:string">
            <xs:enumeration value="top-left"><xs:annotation><xs:documentation>左上对齐</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="top"><xs:annotation><xs:documentation>顶部居中对齐</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="top-right"><xs:annotation><xs:documentation>右上对齐</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="left"><xs:annotation><xs:documentation>左侧居中对齐</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="center"><xs:annotation><xs:documentation>完全居中对齐</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="right"><xs:annotation><xs:documentation>右侧居中对齐</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="bottom-left"><xs:annotation><xs:documentation>左下对齐</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="bottom"><xs:annotation><xs:documentation>底部居中对齐</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="bottom-right"><xs:annotation><xs:documentation>右下对齐</xs:documentation></xs:annotation></xs:enumeration>
        </xs:restriction>
    </xs:simpleType>

    <!-- 缩放比例类型 -->
    <xs:simpleType name="ScaleType">
        <xs:annotation>
            <xs:documentation>缩放比例, 取值范围 [-2,2](1为原始大小, 小于1缩小, 大于1放大)</xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:double">
            <xs:minInclusive value="-2"/>
            <xs:maxInclusive value="2"/>
        </xs:restriction>
    </xs:simpleType>

    <!-- 斜切角度类型 -->
    <xs:simpleType name="SkewType">
        <xs:annotation>
            <xs:documentation>斜切角度, 取值范围 [-90,90](单位为度)</xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:double">
            <xs:minInclusive value="-90"/>
            <xs:maxInclusive value="90"/>
        </xs:restriction>
    </xs:simpleType>

    <!-- 文本垂直对齐方式枚举 -->
    <xs:simpleType name="TextVertical">
        <xs:restriction base="xs:string">
            <xs:enumeration value="horz"><xs:annotation><xs:documentation>水平</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="vert"><xs:annotation><xs:documentation>旋转90度</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="vert270"><xs:annotation><xs:documentation>旋转270度</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="word-art-vert-rtl"><xs:annotation><xs:documentation>堆积从右往左</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="word-art-vert"><xs:annotation><xs:documentation>堆积</xs:documentation></xs:annotation></xs:enumeration>
            <xs:enumeration value="ea-vert"><xs:annotation><xs:documentation>垂直</xs:documentation></xs:annotation></xs:enumeration>
        </xs:restriction>
    </xs:simpleType>

    <!-- 列表类型 -->
    <xs:simpleType name="ListType">
        <xs:restriction base="xs:string">
            <xs:enumeration value="bullet"/>
            <xs:enumeration value="number"/>
            <xs:enumeration value="none"/>
        </xs:restriction>
    </xs:simpleType>

    <!-- 图案类型枚举 -->
    <xs:simpleType name="PatternType">
        <xs:restriction base="xs:string">
            <!-- 百分比图案 -->
            <xs:enumeration value="pct5"/>
            <xs:enumeration value="pct10"/>
            <xs:enumeration value="pct20"/>
            <xs:enumeration value="pct25"/>
            <xs:enumeration value="pct30"/>
            <xs:enumeration value="pct40"/>
            <xs:enumeration value="pct50"/>
            <xs:enumeration value="pct60"/>
            <xs:enumeration value="pct70"/>
            <xs:enumeration value="pct75"/>
            <xs:enumeration value="pct80"/>
            <xs:enumeration value="pct90"/>
            <!-- 对角线图案 -->
            <xs:enumeration value="lt-dn-diag"/>
            <xs:enumeration value="lt-up-diag"/>
            <xs:enumeration value="dk-dn-diag"/>
            <xs:enumeration value="dk-up-diag"/>
            <xs:enumeration value="wd-dn-diag"/>
            <xs:enumeration value="wd-up-diag"/>
            <xs:enumeration value="dn-diag"/>
            <xs:enumeration value="up-diag"/>
            <!-- 水平/垂直线条 -->
            <xs:enumeration value="lt-vert"/>
            <xs:enumeration value="lt-horz"/>
            <xs:enumeration value="nar-vert"/>
            <xs:enumeration value="nar-horz"/>
            <xs:enumeration value="dk-vert"/>
            <xs:enumeration value="dk-horz"/>
            <xs:enumeration value="horz"/>
            <xs:enumeration value="vert"/>
            <!-- 虚线图案 -->
            <xs:enumeration value="dash-dn-diag"/>
            <xs:enumeration value="dash-up-diag"/>
            <xs:enumeration value="dash-horz"/>
            <xs:enumeration value="dash-vert"/>
            <!-- 装饰图案 -->
            <xs:enumeration value="sm-confetti"/>
            <xs:enumeration value="lg-confetti"/>
            <xs:enumeration value="zig-zag"/>
            <xs:enumeration value="wave"/>
            <!-- 砖块/编织图案 -->
            <xs:enumeration value="diag-brick"/>
            <xs:enumeration value="horz-brick"/>
            <xs:enumeration value="weave"/>
            <xs:enumeration value="plaid"/>
            <!-- 点状图案 -->
            <xs:enumeration value="divot"/>
            <xs:enumeration value="dot-grid"/>
            <xs:enumeration value="dot-dmnd"/>
            <!-- 网格/格子图案 -->
            <xs:enumeration value="sm-grid"/>
            <xs:enumeration value="lg-grid"/>
            <xs:enumeration value="sm-check"/>
            <xs:enumeration value="lg-check"/>
            <!-- 菱形图案 -->
            <xs:enumeration value="open-dmnd"/>
            <xs:enumeration value="solid-dmnd"/>
            <!-- 十字图案 -->
            <xs:enumeration value="cross"/>
            <xs:enumeration value="diag-cross"/>
            <!-- 其他图案 -->
            <xs:enumeration value="shingle"/>
            <xs:enumeration value="trellis"/>
            <xs:enumeration value="sphere"/>
        </xs:restriction>
    </xs:simpleType>

    <!-- 以下是图表相关定义 -->
    <!-- ==================== 基础枚举类型 ==================== -->
    <xs:simpleType name="ChartLineStyleType">
        <xs:annotation>
            <xs:documentation>
                图表线条样式枚举
                可选值: solid(实线) | dashed(虚线) | dotted(点线)
            </xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:string">
            <xs:enumeration value="solid"/>
            <xs:enumeration value="dashed"/>
            <xs:enumeration value="dotted"/>
        </xs:restriction>
    </xs:simpleType>

    <xs:simpleType name="ChartPointShapeType">
        <xs:annotation>
            <xs:documentation>
                图表数据点形状枚举
                可选值: circle(圆形) | square(正方形) | triangle(三角形) | diamond(菱形) | rect(矩形)
            </xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:string">
            <xs:enumeration value="circle"/>
            <xs:enumeration value="square"/>
            <xs:enumeration value="triangle"/>
            <xs:enumeration value="diamond"/>
            <xs:enumeration value="rect"/>
        </xs:restriction>
    </xs:simpleType>

    <xs:simpleType name="ChartLegendPositionType">
        <xs:annotation>
            <xs:documentation>
                图表图例位置枚举
                可选值: top(顶部) | bottom(底部) | left(左侧) | right(右侧)
            </xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:string">
            <xs:enumeration value="top"/>
            <xs:enumeration value="bottom"/>
            <xs:enumeration value="left"/>
            <xs:enumeration value="right"/>
        </xs:restriction>
    </xs:simpleType>

    <xs:simpleType name="ChartDataLabelPositionType">
        <xs:annotation>
            <xs:documentation>
                数据标签位置

                - pie: outside | inside,默认值 outside
                - column/bar: outside(柱外部) | center(柱内中间) | top(柱内顶部) | bottom(柱内底部),默认值 outside
                - line/area/radar: 默认值 auto,且建议只设置 auto
            </xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:string">
            <xs:enumeration value="auto"/>
            <xs:enumeration value="top"/>
            <xs:enumeration value="bottom"/>
            <xs:enumeration value="left"/>
            <xs:enumeration value="right"/>
            <xs:enumeration value="center"/>
            <xs:enumeration value="inside"/>
            <xs:enumeration value="outside"/>
        </xs:restriction>
    </xs:simpleType>

    <xs:simpleType name="ChartGradientKindType">
        <xs:annotation>
            <xs:documentation>
                图表渐变类型
                可选值: linear(线性渐变) | radial(径向渐变)
            </xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:string">
            <xs:enumeration value="linear"/>
            <xs:enumeration value="radial"/>
        </xs:restriction>
    </xs:simpleType>

    <xs:simpleType name="ChartRadarShapeType">
        <xs:annotation>
            <xs:documentation>
                雷达图外轮廓形状枚举
                可选值: polygon(多边形) | circle(圆形)
            </xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:string">
            <xs:enumeration value="polygon"/>
            <xs:enumeration value="circle"/>
        </xs:restriction>
    </xs:simpleType>

    <xs:simpleType name="ChartAxisLabelAngleType">
        <xs:annotation>
            <xs:documentation>
                坐标轴标签旋转角度枚举
                可选值: -90 | -45 | 0 | 45 | 90 (单位:度)
            </xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:integer">
            <xs:enumeration value="-90"/>
            <xs:enumeration value="-45"/>
            <xs:enumeration value="0"/>
            <xs:enumeration value="45"/>
            <xs:enumeration value="90"/>
        </xs:restriction>
    </xs:simpleType>

    <xs:simpleType name="ChartTextAlignType">
        <xs:annotation>
            <xs:documentation>
                图表文本对齐方式枚举
                可选值: left(左对齐) | center(居中) | right(右对齐)
            </xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:string">
            <xs:enumeration value="left"/>
            <xs:enumeration value="center"/>
            <xs:enumeration value="right"/>
        </xs:restriction>
    </xs:simpleType>

    <xs:simpleType name="ChartAxisKindType">
        <xs:annotation>
            <xs:documentation>
                图表坐标轴类型
                可选值: x | y | angle | radius
            </xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:string">
            <xs:enumeration value="x"/>
            <xs:enumeration value="y"/>
            <xs:enumeration value="angle"/>
            <xs:enumeration value="radius"/>
        </xs:restriction>
    </xs:simpleType>

    <xs:simpleType name="ChartYAxisPositionType">
        <xs:annotation>
            <xs:documentation>
                图表Y轴位置枚举
                可选值: left(左侧) | right(右侧)
            </xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:string">
            <xs:enumeration value="left"/>
            <xs:enumeration value="right"/>
        </xs:restriction>
    </xs:simpleType>

    <xs:simpleType name="ChartComboSeriesType">
        <xs:annotation>
            <xs:documentation>
                组合图系列类型枚举
                可选值: column(柱状) | line(折线) | area(面积)
            </xs:documentation>
        </xs:annotation>
        <xs:restriction base="xs:string">
            <xs:enumeration value="column"/>
            <xs:enumeration value="line"/>
            <xs:enumeration value="area"/>
        </xs:restriction>
    </xs:simpleType>

    <!-- ==================== 文本样式类型 ==================== -->

    <xs:complexType name="ChartFontStyleType">
        <xs:annotation>
            <xs:documentation>
                图表字体样式基类(所有文本元素的基础配置)

                属性:
                - fontSize: 字号大小
                - bold: 是否加粗
                - italic: 是否斜体, 默认false
                - underline: 是否下划线, 默认false
                - strikethrough: 是否删除线, 默认false
                - color: 文本颜色
            </xs:documentation>
        </xs:annotation>
        <xs:attribute name="fontSize" type="sml:FontSizeType" use="optional"/>
        <xs:attribute name="bold" type="xs:boolean" use="optional"/>
        <xs:attribute name="italic" type="xs:boolean" use="optional" default="false"/>
        <xs:attribute name="underline" type="xs:boolean" use="optional" default="false"/>
        <xs:attribute name="strikethrough" type="xs:boolean" use="optional" default="false"/>
        <xs:attribute name="color" type="sml:SolidColor" use="optional"/>
    </xs:complexType>

    <xs:complexType name="ChartGlobalFontType" >
        <xs:attribute name="size" type="sml:FontSizeType" use="optional" default="12"/>
        <xs:attribute name="color" type="sml:SolidColor" use="optional"/>
    </xs:complexType>

    <xs:complexType name="ChartTitleType" mixed="true">
        <xs:annotation>
            <xs:documentation>
                图表标题配置

                属性:
                - textAlign: 文本对齐方式(left|center|right), 默认left
                - fontFamily: 字体族名称,仅图表根级主标题/副标题支持;坐标轴标题不支持
                - fontSize: 字号大小
                - bold: 是否加粗
                - italic: 是否斜体, 默认false
                - underline: 是否下划线, 默认false
                - strikethrough: 是否删除线, 默认false
                - color: 文本颜色

                内容: 标题文本
            </xs:documentation>
        </xs:annotation>
        <xs:complexContent>
            <xs:extension base="sml:ChartFontStyleType">
                <xs:attribute name="textAlign" type="sml:ChartTextAlignType" use="optional" default="left"/>
                <xs:attribute name="fontFamily" type="sml:FontFamilyType" use="optional"/>
            </xs:extension>
        </xs:complexContent>
    </xs:complexType>

    <xs:complexType name="ChartLegendType">
        <xs:annotation>
            <xs:documentation>
                图表图例配置

                属性:
                - position: 图例位置(top|bottom|left|right), 默认bottom
                - fontSize: 字号大小
                - bold: 是否加粗
                - italic: 是否斜体, 默认false
                - underline: 是否下划线, 默认false
                - strikethrough: 是否删除线, 默认false
                - color: 文本颜色
            </xs:documentation>
        </xs:annotation>
        <xs:complexContent>
            <xs:extension base="sml:ChartFontStyleType">
                <xs:attribute name="position" type="sml:ChartLegendPositionType" use="optional" default="bottom"/>
            </xs:extension>
        </xs:complexContent>
    </xs:complexType>

    <xs:complexType name="ChartDataLabelsType">
        <xs:annotation>
            <xs:documentation>
                图表数据标签配置

                属性:
                - position: 标签位置(auto|top|bottom|left|right|center|inside|outside), 默认auto
                - series: 是否显示系列名称, 默认false, 条形图和柱状图下生效
                - category: 是否显示分类名称, 默认false
                - value: 是否显示数值, 默认true
                - percentage: 是否显示百分比, 默认false
                - format: 数字格式代码,例如 0%、0.00%、0.00、#,##0.00、¥#,##0.00, 0万
                - fontSize: 字号大小
                - bold: 是否加粗
                - italic: 是否斜体, 默认false
                - underline: 是否下划线, 默认false
                - strikethrough: 是否删除线, 默认false
                - color: 文本颜色

                注意: category、value、percentage至少有一项为true
            </xs:documentation>
        </xs:annotation>
        <xs:complexContent>
            <xs:extension base="sml:ChartFontStyleType">
                <xs:attribute name="position" type="sml:ChartDataLabelPositionType" use="optional"/>
                <xs:attribute name="series" type="xs:boolean" use="optional" default="false"/>
                <xs:attribute name="category" type="xs:boolean" use="optional" default="false"/>
                <xs:attribute name="value" type="xs:boolean" use="optional" default="true"/>
                <xs:attribute name="percentage" type="xs:boolean" use="optional" default="false"/>
                <xs:attribute name="format" type="xs:string" use="optional"/>
            </xs:extension>
        </xs:complexContent>
    </xs:complexType>

    <!-- ==================== 图表整体样式 ==================== -->

    <xs:complexType name="ChartBackgroundType">
        <xs:annotation>
            <xs:documentation>
                图表背景配置

                属性:
                - color: 背景颜色,省略时使用图表默认背景;无填充可使用透明色 rgba(0,0,0,0)
            </xs:documentation>
        </xs:annotation>
        <xs:attribute name="color" type="sml:SolidColor" use="optional"/>
    </xs:complexType>

    <xs:complexType name="ChartBorderType">
        <xs:annotation>
            <xs:documentation>
                图表边框配置

                属性:
                - color: 边框颜色,默认 rgb(222, 224, 227)
                - width: 边框宽度(像素), 默认 1;无边框可设置为0,或不设置chartBorder
                - style: 边框样式(solid|dashed|dotted), 默认 solid
                - radius: 圆角半径(像素), 默认 6
            </xs:documentation>
        </xs:annotation>
        <xs:attribute name="color" type="sml:SolidColor" use="optional"/>
        <xs:attribute name="width" type="xs:nonNegativeInteger" use="optional" default="1"/>
        <xs:attribute name="style" type="sml:ChartLineStyleType" use="optional" default="solid"/>
        <xs:attribute name="radius" type="xs:nonNegativeInteger" use="optional" />
    </xs:complexType>

    <xs:complexType name="ChartGradientStopType">
        <xs:annotation>
            <xs:documentation>
                图表渐变色标

                属性:
                - offset: 色标位置比例[0,1]
                - color: 色标颜色
                - opacity: 色标透明度[0,1]
            </xs:documentation>
        </xs:annotation>
        <xs:attribute name="offset" type="sml:RatioType" use="required"/>
        <xs:attribute name="color" type="sml:SolidColor" use="required"/>
        <xs:attribute name="opacity" type="sml:RatioType" use="optional"/>
    </xs:complexType>

    <xs:complexType name="ChartGradientStopsType">
        <xs:annotation>
            <xs:documentation>
                图表渐变色标列表,至少需要2个色标
            </xs:documentation>
        </xs:annotation>
        <xs:sequence>
            <xs:element name="stop" type="sml:ChartGradientStopType" minOccurs="2" maxOccurs="unbounded"/>
        </xs:sequence>
    </xs:complexType>

    <xs:complexType name="ChartGradientType">
        <xs:annotation>
            <xs:documentation>
                图表渐变配置

                属性:
                - type: 渐变类型(linear|radial)
                - x0/y0/x1/y1: 线性渐变起止点坐标
                - r0/r1: 径向渐变半径
                - gradientMethod: 渐变算法/插值方式

                子元素:
                - stops: 渐变色标列表
            </xs:documentation>
        </xs:annotation>
        <xs:sequence>
            <xs:element name="stops" type="sml:ChartGradientStopsType" minOccurs="1"/>
        </xs:sequence>
        <xs:attribute name="type" type="sml:ChartGradientKindType" use="required"/>
        <xs:attribute name="x0" type="xs:double" use="optional"/>
        <xs:attribute name="y0" type="xs:double" use="optional"/>
        <xs:attribute name="x1" type="xs:double" use="optional"/>
        <xs:attribute name="y1" type="xs:double" use="optional"/>
        <xs:attribute name="r0" type="xs:double" use="optional"/>
        <xs:attribute name="r1" type="xs:double" use="optional"/>
        <xs:attribute name="gradientMethod" type="xs:string" use="optional"/>
    </xs:complexType>

    <xs:complexType name="ChartColorThemeType">
        <xs:annotation>
            <xs:documentation>
                图表配色主题(定义系列颜色序列, 按系列索引循环应用)

                属性:
                - themeId: 主题标识(可选),主要用于兼容线上图表导出,AI生成不使用主题标识

                子元素:
                - color: 主题颜色项(建议至少1个), 每个color定义一个系列的颜色
            </xs:documentation>
        </xs:annotation>
        <xs:sequence>
            <xs:element name="color" minOccurs="0" maxOccurs="unbounded">
                <xs:complexType>
                    <xs:annotation>
                        <xs:documentation>
                            单个主题颜色定义

                            属性:
                            - value: 颜色值(必需)
                        </xs:documentation>
                    </xs:annotation>
                    <xs:attribute name="value" type="sml:SolidColor" use="required"/>
                </xs:complexType>
            </xs:element>
        </xs:sequence>
        <xs:attribute name="themeId" type="xs:string" use="optional"/>
    </xs:complexType>

    <xs:complexType name="ChartColorGradientType">
        <xs:annotation>
            <xs:documentation>
                图表渐变填充开关(空标签即启用渐变效果)

                无属性
                无子元素
            </xs:documentation>
        </xs:annotation>
    </xs:complexType>

    <xs:complexType name="ChartStyleType">
        <xs:annotation>
            <xs:documentation>
                图表全局样式容器

                子元素:
                - chartBackground: 背景配置(可选)
                - chartFont: 默认字体配置(可选)
                - chartBorder: 边框配置(可选),无需边框时不设置
                - chartColorTheme: 配色主题(可选)
                - chartColorGradient: 渐变填充开关(可选)
            </xs:documentation>
        </xs:annotation>
        <xs:all>
            <xs:element name="chartBackground" type="sml:ChartBackgroundType" minOccurs="0"/>
            <xs:element name="chartFont" type="sml:ChartGlobalFontType" minOccurs="0"/>
            <xs:element name="chartBorder" type="sml:ChartBorderType" minOccurs="0"/>
            <xs:element name="chartColorTheme" type="sml:ChartColorThemeType" minOccurs="0"/>
            <xs:element name="chartColorGradient" type="sml:ChartColorGradientType" minOccurs="0"/>
        </xs:all>
    </xs:complexType>

    <xs:complexType name="ChartTooltipType">
        <xs:annotation>
            <xs:documentation>
                图表 Tooltip 配置

                属性:
                - format: 数字格式代码,例如 0%、0.00%、0.00、#,##0.00、¥#,##0.00, 0万
            </xs:documentation>
        </xs:annotation>
        <xs:attribute name="format" type="xs:string" use="optional"/>
    </xs:complexType>

    <!-- ==================== 数据可视化元素配置:三层体系 ==================== -->
    <!-- 配置优先级:单元素配置 > 系列级配置 > 全局配置 -->

    <!-- 数据点配置 -->
    <xs:complexType name="ChartGlobalPointsType">
        <xs:annotation>
            <xs:documentation>
                图表全局数据点配置(第一层:所有系列的默认样式)
                适用于: 折线图、散点图、气泡图

                属性:
                - color: 数据点颜色
                - shape: 数据点形状(circle|square|triangle|diamond|rect), 默认circle
                - size: 数据点大小(像素)
            </xs:documentation>
        </xs:annotation>
        <xs:attribute name="color" type="sml:SolidColor" use="optional"/>
        <xs:attribute name="shape" type="sml:ChartPointShapeType" use="optional" default="circle"/>
        <xs:attribute name="size" type="xs:nonNegativeInteger" use="optional"/>
    </xs:complexType>

    <xs:complexType name="ChartSeriesPointsType">
        <xs:annotation>
            <xs:documentation>
                图表系列数据点配置(第二层:单系列统一配置 + 单点精确定制)
                继承ChartGlobalPointsType的所有属性
                适用于: 折线图、散点图、气泡图

                属性:
                - color: 该系列所有点的颜色
                - shape: 该系列所有点的形状
                - size: 该系列所有点的大小

                子元素:
                - fillGradient: 该系列所有点的填充渐变(可选)
                - strokeGradient: 该系列所有点的边框/描边渐变(可选)
                - chartPoint: 单个数据点配置(可选, 多个), 用于覆盖特定点的样式
            </xs:documentation>
        </xs:annotation>
        <xs:complexContent>
            <xs:extension base="sml:ChartGlobalPointsType">
                <xs:sequence>
                    <xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
                    <xs:element name="strokeGradient" type="sml:ChartGradientType" minOccurs="0"/>
                    <xs:element name="chartPoint" minOccurs="0" maxOccurs="unbounded">
                        <xs:complexType>
                            <xs:annotation>
                                <xs:documentation>
                                    单个数据点配置(第三层:最高优先级)

                                    属性:
                                    - index: 数据点索引(从1开始, 必需), 表示该系列中第几个数据点
                                    - color: 该点的颜色
                                    - shape: 该点的形状(circle|square|triangle|diamond|rect)
                                    - size: 该点的大小(像素)

                                    子元素:
                                    - fillGradient: 该点填充渐变(可选)
                                </xs:documentation>
                            </xs:annotation>
                            <xs:sequence>
                                <xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
                            </xs:sequence>
                            <xs:attribute name="index" type="xs:positiveInteger" use="required"/>
                            <xs:attribute name="color" type="sml:SolidColor" use="optional"/>
                            <xs:attribute name="shape" type="sml:ChartPointShapeType" use="optional"/>
                            <xs:attribute name="size" type="xs:nonNegativeInteger" use="optional"/>
                        </xs:complexType>
                    </xs:element>
                </xs:sequence>
            </xs:extension>
        </xs:complexContent>
    </xs:complexType>

    <!-- 线条配置 -->
    <xs:complexType name="ChartGlobalLineType">
        <xs:annotation>
            <xs:documentation>
                图表全局线条配置(第一层:所有系列的默认样式)
                适用于: 折线图、面积图、组合图的折线部分

                属性:
                - color: 线条颜色
                - width: 线条宽度(像素), 默认2
                - style: 线条样式(solid|dashed|dotted), 默认solid
            </xs:documentation>
        </xs:annotation>
        <xs:attribute name="color" type="sml:SolidColor" use="optional"/>
        <xs:attribute name="width" type="xs:positiveInteger" use="optional" default="2"/>
        <xs:attribute name="style" type="sml:ChartLineStyleType" use="optional" default="solid"/>
    </xs:complexType>

    <xs:complexType name="ChartSeriesLineType">
        <xs:annotation>
            <xs:documentation>
                图表系列线条配置(第二层:单系列统一配置)
                继承ChartGlobalLineType的所有属性

                子元素:
                - strokeGradient: 该系列线条渐变(可选)
            </xs:documentation>
        </xs:annotation>
        <xs:complexContent>
            <xs:extension base="sml:ChartGlobalLineType">
                <xs:sequence>
                    <xs:element name="strokeGradient" type="sml:ChartGradientType" minOccurs="0"/>
                </xs:sequence>
            </xs:extension>
        </xs:complexContent>
    </xs:complexType>

    <!-- 面积配置 -->
    <xs:complexType name="ChartGlobalAreaType">
        <xs:annotation>
            <xs:documentation>
                图表全局面积配置(第一层:所有系列的默认填充样式)
                适用于: 面积图、组合图的面积部分, 雷达图的填充部分

                属性:
                - color: 填充颜色
            </xs:documentation>
        </xs:annotation>
        <xs:attribute name="color" type="sml:SolidColor" use="optional"/>
    </xs:complexType>

    <xs:complexType name="ChartSeriesAreaType">
        <xs:annotation>
            <xs:documentation>
                图表系列面积配置(第二层:单系列统一配置)
                继承ChartGlobalAreaType的所有属性

                子元素:
                - fillGradient: 该系列面积填充渐变(可选)
            </xs:documentation>
        </xs:annotation>
        <xs:complexContent>
            <xs:extension base="sml:ChartGlobalAreaType">
                <xs:sequence>
                    <xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
                </xs:sequence>
            </xs:extension>
        </xs:complexContent>
    </xs:complexType>

    <!-- 柱子配置 -->
    <xs:complexType name="ChartGlobalBarsType">
        <xs:annotation>
            <xs:documentation>
                图表全局柱子配置(第一层:所有系列的默认柱子样式)
                适用于: 柱状图、条形图、组合图的柱状部分

                属性:
                - color: 柱子填充颜色
                - borderColor: 柱子边框颜色
                - borderWidth: 柱子边框宽度(像素), 默认0
                - borderStyle: 柱子边框样式(solid|dashed|dotted), 默认solid
                - backgroundColor: 柱子背景颜色
                - width: 柱子宽度(像素), 未设置时自动计算
                - gap: 柱子间距比例[0,1], 0表示无间距, 1表示间距等于柱宽
            </xs:documentation>
        </xs:annotation>
        <xs:attribute name="color" type="sml:SolidColor" use="optional"/>
        <xs:attribute name="borderColor" type="sml:SolidColor" use="optional"/>
        <xs:attribute name="borderWidth" type="xs:nonNegativeInteger" use="optional" default="0"/>
        <xs:attribute name="borderStyle" type="sml:ChartLineStyleType" use="optional" default="solid"/>
        <xs:attribute name="backgroundColor" type="sml:SolidColor" use="optional" />
        <xs:attribute name="width" type="xs:nonNegativeInteger" use="optional"/>
        <xs:attribute name="gap" type="sml:RatioType" use="optional"/>
    </xs:complexType>

    <xs:complexType name="ChartSeriesBarsType">
        <xs:annotation>
            <xs:documentation>
                图表系列柱子配置(第二层:单系列统一配置 + 单柱精确定制)
                继承ChartGlobalBarsType的所有属性,但注意backgroundColor,width,gap不能作为系列设置
                适用于: 柱状图、条形图、组合图的柱状部分

                属性:
                - color: 该系列所有柱子的填充颜色
                - borderColor: 该系列所有柱子的边框颜色
                - borderWidth: 该系列所有柱子的边框宽度
                - borderStyle: 该系列所有柱子的边框样式

                子元素:
                - fillGradient: 该系列所有柱子的填充渐变(可选)
                - strokeGradient: 该系列所有柱子的边框渐变(可选)
                - chartBar: 单个柱子配置(可选, 多个), 用于覆盖特定柱子的样式
            </xs:documentation>
        </xs:annotation>
        <xs:complexContent>
            <xs:extension base="sml:ChartGlobalBarsType">
                <xs:sequence>
                    <xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
                    <xs:element name="strokeGradient" type="sml:ChartGradientType" minOccurs="0"/>
                    <xs:element name="chartBar" minOccurs="0" maxOccurs="unbounded">
                        <xs:complexType>
                            <xs:annotation>
                                <xs:documentation>
                                    单个柱子配置(第三层:最高优先级)

                                    属性:
                                    - index: 柱子索引(从1开始, 必需), 表示该系列中第几个柱子
                                    - color: 该柱子的填充颜色
                                    - borderColor: 该柱子的边框颜色
                                    - borderWidth: 该柱子的边框宽度(像素)
                                    - borderStyle: 该柱子的边框样式(solid|dashed|dotted)

                                    子元素:
                                    - fillGradient: 该柱子的填充渐变(可选)
                                </xs:documentation>
                            </xs:annotation>
                            <xs:sequence>
                                <xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
                            </xs:sequence>
                            <xs:attribute name="index" type="xs:positiveInteger" use="required"/>
                            <xs:attribute name="color" type="sml:SolidColor" use="optional"/>
                            <xs:attribute name="borderColor" type="sml:SolidColor" use="optional"/>
                            <xs:attribute name="borderWidth" type="xs:nonNegativeInteger" use="optional"/>
                            <xs:attribute name="borderStyle" type="sml:ChartLineStyleType" use="optional"/>
                        </xs:complexType>
                    </xs:element>
                </xs:sequence>
            </xs:extension>
        </xs:complexContent>
    </xs:complexType>

    <!-- 扇区配置(饼图专用) -->
    <xs:complexType name="ChartSectorType">
        <xs:annotation>
            <xs:documentation>
                图表单个扇区配置
                适用于: 饼图、环形图

                属性:
                - index: 扇区索引(从1开始, 必需), 表示第几个扇区
                - offsetRadius: 扇区径向偏移比例[0,1], 用于突出显示
                - borderColor: 扇区边框颜色
                - color: 扇区填充颜色

                子元素:
                - fillGradient: 扇区填充渐变(可选)
            </xs:documentation>
        </xs:annotation>
        <xs:sequence>
            <xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
        </xs:sequence>
        <xs:attribute name="index" type="xs:positiveInteger" use="required"/>
        <xs:attribute name="offsetRadius" type="sml:RatioType" use="optional"/>
        <xs:attribute name="borderColor" type="sml:SolidColor" use="optional"/>
        <xs:attribute name="color" type="sml:SolidColor" use="optional"/>
    </xs:complexType>

    <xs:complexType name="ChartSectorsType">
        <xs:annotation>
            <xs:documentation>
                图表扇区整体配置
                适用于: 饼图、环形图

                属性:
                - borderColor: 所有扇区的统一边框颜色
                - innerRadius: 内半径比例[0,1], 0为饼图, 大于0为环形图, 默认0
                - offsetRadius: 整体径向偏移比例[0,1], 将整个饼图从中心推开, 默认0
                - startAngle: 起始角度[0,360), 控制第一个扇区的起始位置, 默认0

                子元素:
                - fillGradient: 所有扇区的统一填充渐变(可选)
                - chartSector: 单个扇区配置(可选, 多个), 用于定制特定扇区
            </xs:documentation>
        </xs:annotation>
        <xs:sequence>
            <xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
            <xs:element name="chartSector" type="sml:ChartSectorType" minOccurs="0" maxOccurs="unbounded"/>
        </xs:sequence>
        <xs:attribute name="borderColor" type="sml:SolidColor" use="optional"/>
        <xs:attribute name="innerRadius" type="sml:RatioType" use="optional" default="0"/>
        <xs:attribute name="offsetRadius" type="sml:RatioType" use="optional" default="0"/>
        <xs:attribute name="startAngle" type="sml:RotationType" use="optional" default="0"/>
    </xs:complexType>

    <!-- ==================== 系列配置 ==================== -->

    <xs:complexType name="ChartSeriesListType">
        <xs:annotation>
            <xs:documentation>
                图表多系列配置

                子元素:
                - chartSeries: 单个系列配置(必需, 1个或多个), 用于定制特定系列
            </xs:documentation>
        </xs:annotation>
        <xs:sequence>
            <xs:element name="chartSeries" type="sml:ChartSeriesType" minOccurs="1" maxOccurs="unbounded"/>
        </xs:sequence>
    </xs:complexType>

    <xs:complexType name="ChartSeriesType">
        <xs:annotation>
            <xs:documentation>
                图表单个系列完整配置(用于覆盖全局配置或定制特定系列)

                属性:
                - index: 系列索引(从1开始, 必需), 指定作用于第几个系列
                - comboType: 组合图中该系列的类型(column|line|area)
                - yAxisPosition: 该系列使用的Y轴位置(left|right)

                子元素:
                - chartPoints: 数据点样式配置(可选)
                - chartLine: 线条样式配置(可选)
                - chartArea: 面积样式配置(可选)
                - chartBars: 柱子样式配置(可选)
                - chartSectors: 扇区样式配置(可选)
                - chartLabels: 数据标签配置(可选)
                - chartTooltip: 数据提示框配置(可选)
            </xs:documentation>
        </xs:annotation>
        <xs:sequence>
            <xs:element name="chartPoints" type="sml:ChartSeriesPointsType" minOccurs="0"/>
            <xs:element name="chartLine" type="sml:ChartSeriesLineType" minOccurs="0"/>
            <xs:element name="chartArea" type="sml:ChartSeriesAreaType" minOccurs="0"/>
            <xs:element name="chartBars" type="sml:ChartSeriesBarsType" minOccurs="0"/>
            <xs:element name="chartSectors" type="sml:ChartSectorsType" minOccurs="0"/>
            <xs:element name="chartLabels" type="sml:ChartDataLabelsType" minOccurs="0"/>
            <xs:element name="chartTooltip" type="sml:ChartTooltipType" minOccurs="0"/>
        </xs:sequence>
        <xs:attribute name="index" type="xs:positiveInteger" use="required"/>
        <xs:attribute name="comboType" type="sml:ChartComboSeriesType" use="optional"/>
        <xs:attribute name="yAxisPosition" type="sml:ChartYAxisPositionType" use="optional"/>
    </xs:complexType>

    <!-- ==================== 坐标轴配置 ==================== -->
    <xs:complexType name="ChartGridLineType">
        <xs:annotation>
            <xs:documentation>
                图表网格线配置

                属性:
                - width: 网格线宽度(像素)
                - color: 网格线颜色
            </xs:documentation>
        </xs:annotation>
        <xs:attribute name="width" type="xs:nonNegativeInteger" use="optional"/>
        <xs:attribute name="color" type="sml:SolidColor" use="optional"/>
    </xs:complexType>

    <xs:complexType name="ChartAxisLineType">
        <xs:annotation>
            <xs:documentation>
                图表坐标轴线配置(空标签即显示轴线)

                无属性
                无子元素
            </xs:documentation>
        </xs:annotation>
    </xs:complexType>

    <xs:complexType name="ChartAxisLabelType">
        <xs:annotation>
            <xs:documentation>
                图表坐标轴标签配置

                属性:
                - angle: 标签旋转角度(-90|-45|0|45|90), 用于避免标签重叠, 不设置时自动计算
                - format: 数字格式代码,例如 0%、0.00%、0.00、#,##0.00、¥#,##0.00, 0万
                - fontSize: 字号大小
                - bold: 是否加粗
                - italic: 是否斜体, 默认false
                - underline: 是否下划线, 默认false
                - strikethrough: 是否删除线, 默认false
                - color: 文本颜色
            </xs:documentation>
        </xs:annotation>
        <xs:complexContent>
            <xs:extension base="sml:ChartFontStyleType">
                <xs:attribute name="angle" type="sml:ChartAxisLabelAngleType" use="optional"/>
                <xs:attribute name="format" type="xs:string" use="optional"/>
            </xs:extension>
        </xs:complexContent>
    </xs:complexType>

    <xs:complexType name="ChartAxisType">
        <xs:annotation>
            <xs:documentation>
                图表单个坐标轴配置

                属性:
                - type: 坐标轴类型(必需)
                * 直角坐标系: x(横轴)、y(纵轴)
                * 极坐标系: angle(角度轴)、radius(半径轴)
                - position: 坐标轴位置(可选)(left|right), 仅对y轴有效
                - max: 坐标轴最大值(不设置时自动计算)
                - min: 坐标轴最小值(不设置时自动计算)

                子元素:
                - chartTitle: 坐标轴标题(可选)
                - chartLabel: 坐标轴刻度标签格式(可选)
                - chartAxisLine: 坐标轴线配置(可选), 未设置时不显示轴线
                - chartGridLine: 网格线配置(可选), 未设置时不显示网格线
            </xs:documentation>
        </xs:annotation>
        <xs:all>
            <xs:element name="chartTitle" type="sml:ChartTitleType" minOccurs="0"/>
            <xs:element name="chartLabel" type="sml:ChartAxisLabelType" minOccurs="0"/>
            <xs:element name="chartAxisLine" type="sml:ChartAxisLineType" minOccurs="0"/>
            <xs:element name="chartGridLine" type="sml:ChartGridLineType" minOccurs="0"/>
        </xs:all>
        <xs:attribute name="type" type="sml:ChartAxisKindType" use="required"/>
        <xs:attribute name="position" type="sml:ChartYAxisPositionType" use="optional"/>
        <xs:attribute name="max" type="xs:integer" use="optional"/>
        <xs:attribute name="min" type="xs:integer" use="optional"/>
    </xs:complexType>

    <xs:complexType name="ChartAxesType">
        <xs:annotation>
            <xs:documentation>
                图表坐标轴容器

                子元素:
                - chartAxis: 坐标轴配置, 可包含多个坐标轴, 建议至少包含 2 个坐标轴配置

                柱状图、条形图、折线图、面积图、组合图: X 轴、Y 轴(可双轴)
                雷达图: 角度轴、半径轴
            </xs:documentation>
        </xs:annotation>
        <xs:sequence>
            <xs:element name="chartAxis" type="sml:ChartAxisType" minOccurs="1" maxOccurs="unbounded"/>
        </xs:sequence>
    </xs:complexType>

    <!-- ==================== 特殊图表类型配置 ==================== -->

    <xs:complexType name="ChartLineSmoothType">
        <xs:annotation>
            <xs:documentation>
                图表平滑折线配置(空标签即启用, 将折线转换为贝塞尔曲线)
                适用于: 折线图、面积图、雷达图、组合图

                无属性
                无子元素
            </xs:documentation>
        </xs:annotation>
    </xs:complexType>

    <xs:complexType name="ChartLineStepType">
        <xs:annotation>
            <xs:documentation>
                图表阶梯折线配置(空标签即启用, 将折线转换为阶梯状)
                适用于: 折线图、面积图、组合图

                无属性
                无子元素
            </xs:documentation>
        </xs:annotation>
    </xs:complexType>

    <xs:complexType name="ChartStackType">
        <xs:annotation>
            <xs:documentation>
                图表堆叠配置(将多个系列的值累加显示)
                适用于: 面积图、条形图、柱状图、组合图

                属性:
                - percentage: 是否启用百分比堆叠(显示占比而非绝对值), 默认false
            </xs:documentation>
        </xs:annotation>
        <xs:attribute name="percentage" type="xs:boolean" use="optional" default="false"/>
    </xs:complexType>

    <xs:complexType name="ChartRadarType">
        <xs:annotation>
            <xs:documentation>
                雷达图专用配置

                属性:
                - shape: 雷达图外轮廓形状(polygon|circle), 默认polygon
                - area: 是否填充区域(类似面积图效果), 默认true
            </xs:documentation>
        </xs:annotation>
        <xs:attribute name="shape" type="sml:ChartRadarShapeType" use="optional" default="polygon"/>
        <xs:attribute name="area" type="xs:boolean" use="optional" default="true"/>
    </xs:complexType>

    <!-- ==================== 绘图区配置 ==================== -->

    <xs:complexType name="ChartPlotAreaType">
        <xs:annotation>
            <xs:documentation>
                图表绘图区配置(定义图表类型和数据坐标体系)
            </xs:documentation>
        </xs:annotation>
        <xs:all>
            <xs:element name="chartPlot" type="sml:ChartPlotType" minOccurs="1"/>
            <xs:element name="chartAxes" type="sml:ChartAxesType" minOccurs="0"/>
        </xs:all>
    </xs:complexType>

    <xs:complexType name="ChartPlotType">
        <xs:annotation>
            <xs:documentation>
                图表绘图区配置(定义图表类型和数据坐标体系)

                属性:
                - type: 图表类型(必需)
                * line(折线图) | area(面积图) | bar(条形图) | column(柱状图)
                * pie(饼图) | radar(雷达图) | combo(组合图)
                - comboType: 组合图系列类型(可选), 仅组合图使用
                - yAxisPosition: 右Y轴位置(可选), 只有在笛卡尔坐标系下, 默认值 left, 其他坐标系图表如饼图, 没有 yAxisPosition

                子元素:
                - chartPoints: 全局数据点样式配置(可选)
                - chartLines: 全局线条样式配置(可选)
                - chartAreas: 全局面积样式配置(可选)
                - chartBars: 全局柱子样式配置(可选)
                - chartLabels: 全局数据标签配置(可选)
                - chartSeriesList: 系列列表配置(可选), 用于单独配置每个系列
                - chartExtra: 额外配置(可选)
            </xs:documentation>
        </xs:annotation>
        <xs:all>
            <xs:element name="chartPoints" type="sml:ChartGlobalPointsType" minOccurs="0"/>
            <xs:element name="chartLines" type="sml:ChartGlobalLineType" minOccurs="0"/>
            <xs:element name="chartAreas" type="sml:ChartGlobalAreaType" minOccurs="0"/>
            <xs:element name="chartBars" type="sml:ChartGlobalBarsType" minOccurs="0"/>
            <xs:element name="chartLabels" type="sml:ChartDataLabelsType" minOccurs="0"/>
            <xs:element name="chartSeriesList" type="sml:ChartSeriesListType" minOccurs="0"/>
            <xs:element name="chartExtra" type="sml:ChartExtraType" minOccurs="0"/>
        </xs:all>
        <xs:attribute name="type" type="sml:ChartType" use="required"/>
        <xs:attribute name="comboType" type="sml:ChartComboSeriesType" use="optional"/>
        <xs:attribute name="yAxisPosition" type="sml:ChartYAxisPositionType" use="optional" default="left"/>
    </xs:complexType>

    <xs:complexType name="ChartExtraType">
        <xs:annotation>
            <xs:documentation>
                图表额外配置
                - chartRadar: 雷达图特有配置(可选), 仅雷达图使用
                - chartSmooth: 平滑折线开关(可选), 适用于折线/面积/雷达/组合图
                - chartStep: 阶梯折线开关(可选), 适用于折线/面积/组合图
                - chartStack: 堆叠设置(可选), 适用于面积/条形/柱状/组合图
            </xs:documentation>
        </xs:annotation>
        <xs:all>
            <xs:element name="chartRadar" type="sml:ChartRadarType" minOccurs="0"/>
            <xs:element name="chartSmooth" type="sml:ChartLineSmoothType" minOccurs="0"/>
            <xs:element name="chartStep" type="sml:ChartLineStepType" minOccurs="0"/>
            <xs:element name="chartStack" type="sml:ChartStackType" minOccurs="0"/>
        </xs:all>
    </xs:complexType>


    <!-- ==================== 数据配置 ==================== -->
    <xs:complexType name="ChartDataFieldType">
        <xs:annotation>
            <xs:documentation>
                图表数据字段定义

                属性:
                - name: 字段名称(必需), 列标题或系列名称
                - valueType: 数据值类型(string|number)

                内容: CSV格式的数据值(逗号分隔)
                - 用逗号 ', ' 分割值, 禁止传入 array
                - 当 valueType="string" 时, 若字符串包含逗号需用双引号包裹, 如:"a, b", c, d
                - 当 valueType="number" 时, 直接用逗号分割数字, 如:1, 2, 3.5
            </xs:documentation>
        </xs:annotation>
        <xs:simpleContent>
            <xs:extension base="xs:string">
                <xs:attribute name="name" type="xs:string" use="required"/>
                <xs:attribute name="valueType" type="sml:ChartValueType" use="optional"/>
            </xs:extension>
        </xs:simpleContent>
    </xs:complexType>

    <xs:complexType name="ChartDataDim1Type">
        <xs:annotation>
            <xs:documentation>
                图表分类维度(第一维度:X轴/分类)
                定义数据点的分类标签或X轴坐标值

                子元素:
                - chartField: 数据字段(有且仅有1个)
            </xs:documentation>
        </xs:annotation>
        <xs:sequence>
            <xs:element name="chartField" type="sml:ChartDataFieldType" minOccurs="1" maxOccurs="1"/>
        </xs:sequence>
    </xs:complexType>

    <xs:complexType name="ChartDataDim2Type">
        <xs:annotation>
            <xs:documentation>
                图表数值维度(第二维度:Y轴/数值)
                包含数值系列数据, 每个field代表一个系列

                子元素:
                - chartField: 数据字段(至少1个), 每个字段代表一个数据系列
            </xs:documentation>
        </xs:annotation>
        <xs:sequence>
            <xs:element name="chartField" type="sml:ChartDataFieldType" minOccurs="1" maxOccurs="unbounded"/>
        </xs:sequence>
    </xs:complexType>

    <xs:complexType name="ChartDataType">
        <xs:annotation>
            <xs:documentation>
                图表数据容器(采用二维数据模型)

                数据组织方式:
                dim1中每个值对应dim2中所有系列的同一位置数据
                例如: dim1的第2个值对应dim2中所有系列的第2个值

                子元素:
                - dim1: 分类维度(必需), 定义数据点的位置
                - dim2: 数值维度(必需), 定义数据系列
            </xs:documentation>
        </xs:annotation>
        <xs:sequence>
            <xs:element name="dim1" type="sml:ChartDataDim1Type" minOccurs="1" maxOccurs="1"/>
            <xs:element name="dim2" type="sml:ChartDataDim2Type" minOccurs="1" maxOccurs="1"/>
        </xs:sequence>
    </xs:complexType>

    <xs:element name="chart">
        <xs:annotation>
            <xs:documentation>
                图表元素, 用于展示数据可视化

                属性:
                - id: 图表元素ID (可选)
                - topLeftX: 左上角X坐标 [-8640,10560]
                - topLeftY: 左上角Y坐标 [-4860,5940]
                - width: 图表宽度
                - height: 图表高度
                - rotation: 旋转角度 [0,360), 默认0
                - flipX: 水平翻转, 默认false
                - flipY: 垂直翻转, 默认false
                - alpha: 透明度 [0,1], 默认1

                子元素:
                - chartTitle: 标题设置 (可选)
                - chartSubTitle: 副标题设置 (可选)
                - chartStyle: 图表样式 (可选)
                - chartPlotArea: 绘图区配置 (必需)
                - chartData: 数据容器 (必需)
                - chartLegend: 图例配置(可选)
                - chartTooltip: Tooltip 配置(可选)
                - reflection: 倒影效果 (可选)
                - shadow: 阴影效果 (可选)
            </xs:documentation>
        </xs:annotation>
        <xs:complexType>
            <xs:all>
                <xs:element name="chartTitle" type="sml:ChartTitleType" minOccurs="0"/>
                <xs:element name="chartSubTitle" type="sml:ChartTitleType" minOccurs="0"/>
                <xs:element name="chartStyle" type="sml:ChartStyleType" minOccurs="0"/>
                <xs:element name="chartLegend" type="sml:ChartLegendType" minOccurs="0"/>
                <xs:element name="chartTooltip" type="sml:ChartTooltipType" minOccurs="0"/>
                <xs:element name="chartPlotArea" type="sml:ChartPlotAreaType" minOccurs="1"/>
                <xs:element name="chartData" type="sml:ChartDataType" minOccurs="1"/>
                <xs:element name="reflection" type="sml:ReflectionType" minOccurs="0"/>
                <xs:element name="shadow" type="sml:ShadowType" minOccurs="0"/>
            </xs:all>
            <xs:attribute name="id" type="xs:string" use="optional"/>
            <xs:attribute name="topLeftX" type="sml:XType" use="required"/>
            <xs:attribute name="topLeftY" type="sml:YType" use="required"/>
            <xs:attribute name="width" type="sml:PositiveSize" use="required"/>
            <xs:attribute name="height" type="sml:PositiveSize" use="required"/>
            <xs:attribute name="rotation" type="sml:RotationType" use="optional" default="0"/>
            <xs:attribute name="flipX" type="xs:boolean" use="optional" default="false"/>
            <xs:attribute name="flipY" type="xs:boolean" use="optional" default="false"/>
            <xs:attribute name="alpha" type="sml:AlphaType" use="optional" default="1"/>
        </xs:complexType>
    </xs:element>

</xs:schema>
references/xml/xml-schema-quick-ref.md
# XML Schema 快速参考

本文档是 [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml) 的精简版摘要,并合并了常用 XML 格式写法;如果两者不一致,以 XSD 原文为准。

## 最重要的规则

1. 协议标准写法应使用 `<presentation xmlns="https://www.larkoffice.com/sml/2.0">`;当前服务端实现可能兼容不带 `xmlns` 的输入,但不作为协议保证
2. `<presentation>` 直接子元素只有 `<title>`、`<theme>`、`<slide>`
3. `<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`
4. 页面中的文本通常通过 `<content>` 表达,而不是把 `<title>`、`<body>` 直接挂在 `<slide>` 下

## 最小可用示例

```xml
<presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
  <slide>
    <data>
      <shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
        <content autoFit="normal-auto-fit" wrap="true" textType="title">
          <p>标题</p>
        </content>
      </shape>
    </data>
  </slide>
</presentation>
```

## presentation 根元素

| 属性 | 必需 | 说明 |
|------|------|------|
| `width` | 是 | 演示文稿宽度,正整数,标准 16:9 页面建议使用 `960` |
| `height` | 是 | 演示文稿高度,正整数,标准 16:9 页面建议使用 `540` |
| `id` | 否 | 演示文稿标识 |

**子元素:** `<title>?`, `<theme>?`, `<slide>+`

`<slide>` 至少 1 页,最多 100 页。

## theme 与文本类型

`<theme>` 当前包含两部分:

- `<background>`:演示文稿级背景填充
- `<textStyles>`:主题文本样式集合

`<textStyles>` 下可选子元素包括 `<title>`、`<headline>`、`<sub-headline>`、`<body>`、`<caption>`。这些元素定义的是主题默认样式,不是页面结构。

常用属性:

| 属性 | 说明 |
|------|------|
| `fontFamily` | 字体 |
| `fontSize` | 字号 |
| `fontColor` | 字体颜色 |

XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出现在:

- `<theme><textStyles>...</textStyles></theme>` 中,作为主题文本样式
- `<content textType="...">` 中,作为内容的文本类型

`textStyles` 的 schema 默认值如下:

| textType | 默认字号 |
|----------|----------|
| `title` | 54 |
| `headline` | 38 |
| `sub-headline` | 32 |
| `body` | 16 |
| `caption` | 12 |

默认字号是省略 `fontSize` 时的兜底字号,不是推荐值。字号必须显式设置 `<content>` 的 `fontSize` 属性,不要依赖 `textType` 的默认字号兜底,这些兜底值明显偏大。

## slide 元素

| 属性 | 必需 | 说明 |
|------|------|------|
| `id` | 否 | 幻灯片标识 |

**子元素:**

- `<style>?` - 页面样式,目前可放 `<fill>`
- `<data>?` - 页面元素容器,可放 `shape`、`line`、`polyline`、`img`、`table`、`icon`、`embed`、`chart`、`undefined`
- `<note>?` - 演讲者备注,内部可放 `<content>`

这意味着 `<title>`、`<headline>`、`<body>`、`<caption>` 不能直接放在 `<slide>` 下。

## content 内容模型

`<content>` 可出现在 `shape`、`table/td`、`note` 中,常用属性包括:

| 属性 | 说明 |
|------|------|
| `textType` | `title` / `headline` / `sub-headline` / `body` / `caption` |
| `verticalAlign` | 垂直对齐 |
| `textAlign` | 文本对齐方式 |
| `lineSpacing` | 行间距,schema 默认 `multiple:1.5` |
| `fontSize` | 字号 |
| `fontFamily` | 字体 |
| `color` | 字体颜色 |
| `bold` / `italic` / `underline` / `strikethrough` | 内容级样式 |
| `wrap` | 是否自动换行 |
| `autoFit` | 是否自动缩排 |

注意事项:

- 字号必须显式设置 `<content>` 的 `fontSize` 属性,不要依赖 `textType` 的默认字号兜底,这些兜底值明显偏大。
- 大数字、字号大或字数多的 `<content>` 必须设置 `wrap="true" autoFit="normal-auto-fit"` 属性自动换行和缩排,避免文字溢出。
- 文字颜色必须用 `<content>` 的 `color` 属性而不是 `fontColor` 属性。
- 文字行间距必须设置 `<content>` 的 `lineSpacing="multiple:xx"` 或 `lineSpacing="fixed:xx"` 而不是 `lineSpacing="xx"`。

`<content>` 直接子元素只有:

- `<p>`
- `<ul>`
- `<ol>`

### p 段落与内联标签

`<p>` 是段落元素,可混排纯文本和内联标签:

- `<br/>`
- `<strong>`
- `<em>`
- `<u>`
- `<span>`
- `<del>`
- `<a>`
- `<shadow>`
- `<outline>`
- `<formula>`

公式写法:

```xml
<p>公式:<formula><latex><![CDATA[ E = mc^2 ]]></latex></formula></p>
```

`<formula>` 是内联元素;当前只支持一个 `<latex>` 子元素。LaTeX 内容必须放在 `CDATA` 中,且 `CDATA` 内不要写 XML 转义;宏只使用服务端支持范围内的写法,优先用基础运算符、`\frac`、`\sqrt`、`matrix`。

示例:

```xml
<content autoFit="normal-auto-fit" textType="body" textAlign="left">
  <p>正文内容 <strong>加粗</strong> <em>斜体</em> <a href="https://example.com">链接</a></p>
  <ul>
    <li><p>列表项 1</p></li>
    <li><p>列表项 2</p></li>
  </ul>
</content>
```

## data 常用元素

所有页面元素都放在 `<data>` 中。

### shape

`shape` 可表示普通形状,也可表示文本框。文本框推荐使用 `type="text"`。

```xml
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
  <content textType="title">
    <p>主标题</p>
  </content>
</shape>
```

```xml
<shape type="rect" topLeftX="120" topLeftY="120" width="240" height="120">
  <fill>
    <fillColor color="rgb(100, 149, 237)"/>
  </fill>
  <border color="rgb(0, 0, 0)" width="2"/>
</shape>
```
`<shape type="rect">` 只是形状不是容器,`<icon>`、`<img>`、`<shape type="text">` 和其他 `<shape>` 必须与它平级靠坐标叠放。


| 属性 | 必需 | 说明 |
|------|------|------|
| `type` | 是 | 形状类型,`text` 表示文本框 |
| `topLeftX` | 是 | 左上角 X 坐标 |
| `topLeftY` | 是 | 左上角 Y 坐标 |
| `width` | 是 | 宽度 |
| `height` | 是 | 高度 |
| `rotation` | 否 | 旋转角度 |
| `flipX` / `flipY` | 否 | 翻转 |
| `alpha` | 否 | 透明度 |

可选子元素:

- `<fill>`
- `<border>`
- `<reflection>`
- `<shadow>`
- `<content>`

`type` 常用取值:`text`(文本框)、`rect`、`round-rect`(圆角矩形)、`ellipse`(椭圆/圆)、`triangle`、`diamond`、`parallelogram`、`trapezoid`、`custom`(配合 `path` 属性写 SVG 路径串)。箭头、星形、标注气泡、`chevron`、`flow-chart-*` 等更多形状见 XSD `ShapeType` 枚举。

其它可选属性:

- `presetHandlers`:控制点,用于圆角等。例如 `<shape type="rect" presetHandlers="60">` = 圆角半径 60px 的圆角矩形;多个控制点用逗号分隔。
- `path`:仅 `type="custom"` 时使用,SVG 路径串。

### line

```xml
<line startX="120" startY="120" endX="420" endY="120">
  <border color="rgb(43, 47, 54)" width="2"/>
</line>
```

`line` 使用的是 `startX` / `startY` / `endX` / `endY`,不是 `x1` / `y1` / `x2` / `y2`。

### polyline

折线 / 曲线连接线,用外接矩形定位(`topLeftX` / `topLeftY` / `width` / `height`),不是端点坐标;`<border>` 必填(无 border 不可见)。`type` 默认 `bent-connector2`(可选 `bent-connector2-5` 折线 / `curved-connector2-5` 曲线)。

```xml
<polyline topLeftX="120" topLeftY="120" width="200" height="100">
  <border color="rgb(43, 47, 54)" width="2"/>
</polyline>
```

### img

```xml
<img src="file_token_或_@本地路径" topLeftX="80" topLeftY="120" width="320" height="180"/>
```

`img` 使用 `topLeftX` / `topLeftY`,不是 `x` / `y`。

`src` 只支持:`slides +media-upload` 返回的 `file_token`,或 `@<本地路径>` 占位符(`+create --slides` 和 `+add-slide` 会自动上传并替换)。**禁止使用 http(s) 外链 URL**——飞书 slides 渲染端不会代理外链图,外链 src 在 PPT 里通常不显示。本地图片详见 [lark-slides-create.md](../cli/lark-slides-create.md#本地图片path-占位符) / [lark-slides-media-upload.md](../cli/lark-slides-media-upload.md)。

本地图片的两种姿势:

- 新建带图 PPT:`+create --slides` 里直接写 `src="@./pic.png"`,CLI 在创空白 PPT 后、加 slides 前自动上传并替换 token
- 给已有 PPT 加带图新页:`+add-slide --slide` 的 XML 里直接写 `src="@./pic.png"`,CLI 上传后替换 token 再提交页面

> **注意**:`width`/`height` 是**裁剪后**的显示尺寸。比例和原图不一致时会自动裁剪(无法靠属性关闭),想避免裁剪就让 `width:height` 对齐原图比例。

### icon

```xml
<icon iconType="iconpark/Charts/chart-line.svg" topLeftX="80" topLeftY="120" width="32" height="32">
  <fill>
    <fillColor color="rgba(37, 99, 235, 1)"/>
  </fill>
</icon>
```

图标必须填充颜色并和背景有足够对比。

禁止盲猜 iconType,必须先检索 IconPark,再写 `<icon iconType="...">`。检索方式和更多规则见 [iconpark.md](iconpark.md)。


### table

表格结构为:

- `<table>` 直接子元素只有 `<colgroup>` 和 `<tr>`,`width` 和 `height` 分别表示表格的目标总宽度和总高度。
- `<colgroup>` 直接子元素只有 `<col width="...">`,width 定义列宽,默认 110。
- `<tr height="...">` 直接子元素只有 `<td>`,height 定义行高,默认 37。
- `<td>` 直接子元素只有 `<fill>`(背景)、`<content>`(文字)和边框配置(一般不用),不能嵌套 `<shape>`、`<img>`、`<icon>`。
- 合并单元格:`<td>` 上用 `colspan`(跨列,默认 1)和 `rowspan`(跨行,默认 1);被合并覆盖的单元格不再写对应 `<td>`。

表头默认的白底白字视觉效果极差,必须设置背景和文字颜色,需在首行每个 `<td>` 上加 `<fill>`(配合 `bold` 与对比文字色)与正文行区分。

表格里的文字默认是居中对齐,可以设置 `textAlign` 调整对齐方式。

表格宽高设置:

- 已设置的列宽和行高优先保留,未设置的列宽、行高会使用表格的目标总宽度、总高度分配剩余空间
- **必须设置 `<table>` 的 `width` 和 `height` 固定表格大小,同时设置需要保留列宽或行高的 `<col>` 的 `width` 和 `<tr>` 的 `height`,其余自动分配。**

不同字号的行高参考:

| `fontSize` | 内容行数 | 紧凑 `height` | 适中 `height` | 宽松 `height` |
|------|------|------|------|------|
| 10 | 单行 | 16 | 20 | 24 |
| 12 | 单行 | 20 | 24 | 28 |
| 10 | 双行 | 32 | 36 | 42 |
| 12 | 双行 | 36 | 42 | 48 |

示例:

```xml
<table topLeftX="80" topLeftY="140" width="520" height="52">
  <colgroup>
    <col width="160"/>
    <col width="120"/>
    <col />
  </colgroup>
  <tr height="28">
    <td>
      <fill><fillColor color="rgba(30,60,114,1)"/></fill>
      <content textType="body" fontSize="12" bold="true" color="rgba(255,255,255,1)" textAlign="center"><p>项目</p></content>
    </td>
    <td>
      <fill><fillColor color="rgba(30,60,114,1)"/></fill>
      <content textType="body" fontSize="12" bold="true" color="rgba(255,255,255,1)" textAlign="right"><p>营收</p></content>
    </td>
    <td>
      <fill><fillColor color="rgba(30,60,114,1)"/></fill>
      <content textType="body" fontSize="12" bold="true" color="rgba(255,255,255,1)" textAlign="left"><p>备注说明</p></content>
    </td>
  </tr>
  <tr>
    <td><content textType="body" fontSize="10" textAlign="center"><p>线上业务</p></content></td>
    <td><content textType="body" fontSize="10" textAlign="right"><p>195</p></content></td>
    <td><content textType="body" fontSize="10" textAlign="left"><p>同比增长 8%,主要来自新客</p></content></td>
  </tr>
</table>
```

### chart

图表语法十分复杂,必须阅读 [slides_chart_demo.xml](slides_chart_demo.xml),直接照抄其中的柱状、条形、折线、面积、饼(环)、雷达、组合图。

`<chart>` 直接子元素必须有 `<chartPlotArea>`(绘图区)和 `<chartData>`(数据);`<chartTitle>`、`<chartSubTitle>`、`<chartStyle>`、`<chartLegend>`、`<chartTooltip>` 可选,如果想不展示标题、副标题、图例或悬浮提示,省略相应元素标签即可。

`<chartStyle>` 常用子元素:

- `<chartBackground>`:`color` 省略时由渲染端决定默认背景;需要完全透明请显式写 `color="rgba(0, 0, 0, 0)"`
- `<chartBorder>`:无边框可写 `width="0"`,或直接不写 `<chartBorder>` 元素

#### 图表渐变 `<fillGradient>` / `<strokeGradient>`

图表支持渐变填充/描边,`<fillGradient>` 用于面积、柱子、数据点、扇区填充,`<strokeGradient>` 用于线条、数据点边框、柱子边框。渐变只能挂在系列级或单元素级,不要挂在 `<chartPlot>` 全局层。

可挂载位置:

- 系列级:`<chartBars>` / `<chartPoints>` 支持 `<fillGradient>` 与 `<strokeGradient>`;`<chartLine>` 只支持 `<strokeGradient>`;`<chartArea>` / `<chartSectors>` 只支持 `<fillGradient>`
- 单元素级:`<chartBar index="...">` / `<chartPoint index="...">` / `<chartSector index="...">` 只支持 `<fillGradient>`
- 全局级:`<chartPlot>` 下的 `<chartLines>` / `<chartAreas>` / `<chartBars>` / `<chartPoints>` 不支持渐变

结构要点:`type` 必填,可为 `linear` 或 `radial`;`linear` 用 `x0` / `y0` / `x1` / `y1`,`radial` 用 `r0` / `r1`;`<stops>` 至少包含 2 个 `<stop>`,`offset` 与 `opacity` 取值均为 `[0, 1]`。

```xml
<chartSeries index="1">
  <chartBars>
    <fillGradient type="linear" x0="0" y0="0" x1="0" y1="1">
      <stops>
        <stop offset="0" color="rgb(28, 71, 120)"/>
        <stop offset="1" color="rgb(28, 71, 120)" opacity="0.3"/>
      </stops>
    </fillGradient>
  </chartBars>
</chartSeries>
```

隐藏 `<chart>` 的图例只能通过不写或删除 `<chartLegend>` 实现,`<chartLegend>` 不支持 `position="none"`。

详细用法见 [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml)。

### embed

嵌入内容容器:外层 `<embed>` 承载 Slides 的摆放和效果属性(`topLeftX`/`topLeftY`/`width`/`height` 必填,`rotation`/`flipX`/`flipY`/`alpha` 可选),内层承载外部标准内容。当前内层内容为标准 SVG,`<svg>` 必须使用 `http://www.w3.org/2000/svg` 命名空间,且仅描述嵌入内容本身,不承载 Slides 布局属性。

```xml
<embed topLeftX="80" topLeftY="120" width="200" height="120">
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 120">
    <circle cx="100" cy="60" r="40" fill="rgba(37, 99, 235, 1)"/>
  </svg>
</embed>
```

`<embed>` 直接子元素为一个 `<svg>`(必需),以及可选的 `<reflection>`(倒影)与 `<shadow>`(阴影)。

## 颜色与样式

### fill

```xml
<fill>
  <fillColor color="rgb(255, 0, 0)"/>
</fill>
```

### border

```xml
<border color="rgb(43, 47, 54)" width="2" dashArray="solid"/>
```

### 颜色格式

```xml
<fillColor color="rgb(255, 0, 0)"/>
<fillColor color="rgba(255, 0, 0, 0.5)"/>
<fillColor color="linear-gradient(90deg, rgb(255,0,0) 0%, rgb(0,0,255) 100%)"/>
<fillColor color="radial-gradient(circle at 50% 50%, rgb(255,0,0) 0%, rgb(0,0,255) 100%)"/>
```

> **注意**:渐变色必须使用 `rgba()` 格式并带百分比停靠点,例如 `linear-gradient(135deg,rgba(30,60,114,1) 0%,rgba(59,130,246,1) 100%)`。使用 `rgb()` 或省略停靠点会导致服务端将其回退为白色。此规则对页面背景和 shape fill 均适用。

### 页面背景

```xml
<!-- 纯色背景 -->
<slide>
  <style>
    <fill>
      <fillColor color="rgb(245, 245, 245)"/>
    </fill>
  </style>
</slide>

<!-- 渐变背景(必须用 rgba + 百分比停靠点) -->
<slide>
  <style>
    <fill>
      <fillColor color="linear-gradient(135deg,rgba(30,60,114,1) 0%,rgba(59,130,246,1) 100%)"/>
    </fill>
  </style>
</slide>
```

## 备注示例

```xml
<note>
  <content autoFit="normal-auto-fit" textType="body">
    <p>这是演讲者备注。</p>
  </content>
</note>
```

## 完整示例

```xml
<presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
  <title>季度报告</title>
  <theme>
    <textStyles>
      <title fontFamily="思源黑体" fontSize="54" fontColor="rgba(0, 0, 0, 1)"/>
      <body fontFamily="思源黑体" fontSize="18" fontColor="rgba(43, 47, 54, 1)"/>
    </textStyles>
  </theme>
  <slide>
    <style>
      <fill>
        <fillColor color="rgb(245, 245, 245)"/>
      </fill>
    </style>
    <data>
      <shape type="text" topLeftX="80" topLeftY="72" width="760" height="100">
        <content textType="title">
          <p>2024 年第一季度报告</p>
        </content>
      </shape>
      <shape type="text" topLeftX="80" topLeftY="200" width="520" height="180">
        <content textType="body">
          <p>核心指标</p>
          <ul>
            <li><p>用户增长:+25%</p></li>
            <li><p>收入增长:+30%</p></li>
            <li><p>市场份额:15%</p></li>
          </ul>
        </content>
      </shape>
      <shape type="rect" topLeftX="660" topLeftY="180" width="180" height="140">
        <fill>
          <fillColor color="rgba(100, 149, 237, 0.25)"/>
        </fill>
        <border color="rgb(100, 149, 237)" width="2"/>
      </shape>
    </data>
    <note>
      <content textType="body">
        <p>讲到增长率时补充样本范围。</p>
      </content>
    </note>
  </slide>
</presentation>
```

## 最佳实践

1. 始终带上命名空间 `xmlns="https://www.larkoffice.com/sml/2.0"`
2. 用 `shape type="text"` + `content` 表达页面文本
3. 用 `topLeftX` / `topLeftY`、`startX` / `startY` 等 schema 中定义的属性名
4. 优先使用 `rgb` / `rgba` 颜色格式;渐变必须使用 `rgba()` 且带百分比停靠点
5. 特殊字符按 XML 规则转义
6. 标准 16:9 页面建议使用 `width="960"` 和 `height="540"`

## 详细参考

- [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml)
- [slides_chart_demo.xml](slides_chart_demo.xml)

## Schema 版本信息

- **版本**: 2.0.0
- **命名空间**: https://www.larkoffice.com/sml/2.0
- **发布日期**: 2025-11-03
scripts/iconpark_tool_test.py
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations

import json
import subprocess
import sys
import unittest
from pathlib import Path

import iconpark_tool


SCRIPT_PATH = Path(__file__).resolve().with_name("iconpark_tool.py")


class IconParkToolTest(unittest.TestCase):
    @classmethod
    def setUpClass(cls) -> None:
        cls.index_data = iconpark_tool.load_index()

    def test_search_icons_finds_growth_trend(self) -> None:
        results = iconpark_tool.search_icons(self.index_data, {"query": "增长趋势", "limit": 5})
        self.assertTrue(results)
        self.assertTrue(
            any(entry["iconType"] == "iconpark/Charts/positive-dynamics.svg" for entry in results)
        )

    def test_search_icons_supports_english_query(self) -> None:
        results = iconpark_tool.search_icons(self.index_data, {"query": "security protect", "limit": 3})
        self.assertTrue(results)
        self.assertEqual(results[0]["iconType"], "iconpark/Safe/protect.svg")

    def test_search_icons_supports_category_filter(self) -> None:
        results = iconpark_tool.search_icons(
            self.index_data,
            {"query": "data", "category": "Charts", "limit": 10},
        )
        self.assertTrue(results)
        self.assertTrue(all(entry["category"] == "Charts" for entry in results))

    def test_search_icons_does_not_expand_ai_inside_words(self) -> None:
        mail_results = iconpark_tool.search_icons(self.index_data, {"query": "mail", "limit": 5})
        self.assertEqual(mail_results[0]["iconType"], "iconpark/Office/envelope-one.svg")
        self.assertNotEqual(mail_results[0]["iconType"], "iconpark/Others/magic.svg")

        fail_results = iconpark_tool.search_icons(self.index_data, {"query": "fail", "limit": 5})
        self.assertNotEqual(fail_results[0]["iconType"], "iconpark/Others/magic.svg")

    def test_search_icons_supports_template_icon_queries(self) -> None:
        cases = [
            ("arrow", "iconpark/Arrows/arrow-right.svg"),
            ("right", "iconpark/Arrows/right.svg"),
            ("PPT", "iconpark/Music/ppt.svg"),
            ("table", "iconpark/Office/table.svg"),
            ("会议", "iconpark/Office/schedule.svg"),
            ("飞书", "iconpark/Brand/bydesign.svg"),
        ]
        for query, icon_type in cases:
            with self.subTest(query=query):
                results = iconpark_tool.search_icons(self.index_data, {"query": query, "limit": 10})
                self.assertTrue(
                    any(entry["iconType"] == icon_type for entry in results),
                    f"{icon_type} not found in {results}",
                )

    def test_search_icons_defaults_to_wider_candidate_set(self) -> None:
        results = iconpark_tool.search_icons(self.index_data, {"query": "data"})
        self.assertEqual(len(results), 8)

    def test_search_icons_boosts_common_slide_terms(self) -> None:
        results = iconpark_tool.search_icons(self.index_data, {"query": "会议", "limit": 3})
        self.assertTrue(
            any(entry["iconType"] == "iconpark/Office/schedule.svg" for entry in results),
            f"iconpark/Office/schedule.svg not found in {results}",
        )

    def test_search_icons_keeps_high_value_top_results(self) -> None:
        cases = [
            ("安全", "iconpark/Safe/protect.svg"),
            ("邮件", "iconpark/Office/mail-open.svg"),
            ("会议", "iconpark/Office/schedule.svg"),
            ("增长趋势", "iconpark/Charts/chart-line.svg"),
            ("飞书", "iconpark/Brand/bydesign.svg"),
        ]
        for query, icon_type in cases:
            with self.subTest(query=query):
                results = iconpark_tool.search_icons(self.index_data, {"query": query, "limit": 3})
                self.assertTrue(results)
                self.assertEqual(results[0]["iconType"], icon_type)

    def test_search_icons_requires_query(self) -> None:
        with self.assertRaises(iconpark_tool.IconParkToolError):
            iconpark_tool.search_icons(self.index_data, {"limit": 5})

    def test_search_icons_rejects_invalid_limit(self) -> None:
        with self.assertRaises(iconpark_tool.IconParkToolError):
            iconpark_tool.search_icons(self.index_data, {"query": "data", "limit": "abc"})

    def test_resolve_icon_accepts_name_and_icon_type(self) -> None:
        by_name = iconpark_tool.resolve_icon(self.index_data, "chart-line")
        by_type = iconpark_tool.resolve_icon(self.index_data, "iconpark/Charts/chart-line.svg")
        self.assertEqual(by_name["iconType"], "iconpark/Charts/chart-line.svg")
        self.assertEqual(by_name, by_type)

    def test_resolve_icon_accepts_template_icon_type(self) -> None:
        result = iconpark_tool.resolve_icon(self.index_data, "iconpark/Arrows/arrow-right.svg")
        self.assertEqual(result["iconType"], "iconpark/Arrows/arrow-right.svg")

    def test_resolve_icon_rejects_unknown_name(self) -> None:
        with self.assertRaises(iconpark_tool.IconParkToolError):
            iconpark_tool.resolve_icon(self.index_data, "not-a-real-icon")

    def test_list_categories_counts_index(self) -> None:
        categories = iconpark_tool.list_categories(self.index_data)
        self.assertTrue(any(entry["category"] == "Charts" and entry["count"] > 0 for entry in categories))


class IconParkToolCLITest(unittest.TestCase):
    def run_tool(self, *args: str) -> subprocess.CompletedProcess[str]:
        return subprocess.run(
            [sys.executable, str(SCRIPT_PATH), *args],
            capture_output=True,
            check=False,
            text=True,
        )

    def test_cli_search_writes_json_to_stdout(self) -> None:
        result = self.run_tool("search", "--query", "增长趋势", "--limit", "5")
        self.assertEqual(result.returncode, 0, result.stderr)
        self.assertEqual(result.stderr, "")

        output = json.loads(result.stdout)
        self.assertTrue(output)
        self.assertTrue(
            any(entry["iconType"] == "iconpark/Charts/positive-dynamics.svg" for entry in output)
        )

    def test_cli_resolve_writes_json_to_stdout(self) -> None:
        result = self.run_tool("resolve", "--name", "chart-line")
        self.assertEqual(result.returncode, 0, result.stderr)
        self.assertEqual(result.stderr, "")

        output = json.loads(result.stdout)
        self.assertEqual(output["iconType"], "iconpark/Charts/chart-line.svg")

    def test_cli_list_categories_writes_json_to_stdout(self) -> None:
        result = self.run_tool("list-categories")
        self.assertEqual(result.returncode, 0, result.stderr)
        self.assertEqual(result.stderr, "")

        output = json.loads(result.stdout)
        self.assertTrue(any(entry["category"] == "Charts" and entry["count"] > 0 for entry in output))

    def test_cli_help_writes_usage_to_stderr(self) -> None:
        result = self.run_tool("--help")
        self.assertEqual(result.returncode, 0, result.stderr)
        self.assertEqual(result.stdout, "")
        self.assertIn("Usage:", result.stderr)
        self.assertIn("python3 iconpark_tool.py search", result.stderr)

    def test_cli_invalid_argument_writes_error_to_stderr(self) -> None:
        result = self.run_tool("search", "增长趋势")
        self.assertEqual(result.returncode, 1)
        self.assertEqual(result.stdout, "")
        self.assertIn("iconpark-tool error: unexpected argument: 增长趋势", result.stderr)

    def test_cli_unknown_command_writes_usage_and_error_to_stderr(self) -> None:
        result = self.run_tool("unknown")
        self.assertEqual(result.returncode, 1)
        self.assertEqual(result.stdout, "")
        self.assertIn("Usage:", result.stderr)
        self.assertIn("iconpark-tool error: unknown command: unknown", result.stderr)


if __name__ == "__main__":
    unittest.main()
scripts/iconpark_tool.py
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT

from __future__ import annotations

import json
import re
import sys
from pathlib import Path
from typing import Any


SKILL_ROOT = Path(__file__).resolve().parent.parent
REFERENCES_DIR = SKILL_ROOT / "references"
DEFAULT_INDEX_PATH = REFERENCES_DIR / "xml" / "iconpark-index.json"
DEFAULT_LIMIT = 8
CURATED_ICON_BOOSTS = {
    "设置": {"iconpark/Base/setting.svg"},
    "配置": {"iconpark/Base/setting.svg", "iconpark/Base/config.svg"},
    "目标": {"iconpark/Base/aiming.svg", "iconpark/Sports/target-one.svg"},
    "增长": {"iconpark/Charts/positive-dynamics.svg"},
    "趋势": {"iconpark/Charts/chart-line.svg", "iconpark/Charts/positive-dynamics.svg"},
    "占比": {"iconpark/Charts/chart-proportion.svg"},
    "数据": {"iconpark/Charts/data-screen.svg"},
    "看板": {"iconpark/Charts/data-screen.svg"},
    "成功": {"iconpark/Character/check-one.svg"},
    "完成": {"iconpark/Character/check-one.svg"},
    "失败": {"iconpark/Character/close-one.svg"},
    "风险": {"iconpark/Character/close-one.svg"},
    "团队": {"iconpark/Peoples/peoples.svg"},
    "用户": {"iconpark/Peoples/peoples.svg", "iconpark/Peoples/user.svg"},
    "安全": {"iconpark/Safe/protect.svg"},
    "防护": {"iconpark/Safe/protect.svg"},
    "全球": {"iconpark/Travel/world.svg"},
    "市场": {"iconpark/Travel/world.svg"},
    "邮件": {"iconpark/Office/envelope-one.svg"},
    "联系": {"iconpark/Office/envelope-one.svg"},
    "会议": {"iconpark/Office/schedule.svg"},
    "日程": {"iconpark/Office/schedule.svg"},
    "飞书": {"iconpark/Brand/bydesign.svg"},
}
CURATED_BOOST_SCORE = 40


class IconParkToolError(Exception):
    pass


def fail(message: str) -> None:
    raise IconParkToolError(message)


def normalize_whitespace(value: str) -> str:
    return re.sub(r"\s+", " ", value).strip()


def normalize_token(value: str) -> str:
    return normalize_whitespace(value.lower().replace("_", "-"))


def append_unique(target: list[str], token: str) -> None:
    normalized = normalize_token(token)
    if normalized and normalized not in target:
        target.append(normalized)


def tokenize_query(value: str) -> list[str]:
    normalized = normalize_token(value)
    if not normalized:
        return []

    tokens: list[str] = []
    for item in re.split(r"[\s,/|,。;;::()()【】\[\]《》<>]+", normalized):
        append_unique(tokens, item)

    for phrase in re.findall(r"[\u3400-\u9fff]+", normalized):
        if len(phrase) < 2:
            continue
        max_size = min(6, len(phrase))
        for size in range(max_size, 1, -1):
            for start in range(0, len(phrase) - size + 1):
                append_unique(tokens, phrase[start : start + size])

    synonym_tokens = {
        "目标": ["aim", "target", "goal"],
        "聚焦": ["focus", "target"],
        "增长": ["growth", "trend", "positive"],
        "趋势": ["trend", "chart", "line"],
        "数据": ["data", "analytics", "chart"],
        "指标": ["metric", "data"],
        "看板": ["dashboard", "screen", "data"],
        "成功": ["success", "check", "done"],
        "完成": ["done", "success", "check"],
        "失败": ["fail", "close", "risk"],
        "风险": ["risk", "fail", "protect"],
        "安全": ["safe", "security", "protect"],
        "配置": ["config", "setting", "system"],
        "设置": ["setting", "config"],
        "团队": ["team", "people", "users"],
        "用户": ["user", "people"],
        "全球": ["global", "world", "earth"],
        "市场": ["market", "world", "business"],
        "邮件": ["mail", "message"],
        "mail": ["message", "envelope", "envelope-one"],
        "计划": ["plan", "schedule"],
        "时间": ["time", "schedule"],
        "学习": ["learning", "education", "book"],
        "培训": ["training", "education"],
        "自动化": ["automation", "ai"],
        "ai": ["ai", "automation", "magic"],
    }
    for token in list(tokens):
        for keyword, aliases in synonym_tokens.items():
            if is_ascii_token(keyword):
                matches = token == keyword
            else:
                matches = keyword in token
            if matches:
                for alias in aliases:
                    append_unique(tokens, alias)

    return tokens


def is_ascii_token(value: str) -> bool:
    return bool(re.fullmatch(r"[a-z0-9-]+", value))


def allows_substring_match(value: str) -> bool:
    return not is_ascii_token(value) or len(value) >= 3


def field_tokens(*values: str) -> set[str]:
    tokens: set[str] = set()
    for value in values:
        normalized = normalize_token(value)
        if not normalized:
            continue
        tokens.add(normalized)
        for part in re.split(r"[-\s]+", normalized):
            if part:
                tokens.add(part)
    return tokens


def load_index(path: str | Path = DEFAULT_INDEX_PATH) -> dict[str, Any]:
    index_path = Path(path)
    if not index_path.exists():
        fail(f"iconpark index not found: {index_path}")
    try:
        index_data = json.loads(index_path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as error:
        fail(f"invalid iconpark index JSON: {error}")
    if not isinstance(index_data.get("icons"), list):
        fail("iconpark index must contain an icons array")
    return index_data


def icon_search_text(entry: dict[str, Any]) -> str:
    parts = [
        entry.get("iconType", ""),
        entry.get("category", ""),
        entry.get("name", ""),
        " ".join(entry.get("tags") or []),
    ]
    return normalize_token(" ".join(parts))


def score_icon(entry: dict[str, Any], query: str, tokens: list[str]) -> int:
    raw_icon_type = entry.get("iconType", "")
    icon_type = normalize_token(raw_icon_type)
    category = normalize_token(entry.get("category", ""))
    name = normalize_token(entry.get("name", ""))
    tags = [normalize_token(tag) for tag in entry.get("tags") or []]
    name_tokens = field_tokens(name)
    category_tokens = field_tokens(category)
    tag_tokens = field_tokens(*tags)
    icon_type_tokens = field_tokens(icon_type)
    search_text = icon_search_text(entry)
    normalized_query = normalize_token(query)

    score = 0
    boosted_keywords: set[str] = set()
    if normalized_query:
        if normalized_query == icon_type or normalized_query == name:
            score += 200
        elif normalized_query in tag_tokens:
            score += 120
        elif normalized_query in icon_type_tokens:
            score += 60
        elif allows_substring_match(normalized_query) and normalized_query in search_text:
            score += 30

    for token in tokens:
        for keyword, boosted_icon_types in CURATED_ICON_BOOSTS.items():
            if keyword in boosted_keywords:
                continue
            if keyword in token and raw_icon_type in boosted_icon_types:
                score += CURATED_BOOST_SCORE
                boosted_keywords.add(keyword)
        if token == name:
            score += 80
        elif token in name_tokens:
            score += 55
        elif allows_substring_match(token) and token in name:
            score += 45
        if token == category:
            score += 35
        elif token in category_tokens:
            score += 25
        elif allows_substring_match(token) and token in category:
            score += 15
        for tag in tags:
            if token == tag:
                score += 60
            elif token in field_tokens(tag):
                score += 45
            elif allows_substring_match(token) and token in tag:
                score += 20
        if token in icon_type_tokens:
            score += 20
        elif allows_substring_match(token) and token in icon_type:
            score += 15

    return score


def parse_limit(value: Any) -> int:
    if value is None or value is False:
        return DEFAULT_LIMIT
    if value is True:
        fail("limit requires an integer value")
    try:
        return int(value)
    except (TypeError, ValueError):
        fail(f"limit must be an integer: {value}")


def public_icon(entry: dict[str, Any], score: int | None = None) -> dict[str, Any]:
    result = {
        "iconType": entry["iconType"],
        "category": entry["category"],
        "name": entry["name"],
        "tags": entry.get("tags") or [],
    }
    if score is not None:
        result["score"] = score
    return result


def search_icons(index_data: dict[str, Any], options: dict[str, Any]) -> list[dict[str, Any]]:
    query = str(options.get("query") or "")
    if not normalize_whitespace(query):
        fail("query is required")
    limit = parse_limit(options.get("limit"))
    category_filter = normalize_token(str(options.get("category") or ""))
    tokens = tokenize_query(query)

    ranked: list[dict[str, Any]] = []
    for entry in index_data["icons"]:
        if category_filter and normalize_token(entry.get("category", "")) != category_filter:
            continue
        score = score_icon(entry, query, tokens)
        if query and score == 0:
            continue
        ranked.append(public_icon(entry, score))

    ranked.sort(key=lambda item: (-int(item["score"]), item["category"], item["name"]))
    return ranked[: max(limit, 0)]


def resolve_icon(index_data: dict[str, Any], name_or_type: str | None) -> dict[str, Any]:
    if not name_or_type:
        fail("name is required")
    target = normalize_token(name_or_type)
    matches = []
    for entry in index_data["icons"]:
        candidates = {
            normalize_token(entry["iconType"]),
            normalize_token(entry["name"]),
            normalize_token(f'{entry["category"]}/{entry["name"]}.svg'),
        }
        if target in candidates:
            matches.append(entry)
    if not matches:
        fail(f"icon not found: {name_or_type}")
    if len(matches) > 1:
        names = ", ".join(entry["iconType"] for entry in matches)
        fail(f"ambiguous icon name: {name_or_type}; matches: {names}")
    return public_icon(matches[0])


def list_categories(index_data: dict[str, Any]) -> list[dict[str, Any]]:
    counts: dict[str, int] = {}
    for entry in index_data["icons"]:
        counts[entry["category"]] = counts.get(entry["category"], 0) + 1
    return [{"category": category, "count": counts[category]} for category in sorted(counts)]


def parse_cli_args(argv: list[str]) -> tuple[str | None, dict[str, Any]]:
    if not argv:
        return None, {}
    command, *rest = argv
    options: dict[str, Any] = {}
    index = 0
    while index < len(rest):
        token = rest[index]
        if not token.startswith("--"):
            fail(f"unexpected argument: {token}")
        key = token[2:]
        next_token = rest[index + 1] if index + 1 < len(rest) else None
        if next_token is None or next_token.startswith("--"):
            options[key] = True
            index += 1
            continue
        options[key] = next_token
        index += 2
    return command, options


def print_usage() -> None:
    usage = [
        "Usage:",
        "  python3 iconpark_tool.py search --query <text> [--category <Category>] [--limit 8]",
        "  python3 iconpark_tool.py resolve --name <name|iconType>",
        "  python3 iconpark_tool.py list-categories",
    ]
    print("\n".join(usage), file=sys.stderr)


def write_json(value: Any) -> None:
    print(json.dumps(value, ensure_ascii=False, indent=2))


def run_cli(argv: list[str] | None = None) -> None:
    command, options = parse_cli_args(argv or sys.argv[1:])
    if not command or command in {"--help", "help"}:
        print_usage()
        raise SystemExit(0)

    index_data = load_index()
    if command == "search":
        write_json(search_icons(index_data, options))
        return
    if command == "resolve":
        write_json(resolve_icon(index_data, options.get("name")))
        return
    if command == "list-categories":
        write_json(list_categories(index_data))
        return

    print_usage()
    fail(f"unknown command: {command}")


if __name__ == "__main__":
    try:
        run_cli()
    except IconParkToolError as error:
        print(f"iconpark-tool error: {error}", file=sys.stderr)
        raise SystemExit(1) from error
scripts/sxsd_validator.py
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
"""Internal XSD model and constraint validation for the Slides lint entrypoint."""

from __future__ import annotations

import math
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from functools import lru_cache
from pathlib import Path
from typing import Any


XS_NS = "{http://www.w3.org/2001/XMLSchema}"
SML_NAMESPACE = "https://www.larkoffice.com/sml/2.0"
SML_LEGACY_HTTP_NAMESPACE = "http://www.larkoffice.com/sml/2.0"
SML_READBACK_NAMESPACE = "/sml/2.0"
ACCEPTED_SML_NAMESPACES = frozenset(
    (SML_NAMESPACE, SML_LEGACY_HTTP_NAMESPACE, SML_READBACK_NAMESPACE)
)


def local_name(value: str) -> str:
    if value.startswith("{"):
        return value.rsplit("}", 1)[-1]
    return value.rsplit(":", 1)[-1]


def direct_children(element: ET.Element, name: str) -> list[ET.Element]:
    return [child for child in element if child.tag == f"{XS_NS}{name}"]


def first_direct_child(element: ET.Element, *names: str) -> ET.Element | None:
    wanted = {f"{XS_NS}{name}" for name in names}
    return next((child for child in element if child.tag in wanted), None)


def occurs_value(raw: str | None, default: int) -> int | None:
    if raw == "unbounded":
        return None
    return int(raw) if raw is not None else default


@dataclass(frozen=True)
class SimpleTypeRule:
    name: str
    base: str | None = None
    enums: tuple[str, ...] = ()
    patterns: tuple[str, ...] = ()
    bounds: tuple[tuple[str, Decimal], ...] = ()
    length_bounds: tuple[tuple[str, int], ...] = ()
    union_members: tuple[str, ...] = ()


@dataclass(frozen=True)
class AttributeRule:
    name: str
    type_name: str
    required: bool


@dataclass(frozen=True)
class ElementRule:
    name: str
    type_name: str | None
    inline_complex_type: ET.Element | None
    ref_name: str | None


@dataclass(frozen=True)
class ChildRule:
    element: ElementRule
    min_occurs: int
    max_occurs: int | None
    order: int | None


@dataclass(frozen=True)
class WildcardRule:
    namespace: str
    process_contents: str
    min_occurs: int
    max_occurs: int | None
    order: int | None


@dataclass(frozen=True)
class ChoiceRequirement:
    names: tuple[str, ...]
    min_occurs: int
    max_occurs: int | None


@dataclass(frozen=True)
class SchemaModel:
    simple_types: dict[str, SimpleTypeRule]
    complex_types: dict[str, ET.Element]
    element_candidates: dict[str, tuple[ElementRule, ...]]
    global_elements: dict[str, ElementRule]


def parse_simple_type(
    element: ET.Element,
    fallback_name: str,
    simple_types: dict[str, SimpleTypeRule] | None = None,
) -> SimpleTypeRule:
    name = element.attrib.get("name", fallback_name)
    restriction = first_direct_child(element, "restriction")
    union = first_direct_child(element, "union")
    if union is not None:
        union_members = [
            local_name(member) for member in union.attrib.get("memberTypes", "").split()
        ]
        for index, inline_simple in enumerate(direct_children(union, "simpleType"), start=1):
            inline_name = f"__inline_union_member_{name}_{index}"
            union_members.append(inline_name)
            if simple_types is not None:
                simple_types[inline_name] = parse_simple_type(
                    inline_simple,
                    inline_name,
                    simple_types,
                )
        return SimpleTypeRule(
            name=name,
            union_members=tuple(union_members),
        )
    if restriction is None:
        return SimpleTypeRule(name=name)

    facet_names = {
        "minInclusive",
        "minExclusive",
        "maxInclusive",
        "maxExclusive",
    }
    bounds: list[tuple[str, Decimal]] = []
    length_bounds: list[tuple[str, int]] = []
    for child in restriction:
        facet = local_name(child.tag)
        if "value" not in child.attrib:
            continue
        if facet in facet_names:
            bounds.append((facet, Decimal(child.attrib["value"])))
        elif facet in {"minLength", "maxLength"}:
            length_bounds.append((facet, int(child.attrib["value"])))
    return SimpleTypeRule(
        name=name,
        base=local_name(restriction.attrib.get("base", "string")),
        enums=tuple(child.attrib["value"] for child in direct_children(restriction, "enumeration")),
        patterns=tuple(child.attrib["value"] for child in direct_children(restriction, "pattern")),
        bounds=tuple(bounds),
        length_bounds=tuple(length_bounds),
    )


def parse_element_rule(element: ET.Element) -> ElementRule | None:
    raw_ref = element.attrib.get("ref")
    name = element.attrib.get("name")
    if name is None and raw_ref:
        name = local_name(raw_ref)
    if not name:
        return None
    return ElementRule(
        name=name,
        type_name=local_name(element.attrib["type"]) if element.attrib.get("type") else None,
        inline_complex_type=first_direct_child(element, "complexType"),
        ref_name=local_name(raw_ref) if raw_ref else None,
    )


@lru_cache(maxsize=4)
def load_schema_model(schema_path: str) -> SchemaModel:
    root = ET.parse(schema_path).getroot()
    simple_types: dict[str, SimpleTypeRule] = {}
    for element in direct_children(root, "simpleType"):
        name = element.attrib.get("name")
        if not name:
            continue
        simple_types[name] = parse_simple_type(element, name, simple_types)
    for attribute in root.iter(f"{XS_NS}attribute"):
        inline_simple = first_direct_child(attribute, "simpleType")
        if inline_simple is None:
            continue
        inline_name = f"__inline_attribute_{attribute.attrib.get('name', 'anonymous')}_{id(attribute)}"
        simple_types[inline_name] = parse_simple_type(inline_simple, inline_name, simple_types)
    complex_types = {
        element.attrib["name"]: element
        for element in direct_children(root, "complexType")
        if element.attrib.get("name")
    }
    candidates: dict[str, list[ElementRule]] = {}
    for element in root.iter(f"{XS_NS}element"):
        rule = parse_element_rule(element)
        if rule is not None:
            candidates.setdefault(rule.name, []).append(rule)
    global_elements = {
        rule.name: rule
        for element in direct_children(root, "element")
        if (rule := parse_element_rule(element)) is not None
    }
    return SchemaModel(
        simple_types=simple_types,
        complex_types=complex_types,
        element_candidates={name: tuple(rules) for name, rules in candidates.items()},
        global_elements=global_elements,
    )


def attributes_for_complex_type(
    complex_type: ET.Element,
    model: SchemaModel,
    resolving: set[str] | None = None,
) -> dict[str, AttributeRule]:
    resolving = resolving or set()
    attributes: dict[str, AttributeRule] = {}

    for content_name in ("simpleContent", "complexContent"):
        content = first_direct_child(complex_type, content_name)
        if content is None:
            continue
        extension = first_direct_child(content, "extension")
        if extension is None:
            continue
        base_name = local_name(extension.attrib.get("base", ""))
        if base_name in model.complex_types and base_name not in resolving:
            resolving.add(base_name)
            attributes.update(attributes_for_complex_type(model.complex_types[base_name], model, resolving))
            resolving.remove(base_name)
        attributes.update(direct_attribute_rules(extension))

    attributes.update(direct_attribute_rules(complex_type))
    return attributes


def direct_attribute_rules(element: ET.Element) -> dict[str, AttributeRule]:
    rules: dict[str, AttributeRule] = {}
    for attribute in direct_children(element, "attribute"):
        name = attribute.attrib.get("name")
        if not name:
            continue
        type_name = local_name(attribute.attrib.get("type", "string"))
        inline_simple = first_direct_child(attribute, "simpleType")
        if inline_simple is not None:
            type_name = f"__inline_attribute_{name}_{id(attribute)}"
        rules[name] = AttributeRule(
            name=name,
            type_name=type_name,
            required=attribute.attrib.get("use") == "required",
        )
    return rules


def attributes_for_element(rule: ElementRule, model: SchemaModel) -> dict[str, AttributeRule]:
    complex_type = rule.inline_complex_type
    if complex_type is None and rule.type_name in model.complex_types:
        complex_type = model.complex_types[rule.type_name]
    if complex_type is None:
        return {}
    return attributes_for_complex_type(complex_type, model)


def best_element_rule(element_name: str, model: SchemaModel) -> ElementRule | None:
    candidates = model.element_candidates.get(element_name, ())
    if not candidates:
        return None
    return max(
        candidates,
        key=lambda candidate: (
            candidate.type_name is not None or candidate.inline_complex_type is not None,
            len(attributes_for_element(candidate, model)),
        ),
    )


def concrete_element_rule(rule: ElementRule, model: SchemaModel) -> ElementRule:
    if rule.ref_name is not None:
        return model.global_elements.get(rule.ref_name, rule)
    if rule.type_name is not None or rule.inline_complex_type is not None:
        return rule
    candidate = best_element_rule(rule.name, model)
    return candidate or rule


def complex_type_for_element(rule: ElementRule, model: SchemaModel) -> ET.Element | None:
    rule = concrete_element_rule(rule, model)
    if rule.inline_complex_type is not None:
        return rule.inline_complex_type
    if rule.type_name is not None:
        return model.complex_types.get(rule.type_name)
    return None


def particle_for_complex_type(complex_type: ET.Element) -> ET.Element | None:
    particle = first_direct_child(complex_type, "sequence", "all", "choice")
    if particle is not None:
        return particle
    for content_name in ("simpleContent", "complexContent"):
        content = first_direct_child(complex_type, content_name)
        if content is None:
            continue
        extension = first_direct_child(content, "extension")
        if extension is not None:
            return first_direct_child(extension, "sequence", "all", "choice")
    return None


def multiplied_max(left: int | None, right: int | None) -> int | None:
    if left is None or right is None:
        return None
    return left * right


def child_rules_for_complex_type(
    complex_type: ET.Element,
) -> tuple[list[ChildRule], list[WildcardRule], list[ChoiceRequirement]]:
    particle = particle_for_complex_type(complex_type)
    if particle is None:
        return [], [], []

    rules: list[ChildRule] = []
    wildcard_rules: list[WildcardRule] = []
    requirements: list[ChoiceRequirement] = []
    next_order = 0

    def add_element(
        element: ET.Element,
        *,
        order: int | None,
        optional_by_choice: bool,
        max_multiplier: int | None,
    ) -> None:
        element_rule = parse_element_rule(element)
        if element_rule is None:
            return
        minimum = occurs_value(element.attrib.get("minOccurs"), 1) or 0
        maximum = occurs_value(element.attrib.get("maxOccurs"), 1)
        rules.append(
            ChildRule(
                element=element_rule,
                min_occurs=0 if optional_by_choice else minimum,
                max_occurs=multiplied_max(maximum, max_multiplier),
                order=order,
            )
        )

    def add_wildcard(
        wildcard: ET.Element,
        *,
        order: int | None,
        optional_by_choice: bool,
        max_multiplier: int | None,
    ) -> None:
        minimum = occurs_value(wildcard.attrib.get("minOccurs"), 1) or 0
        maximum = occurs_value(wildcard.attrib.get("maxOccurs"), 1)
        wildcard_rules.append(
            WildcardRule(
                namespace=wildcard.attrib.get("namespace", "##any"),
                process_contents=wildcard.attrib.get("processContents", "strict"),
                min_occurs=0 if optional_by_choice else minimum,
                max_occurs=multiplied_max(maximum, max_multiplier),
                order=order,
            )
        )

    def walk_group(
        group: ET.Element,
        *,
        ordered: bool,
        fixed_order: int | None = None,
        optional_by_choice: bool = False,
        max_multiplier: int | None = 1,
    ) -> None:
        nonlocal next_order
        kind = local_name(group.tag)
        group_min = occurs_value(group.attrib.get("minOccurs"), 1) or 0
        group_max = occurs_value(group.attrib.get("maxOccurs"), 1)
        effective_max = multiplied_max(max_multiplier, group_max)

        if kind == "choice":
            choice_order = fixed_order
            if choice_order is None and ordered:
                choice_order = next_order
                next_order += 1
            names: list[str] = []
            for child in group:
                child_kind = local_name(child.tag)
                if child_kind == "element":
                    parsed = parse_element_rule(child)
                    if parsed is not None:
                        names.append(parsed.name)
                    add_element(
                        child,
                        order=choice_order,
                        optional_by_choice=True,
                        max_multiplier=effective_max,
                    )
                elif child_kind == "any":
                    add_wildcard(
                        child,
                        order=choice_order,
                        optional_by_choice=True,
                        max_multiplier=effective_max,
                    )
                elif child_kind in {"sequence", "all", "choice"}:
                    walk_group(
                        child,
                        ordered=ordered,
                        fixed_order=choice_order,
                        optional_by_choice=True,
                        max_multiplier=effective_max,
                    )
            if names and (group_min > 0 or effective_max is not None):
                requirements.append(ChoiceRequirement(tuple(names), group_min, effective_max))
            return

        group_ordered = kind == "sequence"
        for child in group:
            child_kind = local_name(child.tag)
            if child_kind == "element":
                child_order = fixed_order
                if child_order is None and ordered and group_ordered:
                    child_order = next_order
                    next_order += 1
                add_element(
                    child,
                    order=child_order,
                    optional_by_choice=optional_by_choice or group_min == 0,
                    max_multiplier=effective_max,
                )
            elif child_kind == "any":
                child_order = fixed_order
                if child_order is None and ordered and group_ordered:
                    child_order = next_order
                    next_order += 1
                add_wildcard(
                    child,
                    order=child_order,
                    optional_by_choice=optional_by_choice or group_min == 0,
                    max_multiplier=effective_max,
                )
            elif child_kind in {"sequence", "all", "choice"}:
                walk_group(
                    child,
                    ordered=ordered and group_ordered,
                    fixed_order=fixed_order,
                    optional_by_choice=optional_by_choice or group_min == 0,
                    max_multiplier=effective_max,
                )

    walk_group(particle, ordered=local_name(particle.tag) == "sequence")
    return rules, wildcard_rules, requirements


def issue(
    code: str,
    path: str,
    tag: str,
    *,
    attr: str | None,
    expected: str,
    actual: Any,
    message: str,
    hint: str,
) -> dict[str, Any]:
    result: dict[str, Any] = {
        "level": "error",
        "code": code,
        "path": path,
        "tag": tag,
        "expected": expected,
        "actual": actual,
        "message": message,
        "hint": hint,
    }
    if attr is not None:
        result["attr"] = attr
    return result


def builtin_scalar_value(type_name: str, value: str) -> Decimal | str | bool:
    if type_name in {"string", "anyURI"}:
        return value
    if type_name == "boolean":
        if value not in {"true", "false", "1", "0"}:
            raise ValueError("expected boolean")
        return value in {"true", "1"}
    if type_name in {"integer", "positiveInteger", "nonNegativeInteger"}:
        if re.fullmatch(r"[+-]?\d+", value) is None:
            raise ValueError("expected integer")
        number = Decimal(value)
        if type_name == "positiveInteger" and number <= 0:
            raise ArithmeticError("expected positive integer")
        if type_name == "nonNegativeInteger" and number < 0:
            raise ArithmeticError("expected non-negative integer")
        return number
    if type_name in {"double", "decimal"}:
        lexical_value = value.strip(" \t\n\r")
        decimal_pattern = r"[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)"
        double_pattern = decimal_pattern + r"(?:[eE][+-]?[0-9]+)?"
        expected_pattern = double_pattern if type_name == "double" else decimal_pattern
        if re.fullmatch(expected_pattern, lexical_value) is None:
            raise ValueError(f"expected {type_name}")
        try:
            number = Decimal(lexical_value)
        except InvalidOperation as error:
            raise ValueError(f"expected {type_name}") from error
        if not math.isfinite(float(number)):
            raise ValueError(f"expected finite {type_name}")
        return number
    return value


def scalar_value_for_type(
    type_name: str,
    value: str,
    model: SchemaModel,
    resolving: set[str] | None = None,
) -> Decimal | str | bool:
    resolving = resolving or set()
    if type_name in resolving:
        return value
    rule = model.simple_types.get(type_name)
    if rule is None or rule.base is None:
        return builtin_scalar_value(type_name, value)
    resolving.add(type_name)
    try:
        return scalar_value_for_type(rule.base, value, model, resolving)
    finally:
        resolving.remove(type_name)


@lru_cache(maxsize=32)
def python_pattern_for_xsd(pattern: str) -> str:
    translated: list[str] = []
    in_character_class = False
    index = 0
    while index < len(pattern):
        char = pattern[index]
        if char == "\\" and index + 1 < len(pattern):
            escaped = pattern[index + 1]
            if escaped in {"s", "S"}:
                body = r" \t\n\r"
                if in_character_class:
                    if escaped == "S":
                        raise ValueError(
                            f"unsupported complemented XSD character class \\{escaped} inside []"
                        )
                    translated.append(body)
                else:
                    prefix = "^" if escaped == "S" else ""
                    translated.append(f"[{prefix}{body}]")
                index += 2
                continue
            translated.extend((char, escaped))
            index += 2
            continue
        if char == "[":
            in_character_class = True
        elif char == "]":
            in_character_class = False
        elif char == "." and not in_character_class:
            translated.append(r"[^\n\r]")
            index += 1
            continue
        elif char in "^$" and not in_character_class:
            translated.append(f"\\{char}")
            index += 1
            continue
        translated.append(char)
        index += 1
    return "".join(translated)


def xsd_pattern_matches(pattern: str, value: str) -> bool:
    if pattern == r"[\w.-]+[.:]\S*":
        if any(character in " \t\n\r" for character in value):
            return False
        for index, character in enumerate(value):
            if index > 0 and character in ".:":
                return True
            if not (character == "_" or character.isalnum() or character in ".-"):
                return False
        return False
    return re.fullmatch(python_pattern_for_xsd(pattern), value) is not None


def value_error_for_type(
    type_name: str,
    value: str,
    model: SchemaModel,
    resolving: set[str] | None = None,
) -> tuple[str, str] | None:
    resolving = resolving or set()
    if type_name in resolving:
        return None
    rule = model.simple_types.get(type_name)
    if rule is None:
        try:
            builtin_scalar_value(type_name, value)
        except ValueError:
            return "sxsd_invalid_scalar", f"value valid for {type_name}"
        except ArithmeticError:
            return "sxsd_value_out_of_range", f"value in the range allowed by {type_name}"
        return None

    resolving.add(type_name)
    try:
        if rule.union_members:
            member_errors = [value_error_for_type(member, value, model, resolving) for member in rule.union_members]
            if any(error is None for error in member_errors):
                return None
            unsupported_error = next(
                (error for error in member_errors if error and error[0] == "sxsd_unsupported_pattern"),
                None,
            )
            if unsupported_error is not None:
                return unsupported_error
            if any(error and error[0] == "sxsd_pattern_mismatch" for error in member_errors):
                return "sxsd_pattern_mismatch", f"value matching one member of {type_name}"
            return member_errors[0]

        if rule.enums and value not in rule.enums:
            return "sxsd_invalid_enum", "one of: " + ", ".join(rule.enums)

        if rule.patterns:
            unsupported_patterns: list[str] = []
            for pattern in rule.patterns:
                try:
                    if xsd_pattern_matches(pattern, value):
                        break
                except (ValueError, re.error) as error:
                    unsupported_patterns.append(f"{pattern!r}: {error}")
            else:
                if unsupported_patterns:
                    return (
                        "sxsd_unsupported_pattern",
                        "lint support for XSD pattern " + "; ".join(unsupported_patterns),
                    )
                return "sxsd_pattern_mismatch", "value matching pattern " + " or ".join(rule.patterns)

        base_name = rule.base or "string"
        base_error = value_error_for_type(base_name, value, model, resolving)
        if base_error is not None:
            return base_error
        for facet, bound in rule.length_bounds:
            allowed = len(value) >= bound if facet == "minLength" else len(value) <= bound
            if not allowed:
                return "sxsd_value_out_of_range", f"{facet} {bound}"
        scalar = scalar_value_for_type(base_name, value, model)
        if isinstance(scalar, Decimal):
            for facet, bound in rule.bounds:
                allowed = {
                    "minInclusive": scalar >= bound,
                    "minExclusive": scalar > bound,
                    "maxInclusive": scalar <= bound,
                    "maxExclusive": scalar < bound,
                }[facet]
                if not allowed:
                    return "sxsd_value_out_of_range", f"{facet} {bound}"
        return None
    finally:
        resolving.remove(type_name)


def validate_element_attributes(
    element: ET.Element,
    path: str,
    model: SchemaModel,
    element_rule: ElementRule | None = None,
) -> list[dict[str, Any]]:
    tag = local_name(element.tag)
    element_rule = element_rule or best_element_rule(tag, model)
    if element_rule is None:
        return []
    attribute_rules = attributes_for_element(element_rule, model)
    issues: list[dict[str, Any]] = []
    for attr_rule in attribute_rules.values():
        if attr_rule.required and attr_rule.name not in element.attrib:
            issues.append(
                issue(
                    "sxsd_missing_required_attr",
                    path,
                    tag,
                    attr=attr_rule.name,
                    expected=f"required attribute of type {attr_rule.type_name}",
                    actual=None,
                    message=f'missing required SXSD attribute "{attr_rule.name}" on <{tag}> at {path}',
                    hint=f'Add attribute "{attr_rule.name}" with a value valid for {attr_rule.type_name}.',
                )
            )
    for raw_name, value in element.attrib.items():
        attr_name = local_name(raw_name)
        attr_rule = attribute_rules.get(attr_name)
        if attr_rule is None:
            continue
        validation_error = value_error_for_type(attr_rule.type_name, value, model)
        if validation_error is None:
            continue
        code, expected = validation_error
        if code == "sxsd_unsupported_pattern":
            message = (
                f'unsupported SXSD pattern for attribute "{attr_name}" on <{tag}> at {path}'
            )
            hint = (
                f"Extend the SXSD pattern interpreter for {attr_rule.type_name}; "
                "do not treat this attribute value as validated."
            )
        else:
            message = (
                f'invalid SXSD value {value!r} for attribute "{attr_name}" on <{tag}> at {path}'
            )
            hint = f'Set attribute "{attr_name}" to a value valid for {attr_rule.type_name}.'
        issues.append(
            issue(
                code,
                path,
                tag,
                attr=attr_name,
                expected=expected,
                actual=value,
                message=message,
                hint=hint,
            )
        )
    return issues


def element_namespace(tag: str) -> str | None:
    if not tag.startswith("{"):
        return None
    return tag[1:].split("}", 1)[0]


def wildcard_matches_namespace(namespace_rule: str, namespace: str | None) -> bool:
    tokens = namespace_rule.split()
    if "##any" in tokens:
        return True
    if "##local" in tokens and namespace is None:
        return True
    if "##targetNamespace" in tokens and namespace == SML_NAMESPACE:
        return True
    if "##other" in tokens and namespace not in {None, SML_NAMESPACE}:
        return True
    return namespace in tokens


def wildcard_namespace_description(namespace_rule: str) -> str:
    if namespace_rule == "##any":
        return "any namespace"
    if namespace_rule == "##local":
        return "the local namespace"
    if namespace_rule == "##targetNamespace":
        return f"the target namespace {SML_NAMESPACE}"
    if namespace_rule == "##other":
        return "a namespace other than the SXSD target namespace"
    return f"namespace {namespace_rule}"


def validate_element_children(
    element: ET.Element,
    path: str,
    element_rule: ElementRule,
    model: SchemaModel,
) -> tuple[list[dict[str, Any]], dict[int, ElementRule]]:
    tag = local_name(element.tag)
    complex_type = complex_type_for_element(element_rule, model)
    child_rules, wildcard_rules, choice_requirements = (
        child_rules_for_complex_type(complex_type)
        if complex_type is not None
        else ([], [], [])
    )
    rules_by_name: dict[str, list[ChildRule]] = {}
    for child_rule in child_rules:
        rules_by_name.setdefault(child_rule.element.name, []).append(child_rule)

    issues: list[dict[str, Any]] = []
    matched: dict[int, ElementRule] = {}
    counts: dict[str, int] = {}
    wildcard_counts: dict[int, int] = {}
    latest_order = -1
    for child in element:
        child_name = local_name(child.tag)
        child_path = f"{path}/{child_name}"
        candidates = rules_by_name.get(child_name, [])
        wildcard_match = next(
            (
                (index, wildcard_rule)
                for index, wildcard_rule in enumerate(wildcard_rules)
                if wildcard_matches_namespace(
                    wildcard_rule.namespace,
                    element_namespace(child.tag),
                )
            ),
            None,
        )
        if not candidates and wildcard_match is None:
            expected_children = sorted(rules_by_name)
            expected_children.extend(
                wildcard_namespace_description(rule.namespace) for rule in wildcard_rules
            )
            issues.append(
                issue(
                    "sxsd_unexpected_child",
                    child_path,
                    child_name,
                    attr=None,
                    expected="one of: " + ", ".join(expected_children)
                    if expected_children
                    else "no child elements",
                    actual=child_name,
                    message=f"unexpected SXSD child <{child_name}> under <{tag}> at {child_path}",
                    hint=f"Move or remove <{child_name}> so <{tag}> follows the SXSD child structure.",
                )
            )
            continue

        if not candidates and wildcard_match is not None:
            wildcard_index, wildcard_rule = wildcard_match
            if wildcard_rule.order is not None:
                if wildcard_rule.order < latest_order:
                    issues.append(
                        issue(
                            "sxsd_invalid_child_order",
                            child_path,
                            child_name,
                            attr=None,
                            expected="children in xs:sequence order",
                            actual=child_name,
                            message=f"SXSD child <{child_name}> is out of order under <{tag}> at {child_path}",
                            hint=f"Reorder <{child_name}> according to the SXSD sequence for <{tag}>.",
                        )
                    )
                latest_order = max(latest_order, wildcard_rule.order)
            wildcard_counts[wildcard_index] = wildcard_counts.get(wildcard_index, 0) + 1
            actual_count = wildcard_counts[wildcard_index]
            if wildcard_rule.max_occurs is not None and actual_count > wildcard_rule.max_occurs:
                issues.append(
                    issue(
                        "sxsd_too_many_children",
                        child_path,
                        child_name,
                        attr=None,
                        expected=f"at most {wildcard_rule.max_occurs} child from {wildcard_namespace_description(wildcard_rule.namespace)}",
                        actual=actual_count,
                        message=f"too many wildcard SXSD children under <{tag}> at {path}",
                        hint=f"Keep at most {wildcard_rule.max_occurs} child from {wildcard_namespace_description(wildcard_rule.namespace)} under <{tag}>.",
                    )
                )
            continue

        child_rule = candidates[0]
        if child_rule.order is not None:
            if child_rule.order < latest_order:
                issues.append(
                    issue(
                        "sxsd_invalid_child_order",
                        child_path,
                        child_name,
                        attr=None,
                        expected="children in xs:sequence order",
                        actual=child_name,
                        message=f"SXSD child <{child_name}> is out of order under <{tag}> at {child_path}",
                        hint=f"Reorder <{child_name}> according to the SXSD sequence for <{tag}>.",
                    )
                )
            latest_order = max(latest_order, child_rule.order)

        counts[child_name] = counts.get(child_name, 0) + 1
        if child_rule.max_occurs is not None and counts[child_name] > child_rule.max_occurs:
            issues.append(
                issue(
                    "sxsd_too_many_children",
                    child_path,
                    child_name,
                    attr=None,
                    expected=f"at most {child_rule.max_occurs}",
                    actual=counts[child_name],
                    message=f"too many SXSD <{child_name}> children under <{tag}> at {path}",
                    hint=f"Keep at most {child_rule.max_occurs} <{child_name}> children under <{tag}>.",
                )
            )
        matched[id(child)] = concrete_element_rule(child_rule.element, model)

    for child_rule in child_rules:
        child_name = child_rule.element.name
        actual_count = counts.get(child_name, 0)
        if child_rule.min_occurs <= actual_count:
            continue
        issues.append(
            issue(
                "sxsd_missing_required_child",
                path,
                tag,
                attr=None,
                expected=f"{child_name} (at least {child_rule.min_occurs})",
                actual=actual_count,
                message=f"missing required SXSD child <{child_name}> under <{tag}> at {path}",
                hint=f"Add at least {child_rule.min_occurs} <{child_name}> child under <{tag}>.",
            )
        )

    for wildcard_index, wildcard_rule in enumerate(wildcard_rules):
        actual_count = wildcard_counts.get(wildcard_index, 0)
        if wildcard_rule.min_occurs <= actual_count:
            continue
        namespace_description = wildcard_namespace_description(wildcard_rule.namespace)
        issues.append(
            issue(
                "sxsd_missing_required_child",
                path,
                tag,
                attr=None,
                expected=f"at least {wildcard_rule.min_occurs} child from {namespace_description}",
                actual=actual_count,
                message=f"missing required wildcard SXSD child under <{tag}> at {path}",
                hint=f"Add at least {wildcard_rule.min_occurs} child from {namespace_description} under <{tag}>.",
            )
        )

    for requirement in choice_requirements:
        actual_count = sum(counts.get(name, 0) for name in requirement.names)
        expected_names = ", ".join(requirement.names)
        if actual_count < requirement.min_occurs:
            issues.append(
                issue(
                    "sxsd_missing_required_child",
                    path,
                    tag,
                    attr=None,
                    expected=f"one of: {expected_names} (at least {requirement.min_occurs})",
                    actual=actual_count,
                    message=f"missing required SXSD choice child under <{tag}> at {path}",
                    hint=f"Add at least {requirement.min_occurs} child from: {expected_names}.",
                )
            )
        if requirement.max_occurs is not None and actual_count > requirement.max_occurs:
            issues.append(
                issue(
                    "sxsd_too_many_children",
                    path,
                    tag,
                    attr=None,
                    expected=f"at most {requirement.max_occurs} child from: {expected_names}",
                    actual=actual_count,
                    message=f"too many SXSD choice children under <{tag}> at {path}",
                    hint=f"Keep at most {requirement.max_occurs} child from: {expected_names}.",
                )
            )
    return issues, matched


def validate_sxsd(root: ET.Element, schema_path: Path) -> list[dict[str, Any]]:
    model = load_schema_model(str(schema_path.resolve()))
    issues: list[dict[str, Any]] = []
    root_name = local_name(root.tag)
    document_namespace = element_namespace(root.tag)
    is_bare_slide_fragment = root_name == "slide" and document_namespace is None
    has_valid_document_namespace = (
        document_namespace in ACCEPTED_SML_NAMESPACES or is_bare_slide_fragment
    )

    def visit(element: ET.Element, parent_path: str, element_rule: ElementRule) -> None:
        tag = local_name(element.tag)
        path = f"{parent_path}/{tag}" if parent_path else tag
        namespace = element_namespace(element.tag)
        invalid_root_namespace = (
            not parent_path
            and namespace not in ACCEPTED_SML_NAMESPACES
            and not is_bare_slide_fragment
        )
        invalid_descendant_namespace = (
            bool(parent_path)
            and has_valid_document_namespace
            and namespace != document_namespace
        )
        if invalid_root_namespace or invalid_descendant_namespace:
            expected_namespace = document_namespace if parent_path else SML_NAMESPACE
            namespace_hint = (
                "Keep SXSD descendants without xmlns in a bare <slide> readback fragment."
                if expected_namespace is None
                else f'Use xmlns="{expected_namespace}" for SXSD elements.'
            )
            issues.append(
                issue(
                    "sxsd_invalid_namespace",
                    path,
                    tag,
                    attr=None,
                    expected=expected_namespace,
                    actual=namespace,
                    message=f"invalid SXSD namespace on <{tag}> at {path}",
                    hint=namespace_hint,
                )
            )
        issues.extend(validate_element_attributes(element, path, model, element_rule))
        child_issues, matched = validate_element_children(element, path, element_rule, model)
        issues.extend(child_issues)
        for child in element:
            child_rule = matched.get(id(child))
            if child_rule is not None:
                visit(child, path, child_rule)

    if root_name not in {"presentation", "slide"}:
        issues.append(
            issue(
                "sxsd_unexpected_root",
                root_name,
                root_name,
                attr=None,
                expected="presentation or slide",
                actual=root_name,
                message=f"unsupported SXSD root <{root_name}>",
                hint="Use a <presentation> or <slide> root.",
            )
        )
        return issues
    if root_name == "presentation":
        root_rule = model.global_elements.get("presentation")
    elif "SlideType" in model.complex_types:
        root_rule = ElementRule("slide", "SlideType", None, None)
    else:
        root_rule = None
    if root_rule is None:
        issues.append(
            issue(
                "sxsd_unexpected_root",
                root_name,
                root_name,
                attr=None,
                expected="presentation or slide",
                actual=root_name,
                message=f"unsupported SXSD root <{root_name}>",
                hint="Use a <presentation> or <slide> root.",
            )
        )
        return issues
    visit(root, "", root_rule)
    return issues


def load_tag_attributes(schema_path: Path) -> dict[str, set[str]]:
    model = load_schema_model(str(schema_path.resolve()))
    tag_attributes: dict[str, set[str]] = {}
    for tag_name, candidates in model.element_candidates.items():
        attrs = tag_attributes.setdefault(tag_name, set())
        for candidate in candidates:
            attrs.update(attributes_for_element(concrete_element_rule(candidate, model), model))
    return tag_attributes
scripts/xml_lint_test.py
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations

import itertools
import json
import subprocess
import sys
import tempfile
import unittest
import xml.etree.ElementTree as ET
from pathlib import Path
from unittest import mock

import sxsd_validator
import xml_lint


class XmlTextOverlapLintGeometryTest(unittest.TestCase):
    def assertNoXmlTextOverlapLintErrors(self, result: dict, sample_name: str) -> None:
        issue_summaries = []
        for slide in result.get("slides", []):
            for issue in slide.get("issues", []):
                issue_summaries.append(
                    f"slide {slide['slide_number']}: {issue['level']} {issue['code']} {issue['message']}"
                )
        if result.get("issues"):
            for issue in result["issues"]:
                issue_summaries.append(f"{issue['level']} {issue['code']} {issue['message']}")
        self.assertEqual(
            result["summary"]["error_count"],
            0,
            f"{sample_name} has XML text overlap lint errors:\n" + "\n".join(issue_summaries),
        )

    def test_cli_suggests_input_flag_for_positional_argument(self) -> None:
        script_path = Path(xml_lint.__file__).resolve()
        input_path = "/sandboxdata/workspace/file/full_presentation.xml"

        completed = subprocess.run(
            [sys.executable, str(script_path), input_path],
            capture_output=True,
            check=False,
            text=True,
        )

        self.assertEqual(completed.returncode, 1)
        self.assertEqual(completed.stdout, "")
        self.assertEqual(
            completed.stderr,
            f"xml-lint error: unexpected argument: {input_path}, need --input\n",
        )

    def test_cli_preserves_requested_symlink_path_in_result(self) -> None:
        script_path = Path(xml_lint.__file__).resolve()
        with tempfile.TemporaryDirectory() as temp_dir:
            temp_path = Path(temp_dir)
            resolved_path = temp_path / "resolved.xml"
            requested_path = temp_path / "requested.xml"
            resolved_path.write_text(
                '<presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">'
                '<slide xmlns="https://www.larkoffice.com/sml/2.0"><data>'
                '<shape type="text" topLeftX="10" topLeftY="10" width="100" height="30">'
                '<content><p><span>Test</span></p></content>'
                '</shape></data></slide>'
                '</presentation>',
                encoding="utf-8",
            )
            requested_path.symlink_to(resolved_path)

            completed = subprocess.run(
                [sys.executable, str(script_path), "--input", str(requested_path)],
                capture_output=True,
                check=False,
                text=True,
            )

        self.assertEqual(completed.returncode, 0, completed.stderr)
        result = json.loads(completed.stdout)
        self.assertEqual(result["file"], str(requested_path))

    def test_cli_reports_structured_slide_sxsd_error_outside_skill_directory(self) -> None:
        script_path = Path(xml_lint.__file__).resolve()
        with tempfile.TemporaryDirectory() as temp_dir:
            input_path = Path(temp_dir) / "invalid-slide.xml"
            input_path.write_text(
                """
                <slide xmlns="https://www.larkoffice.com/sml/2.0">
                  <data><shape type="text" topLeftX="10" topLeftY="20" width="300"/></data>
                </slide>
                """,
                encoding="utf-8",
            )

            completed = subprocess.run(
                [sys.executable, str(script_path), "--input", str(input_path)],
                cwd=temp_dir,
                capture_output=True,
                check=False,
                text=True,
            )

        result = json.loads(completed.stdout)
        issue = result["slides"][0]["errors"][0]
        self.assertEqual(completed.returncode, 1)
        self.assertEqual(completed.stderr, "")
        self.assertEqual(issue["code"], "sxsd_missing_required_attr")
        self.assertEqual(issue["path"], "slide/data/shape")
        self.assertEqual(issue["attr"], "height")
        self.assertEqual(issue["target"]["slide_number"], 1)
        self.assertTrue(issue["hint"])

    def test_xml_lint_accepts_inline_fixture_xml_samples(self) -> None:
        samples = {
            "image-led-cover": """
                <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
                  <slide xmlns="https://www.larkoffice.com/sml/2.0">
                    <style><fill><fillColor color="rgb(15,23,42)"/></fill></style>
                    <data>
                      <img src="tok" topLeftX="560" topLeftY="0" width="400" height="540"/>
                      <shape type="text" topLeftX="64" topLeftY="150" width="420" height="70">
                        <content textType="title"><p><span fontSize="42">Quarterly Review</span></p></content>
                      </shape>
                      <shape type="text" topLeftX="64" topLeftY="235" width="420" height="36">
                        <content textType="sub-headline"><p><span fontSize="20">Focus, progress, and next steps</span></p></content>
                      </shape>
                    </data>
                  </slide>
                </presentation>
            """,
            "content-grid": """
                <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
                  <slide xmlns="https://www.larkoffice.com/sml/2.0">
                    <data>
                      <shape type="text" topLeftX="60" topLeftY="44" width="620" height="46">
                        <content textType="title"><p><span fontSize="30">Execution Snapshot</span></p></content>
                      </shape>
                      <shape type="rect" topLeftX="60" topLeftY="126" width="250" height="150"/>
                      <shape type="text" topLeftX="84" topLeftY="152" width="200" height="36">
                        <content textType="headline"><p><span fontSize="22">Plan</span></p></content>
                      </shape>
                      <shape type="rect" topLeftX="355" topLeftY="126" width="250" height="150"/>
                      <shape type="text" topLeftX="379" topLeftY="152" width="200" height="36">
                        <content textType="headline"><p><span fontSize="22">Build</span></p></content>
                      </shape>
                      <shape type="rect" topLeftX="650" topLeftY="126" width="250" height="150"/>
                      <shape type="text" topLeftX="674" topLeftY="152" width="200" height="36">
                        <content textType="headline"><p><span fontSize="22">Launch</span></p></content>
                      </shape>
                    </data>
                  </slide>
                </presentation>
            """,
        }
        self.assertTrue(samples)
        for sample_name, sample_xml in samples.items():
            with self.subTest(sample=sample_name):
                result = xml_lint.lint_xml(
                    sample_xml,
                    sample_name,
                )
                self.assertNoXmlTextOverlapLintErrors(result, sample_name)

    def test_lint_xml_reports_unescaped_ampersand_in_text(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
                  <content textType="body"><p>Q&A</p></content>
                </shape>
              </data>
            </slide>
            """
        )
        issue = result["issues"][0]
        self.assertEqual(result["summary"]["error_count"], 1)
        self.assertEqual(issue["code"], "xml_not_well_formed")
        self.assertIsInstance(issue["line"], int)
        self.assertIsInstance(issue["column"], int)
        self.assertIn("Q&A", issue["context"])
        self.assertIn("&amp;", issue["hint"])

    def test_lint_xml_reports_unescaped_ampersand_in_attribute(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
                  <content textType="body"><p><a href="https://example.com/?a=1&b=2">link</a></p></content>
                </shape>
              </data>
            </slide>
            """
        )
        issue = result["issues"][0]
        self.assertEqual(issue["code"], "xml_not_well_formed")
        self.assertIn("attribute", issue["hint"])
        self.assertIn("a=1&amp;b=2", issue["hint"])

    def test_lint_xml_reports_mismatched_xml_tag(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
                  <content textType="body"><p>Broken XML</content>
                </shape>
              </data>
            </slide>
            """
        )
        issue = result["issues"][0]
        self.assertEqual(result["summary"]["slide_count"], 0)
        self.assertEqual(result["summary"]["error_count"], 1)
        self.assertEqual(issue["code"], "xml_not_well_formed")
        self.assertIsInstance(issue["line"], int)
        self.assertIsInstance(issue["column"], int)
        self.assertIn("Broken XML", issue["context"])
        self.assertEqual(issue["related_objects"], [])
        self.assertNotIn("Locate via related_objects[].xml_path.", issue["hint"])

    def test_lint_xml_rejects_prefixed_sml_tags(self) -> None:
        result = xml_lint.lint_xml(
            """
            <ns0:slide xmlns:ns0="https://www.larkoffice.com/sml/2.0">
              <ns0:data>
                <sml:shape xmlns:sml="https://www.larkoffice.com/sml/2.0" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
                  <sml:content textType="body"><sml:p>Prefixed SML</sml:p></sml:content>
                </sml:shape>
              </ns0:data>
            </ns0:slide>
            """
        )
        issues = result["issues"]
        self.assertEqual(result["summary"]["error_count"], 5)
        self.assertEqual([issue["code"] for issue in issues], ["sml_prefixed_tag"] * 5)
        self.assertEqual(issues[0]["tag"], "ns0:slide")
        self.assertIn("default namespace", issues[0]["hint"])
        self.assertEqual({issue["namespace"] for issue in issues}, {SML_NAMESPACE})

    def test_lint_xml_reports_bound_namespace_for_prefixed_sml_tags(self) -> None:
        for namespace in (
            "http://www.larkoffice.com/sml/2.0",
            "/sml/2.0",
            SML_NAMESPACE,
        ):
            with self.subTest(namespace=namespace):
                result = xml_lint.lint_xml(
                    f"""
                    <sml:slide xmlns:sml="{namespace}">
                      <sml:data>
                        <sml:shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
                          <sml:content textType="body"><sml:p>Prefixed SML</sml:p></sml:content>
                        </sml:shape>
                      </sml:data>
                    </sml:slide>
                    """
                )
                issues = result["issues"]
                self.assertEqual([issue["code"] for issue in issues], ["sml_prefixed_tag"] * 5)
                self.assertEqual({issue["namespace"] for issue in issues}, {namespace})

    def test_lint_xml_accepts_server_readback_slide_without_namespace(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide id="server-slide-id">
              <data>
                <shape id="server-shape-id" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
                  <content textType="body"><p>Unprefixed SML</p></content>
                </shape>
              </data>
            </slide>
            """
        )
        self.assertEqual(result["summary"]["error_count"], 0)

    def test_lint_xml_accepts_server_readback_presentation_short_namespace(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="/sml/2.0" width="960" height="540" id="server-presentation-id">
              <slide id="server-slide-id">
                <data>
                  <shape id="server-shape-id" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
                    <content textType="body"><p>Server readback presentation</p></content>
                  </shape>
                </data>
              </slide>
            </presentation>
            """
        )

        self.assertEqual(result["summary"]["slide_count"], 1)
        self.assertEqual(result["summary"]["error_count"], 0)

    def test_lint_xml_accepts_server_readback_presentation_https_namespace(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540" id="server-presentation-id">
              <slide id="server-slide-id">
                <data>
                  <shape id="server-shape-id" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
                    <content textType="body"><p>Server readback presentation</p></content>
                  </shape>
                </data>
              </slide>
            </presentation>
            """
        )

        self.assertEqual(result["summary"]["slide_count"], 1)
        self.assertEqual(result["summary"]["error_count"], 0)

    def test_lint_xml_accepts_legacy_http_namespace(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540" id="legacy-http-id">
              <slide id="server-slide-id">
                <data>
                  <shape id="server-shape-id" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
                    <content textType="body"><p>Legacy HTTP namespace</p></content>
                  </shape>
                </data>
              </slide>
            </presentation>
            """
        )

        self.assertEqual(result["summary"]["slide_count"], 1)
        self.assertEqual(result["summary"]["error_count"], 0)

    def test_lint_xml_rejects_server_readback_presentation_wrong_namespace(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://example.com/not-sml" width="960" height="540">
              <slide/>
            </presentation>
            """
        )

        self.assertEqual(result["summary"]["error_count"], 1)
        self.assertEqual(result["issues"][0]["code"], "sxsd_invalid_namespace")

    def test_lint_xml_rejects_xml_declaration(self) -> None:
        result = xml_lint.lint_xml(
            '<?xml version="1.0"?><slide xmlns="https://www.larkoffice.com/sml/2.0"><data/></slide>'
        )

        self.assertEqual(result["summary"]["error_count"], 1)
        self.assertEqual(result["issues"][0]["code"], "sxsd_unsupported_declaration")

    def test_lint_xml_reports_missing_required_sxsd_attribute(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data><shape type="text" topLeftX="80" topLeftY="80" width="300"/></data>
            </slide>
            """
        )

        self.assertEqual(result["summary"]["error_count"], 1)
        self.assertNotIn("issues", result)
        issue = result["slides"][0]["issues"][0]
        self.assertEqual(issue["code"], "sxsd_missing_required_attr")
        self.assertEqual(issue["attr"], "height")

    def test_lint_xml_rejects_child_order_that_violates_xsd_sequence(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide/>
              <title>Late title</title>
            </presentation>
            """
        )

        self.assertEqual(result["summary"]["error_count"], 1)
        self.assertEqual(result["issues"][0]["code"], "sxsd_invalid_child_order")

    def test_lint_xml_accepts_escaped_entities_without_suspicious_entity_warning(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
                  <content textType="body"><p>Q&amp;A</p></content>
                </shape>
              </data>
            </slide>
            """
        )
        self.assertEqual(result["summary"]["error_count"], 0)
        self.assertNotIn("issues", result)

    def test_lint_xml_accepts_chinese_full_width_punctuation(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape type="text" topLeftX="80" topLeftY="80" width="620" height="90">
                  <content textType="body"><p>承诺:按期交付;持续复盘|风险透明</p></content>
                </shape>
              </data>
            </slide>
            """
        )
        self.assertEqual(result["summary"]["error_count"], 0)

    def test_lint_xml_single_slide_reports_out_of_canvas_and_blank_slide_errors(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape type="text" topLeftX="1000" topLeftY="500" width="120" height="80">
                  <content textType="body"><p>Body text outside the canvas</p></content>
                </shape>
              </data>
            </slide>
            """
        )
        self.assertEqual(result["slide_size"], {"width": 960, "height": 540})
        self.assertEqual(result["summary"]["slide_count"], 1)
        self.assertEqual(result["summary"]["error_count"], 2)
        self.assertEqual(
            [issue["code"] for issue in result["slides"][0]["errors"]],
            ["shape_out_of_canvas", "blank_slide"],
        )

    def test_lint_xml_preserves_presentation_canvas_and_slide_order(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="1280" height="720">
              <slide xmlns="https://www.larkoffice.com/sml/2.0">
                <data>
                  <shape id="first" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
                    <content textType="body"><p>First slide</p></content>
                  </shape>
                </data>
              </slide>
              <slide xmlns="https://www.larkoffice.com/sml/2.0">
                <data>
                  <img id="second" src="tok" topLeftX="100" topLeftY="120" width="240" height="160"/>
                  <shape id="second-shape" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
                    <content textType="body"><p>Second slide</p></content>
                  </shape>
                </data>
              </slide>
            </presentation>
            """
        )
        self.assertEqual(result["slide_size"], {"width": 1280, "height": 720})
        self.assertEqual(result["summary"]["slide_count"], 2)
        self.assertEqual([slide["slide_number"] for slide in result["slides"]], [1, 2])
        self.assertEqual([slide["element_count"] for slide in result["slides"]], [1, 2])
        self.assertEqual(result["summary"]["error_count"], 0)
        self.assertEqual(result["summary"]["warning_count"], 0)

    def test_lint_xml_handles_self_closing_slide_before_normal_slide(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide/>
              <slide>
                <data>
                  <shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
                    <content textType="body"><p>Second slide</p></content>
                  </shape>
                </data>
              </slide>
            </presentation>
            """
        )

        self.assertEqual(result["summary"]["slide_count"], 2)
        self.assertEqual(
            [issue["code"] for issue in result["slides"][0]["errors"]],
            ["blank_slide"],
        )
        self.assertEqual(result["slides"][1]["status"], "passed")

    def test_lint_xml_keeps_trailing_self_closing_slide(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide>
                <data>
                  <shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
                    <content textType="body"><p>First slide</p></content>
                  </shape>
                </data>
              </slide>
              <slide/>
            </presentation>
            """
        )

        self.assertEqual(result["summary"]["slide_count"], 2)
        self.assertEqual(
            [issue["code"] for issue in result["slides"][1]["errors"]],
            ["blank_slide"],
        )

    def test_lint_xml_ignores_slide_markup_inside_xml_comments(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide>
                <data>
                  <shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
                    <content textType="body"><p>Real slide</p></content>
                  </shape>
                </data>
              </slide>
              <!-- Old draft: <slide id="ghost"/> -->
            </presentation>
            """
        )

        self.assertEqual(result["summary"]["slide_count"], 1)
        self.assertEqual(result["slides"][0]["status"], "passed")

    def test_lint_xml_ignores_invalid_slide_markup_inside_xml_comments(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide><data/></slide>
              <!-- Old draft: <slide bogus="x"/> -->
            </presentation>
            """
        )

        self.assertEqual(result["summary"]["slide_count"], 1)
        self.assertEqual(
            [issue["code"] for issue in result["slides"][0]["errors"]],
            ["blank_slide"],
        )

    def test_lint_xml_skips_only_invalid_slide_and_continues_geometry_checks(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide>
                <data>
                  <shape id="invalid" type="text" topLeftX="1000" topLeftY="80" width="300">
                    <content textType="body"><p>Missing height</p></content>
                  </shape>
                </data>
              </slide>
              <slide>
                <data>
                  <shape id="outside" type="text" topLeftX="1000" topLeftY="80" width="120" height="60">
                    <content textType="body"><p>Outside canvas</p></content>
                  </shape>
                </data>
              </slide>
            </presentation>
            """
        )

        self.assertEqual(result["summary"]["slide_count"], 2)
        self.assertEqual(result["slides"][0]["element_count"], 0)
        self.assertEqual(
            [issue["code"] for issue in result["slides"][0]["errors"]],
            ["sxsd_missing_required_attr"],
        )
        self.assertNotIn(
            "shape_out_of_canvas",
            [issue["code"] for issue in result["slides"][0]["issues"]],
        )
        self.assertIn(
            "shape_out_of_canvas",
            [issue["code"] for issue in result["slides"][1]["errors"]],
        )

    def test_lint_xml_scopes_invalid_slide_root_attribute_to_that_slide(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide bogusAttr="oops">
                <data><shape type="rect" topLeftX="80" topLeftY="80" width="100" height="100"/></data>
              </slide>
              <slide>
                <data><shape type="rect" topLeftX="1000" topLeftY="80" width="100" height="100"/></data>
              </slide>
            </presentation>
            """
        )

        self.assertEqual(result["summary"]["slide_count"], 2)
        self.assertEqual(
            [issue["code"] for issue in result["slides"][0]["errors"]],
            ["sxsd_unsupported_attr"],
        )
        self.assertIn(
            "shape_out_of_canvas",
            [issue["code"] for issue in result["slides"][1]["errors"]],
        )

    def test_lint_xml_allows_svg_subtree_inside_embed(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="http://www.larkoffice.com/sml/2.0">
              <data>
                <embed topLeftX="80" topLeftY="120" width="240" height="140">
                  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 140">
                    <rect x="10" y="10" width="220" height="120" rx="12" fill="#EFF6FF"/>
                    <circle cx="70" cy="70" r="34" fill="#2563EB"/>
                    <text x="130" y="76" font-size="18" fill="#1E3A8A">SVG OK</text>
                    <foreignObject x="0" y="0" width="1" height="1">
                      <embed xmlns="http://www.w3.org/1999/xhtml">
                        <rect xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>
                      </embed>
                    </foreignObject>
                  </svg>
                </embed>
              </data>
            </slide>
            """
        )
        codes = [issue["code"] for issue in result["slides"][0]["issues"]]
        self.assertNotIn("sxsd_unsupported_tag", codes)
        self.assertNotIn("sxsd_unsupported_attr", codes)
        self.assertEqual(result["summary"]["error_count"], 0)

    def test_lint_xml_enforces_embed_svg_contract(self) -> None:
        cases = [
            ("missing SVG", "", "sxsd_missing_required_child"),
            ("wrong namespace", '<svg viewBox="0 0 20 20"/>', "sxsd_unsupported_tag"),
            (
                "multiple SVG roots",
                '<svg xmlns="http://www.w3.org/2000/svg"/><svg xmlns="http://www.w3.org/2000/svg"/>',
                "sxsd_too_many_children",
            ),
            (
                "non-SVG root element",
                '<rect xmlns="http://www.w3.org/2000/svg" width="20" height="20"/>',
                "sxsd_unexpected_child",
            ),
            (
                "SVG after reflection",
                '<reflection/><svg xmlns="http://www.w3.org/2000/svg"/>',
                "sxsd_invalid_child_order",
            ),
        ]
        for name, children, expected_code in cases:
            with self.subTest(name=name):
                result = xml_lint.lint_xml(
                    f"""
                    <slide xmlns="http://www.larkoffice.com/sml/2.0">
                      <data>
                        <embed topLeftX="80" topLeftY="120" width="240" height="140">
                          {children}
                        </embed>
                      </data>
                    </slide>
                    """
                )
                codes = [issue["code"] for issue in result["slides"][0]["issues"]]
                self.assertIn(expected_code, codes)

    def test_lint_xml_enforces_embed_svg_root_for_readback_namespaces(self) -> None:
        document_templates = [
            ("bare slide", "<slide>{content}</slide>"),
            (
                "short namespace",
                '<presentation xmlns="/sml/2.0" width="960" height="540">'
                "<slide>{content}</slide>"
                "</presentation>",
            ),
            (
                "HTTPS namespace",
                '<presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">'
                "<slide>{content}</slide>"
                "</presentation>",
            ),
        ]
        embed_template = """
            <data>
              <embed topLeftX="80" topLeftY="120" width="240" height="140">
                {svg_root}
              </embed>
            </data>
        """
        roots = [
            ("valid SVG root", '<svg xmlns="http://www.w3.org/2000/svg"/>', False),
            (
                "non-SVG root element",
                '<rect xmlns="http://www.w3.org/2000/svg" width="20" height="20"/>',
                True,
            ),
        ]

        for document_name, document_template in document_templates:
            for root_name, svg_root, should_error in roots:
                with self.subTest(document=document_name, root=root_name):
                    content = embed_template.format(svg_root=svg_root)
                    result = xml_lint.lint_xml(
                        document_template.format(content=content)
                    )
                    codes = [
                        issue["code"]
                        for slide in result["slides"]
                        for issue in slide["issues"]
                    ]
                    if should_error:
                        self.assertIn("sxsd_unexpected_child", codes)
                    else:
                        self.assertNotIn("sxsd_unexpected_child", codes)
                        self.assertEqual(result["summary"]["error_count"], 0)

    def test_lint_xml_reports_sxsd_unsupported_tag_with_alias_hint(self) -> None:
        cases = [
            ("textbox", '<textbox topLeftX="80" topLeftY="80" width="300" height="60">Text</textbox>', '<shape type="text">'),
            ("image", '<image src="tok" topLeftX="80" topLeftY="80" width="300" height="180"/>', "<img>"),
        ]
        for tag_name, element_xml, expected_hint in cases:
            with self.subTest(tag=tag_name):
                result = xml_lint.lint_xml(
                    f"""
                    <slide xmlns="https://www.larkoffice.com/sml/2.0">
                      <data>{element_xml}</data>
                    </slide>
                    """
                )
                slide_issues = result["slides"][0]["issues"]
                issue = next(
                    issue for issue in slide_issues if issue["code"] == "sxsd_unsupported_tag"
                )
                self.assertEqual(result["summary"]["error_count"], 1)
                self.assertEqual(
                    [reported["code"] for reported in slide_issues],
                    ["sxsd_unsupported_tag"],
                )
                self.assertEqual(issue["code"], "sxsd_unsupported_tag")
                self.assertEqual(issue["tag"], tag_name)
                self.assertIn(expected_hint, issue["hint"])

    def test_lint_xml_reports_sxsd_unsupported_attr_with_alias_hint(self) -> None:
        cases = [
            ("shape", "x", "topLeftX", '<shape type="text" x="80" topLeftY="80" width="300" height="60"><content><p>Text</p></content></shape>'),
            ("shape", "heigth", "height", '<shape type="text" topLeftX="80" topLeftY="80" width="300" heigth="60"><content><p>Text</p></content></shape>'),
            ("content", "fontColor", "color", '<shape type="text" topLeftX="80" topLeftY="80" width="300" height="60"><content fontColor="rgba(0, 0, 0, 1)"><p>Text</p></content></shape>'),
        ]
        for tag_name, attr_name, expected_attr, element_xml in cases:
            with self.subTest(attr=attr_name):
                result = xml_lint.lint_xml(
                    f"""
                    <slide xmlns="https://www.larkoffice.com/sml/2.0">
                      <data>{element_xml}</data>
                    </slide>
                    """
                )
                slide_issues = result["slides"][0]["issues"]
                issue = next(
                    issue for issue in slide_issues if issue["code"] == "sxsd_unsupported_attr"
                )
                self.assertEqual(result["summary"]["error_count"], 1)
                self.assertEqual(
                    [reported["code"] for reported in slide_issues],
                    ["sxsd_unsupported_attr"],
                )
                self.assertEqual(issue["code"], "sxsd_unsupported_attr")
                self.assertEqual(issue["tag"], tag_name)
                self.assertEqual(issue["attr"], attr_name)
                self.assertIn(expected_attr, issue["hint"])

    def test_lint_xml_keeps_unrelated_unsupported_and_missing_attrs(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape type="text" bogus="x" topLeftX="80" topLeftY="80" width="300"/>
              </data>
            </slide>
            """
        )

        self.assertEqual(
            [issue["code"] for issue in result["slides"][0]["issues"]],
            ["sxsd_unsupported_attr", "sxsd_missing_required_attr"],
        )

    def test_lint_xml_keeps_missing_attrs_when_suggestion_is_ambiguous(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape type="text" topLeft="80" width="300" height="60"/>
              </data>
            </slide>
            """
        )

        self.assertEqual(
            [issue["code"] for issue in result["slides"][0]["issues"]],
            [
                "sxsd_unsupported_attr",
                "sxsd_missing_required_attr",
                "sxsd_missing_required_attr",
            ],
        )

    def test_lint_xml_suppresses_only_missing_attr_that_resolves_ambiguous_suggestion(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape type="text" topLeftXX="80" topLeftY="80" width="300" height="60"/>
              </data>
            </slide>
            """
        )

        self.assertEqual(
            [
                (issue["code"], issue.get("attr"))
                for issue in result["slides"][0]["issues"]
            ],
            [("sxsd_unsupported_attr", "topLeftXX")],
        )

    def test_lint_xml_ignores_server_filled_id_attrs(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide id="server-slide-id" xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="server-shape-id" type="rect" topLeftX="80" topLeftY="80" width="300" height="160">
                  <fill id="server-fill-id" unexpected="value">
                    <fillColor color="rgba(255, 255, 255, 1)"/>
                  </fill>
                </shape>
              </data>
            </slide>
            """
        )

        self.assertEqual(result["summary"]["error_count"], 1)
        self.assertEqual(
            [(issue["tag"], issue["attr"]) for issue in result["slides"][0]["issues"]],
            [("fill", "unexpected")],
        )

    def test_lint_xml_ignores_chart_roundtrip_attrs(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <chart updated="true" topLeftX="80" topLeftY="80" width="300" height="160">
                  <chartPlotArea><chartPlot type="line"/></chartPlotArea>
                  <chartData isStaticData="true">
                    <dim1><chartField name="category" valueType="string">A</chartField></dim1>
                    <dim2><chartField name="value" valueType="number">1</chartField></dim2>
                  </chartData>
                </chart>
              </data>
            </slide>
            """
        )

        self.assertEqual(result["summary"]["error_count"], 0)

    def test_lint_xml_ignores_chart_parsed_values_roundtrip_tags(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <chart topLeftX="80" topLeftY="80" width="300" height="160">
                  <chartPlotArea><chartPlot type="line"/></chartPlotArea>
                  <chartData isStaticData="true">
                    <dim1>
                      <chartField name="category" valueType="string">
                        A<chartParsedValues>A</chartParsedValues>
                      </chartField>
                    </dim1>
                    <dim2><chartField name="value" valueType="number">1</chartField></dim2>
                  </chartData>
                </chart>
              </data>
            </slide>
            """
        )

        self.assertEqual(result["summary"]["error_count"], 0)

    def test_lint_xml_ignores_chart_parsed_values_roundtrip_tag(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <chart topLeftX="80" topLeftY="80" width="300" height="160">
                  <chartPlotArea><chartPlot type="line"/></chartPlotArea>
                  <chartData>
                    <dim1>
                      <chartField name="category" valueType="string">
                        Africa<chartParsedValues>Africa</chartParsedValues>
                      </chartField>
                    </dim1>
                    <dim2><chartField name="value" valueType="number">1</chartField></dim2>
                  </chartData>
                </chart>
              </data>
            </slide>
            """
        )

        self.assertEqual(result["summary"]["error_count"], 0)
        self.assertNotIn("issues", result)

    def test_lint_xml_rejects_chart_parsed_values_outside_chart_field(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape type="rect" topLeftX="80" topLeftY="80" width="300" height="160">
                  <chartParsedValues>unexpected</chartParsedValues>
                </shape>
              </data>
            </slide>
            """
        )

        self.assertEqual(result["summary"]["error_count"], 1)
        self.assertEqual(
            [issue["code"] for issue in result["slides"][0]["issues"]],
            ["sxsd_unsupported_tag"],
        )
        self.assertEqual(
            result["slides"][0]["issues"][0]["path"],
            "slide/data/shape/chartParsedValues",
        )

    def test_lint_xml_limits_chart_roundtrip_attrs_to_matching_tags(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <chart isStaticData="true" topLeftX="80" topLeftY="80" width="300" height="160">
                  <chartPlotArea><chartPlot type="line"/></chartPlotArea>
                  <chartData updated="true">
                    <dim1><chartField name="category" valueType="string">A</chartField></dim1>
                    <dim2><chartField name="value" valueType="number">1</chartField></dim2>
                  </chartData>
                </chart>
              </data>
            </slide>
            """
        )

        self.assertEqual(result["summary"]["error_count"], 2)
        slide_issues = result["slides"][0]["issues"]
        self.assertEqual(
            {(issue["tag"], issue["attr"]) for issue in slide_issues},
            {("chart", "isStaticData"), ("chartData", "updated")},
        )
        self.assertTrue(all(issue["code"] == "sxsd_unsupported_attr" for issue in slide_issues))

    def test_lint_xml_reports_gradient_shorthand_attrs_on_fill_color(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape type="rect" topLeftX="80" topLeftY="80" width="300" height="160">
                  <fill>
                    <fillColor
                      type="gradient"
                      color1="rgba(255, 0, 0, 1)"
                      color2="rgba(0, 0, 255, 1)"
                      angle="45"
                      stop1="0%"
                      stop2="100%"/>
                  </fill>
                </shape>
              </data>
            </slide>
            """
        )
        slide_issues = result["slides"][0]["issues"]
        unsupported_attrs = {issue["attr"] for issue in slide_issues}
        self.assertEqual(result["summary"]["error_count"], 6)
        self.assertEqual(
            unsupported_attrs,
            {"type", "color1", "color2", "angle", "stop1", "stop2"},
        )
        self.assertTrue(all(issue["code"] == "sxsd_unsupported_attr" for issue in slide_issues))
        self.assertTrue(all(issue["tag"] == "fillColor" for issue in slide_issues))

    def test_lint_xml_accepts_chart_field_simple_content_attrs(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <chart topLeftX="80" topLeftY="80" width="520" height="320">
                  <chartPlotArea>
                    <chartPlot type="line"/>
                  </chartPlotArea>
                  <chartData>
                    <dim1>
                      <chartField name="month" valueType="string">Jan, Feb</chartField>
                    </dim1>
                    <dim2>
                      <chartField name="value" valueType="number">1, 2</chartField>
                    </dim2>
                  </chartData>
                </chart>
              </data>
            </slide>
            """
        )
        self.assertEqual(result["summary"]["error_count"], 0)
        self.assertNotIn("issues", result)

    def test_lint_xml_does_not_load_iconpark_index_without_icons(self) -> None:
        original_loader = xml_lint.load_iconpark_icon_types

        def fail_if_loaded() -> set[str]:
            raise AssertionError("iconpark index should not be loaded without <icon iconType>")

        xml_lint.load_iconpark_icon_types = fail_if_loaded
        try:
            result = xml_lint.lint_xml(
                """
                <slide xmlns="https://www.larkoffice.com/sml/2.0">
                  <data>
                    <shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
                      <content><p>No icons here</p></content>
                    </shape>
                  </data>
                </slide>
                """
            )
        finally:
            xml_lint.load_iconpark_icon_types = original_loader

        self.assertEqual(result["summary"]["error_count"], 0)
        self.assertNotIn("issues", result)

    def test_lint_xml_accepts_iconpark_icon_type_from_index(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <icon iconType="iconpark/Base/setting.svg" topLeftX="80" topLeftY="80" width="48" height="48">
                  <fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
                </icon>
              </data>
            </slide>
            """
        )
        self.assertEqual(result["summary"]["error_count"], 0)
        self.assertNotIn("issues", result)

    def test_lint_xml_reports_icon_missing_fill_color(self) -> None:
        cases = [
            '<icon iconType="iconpark/Base/setting.svg" topLeftX="80" topLeftY="80" width="48" height="48"/>',
            '<icon iconType="iconpark/Base/setting.svg" topLeftX="80" topLeftY="80" width="48" height="48"><fill/></icon>',
            (
                '<icon iconType="iconpark/Base/setting.svg" topLeftX="80" topLeftY="80" width="48" height="48">'
                "<fill><fillColor/></fill></icon>"
            ),
        ]
        for icon_xml in cases:
            with self.subTest(icon=icon_xml):
                result = xml_lint.lint_xml(
                    f"""
                    <slide xmlns="https://www.larkoffice.com/sml/2.0">
                      <data>{icon_xml}</data>
                    </slide>
                    """
                )

                issue = result["issues"][0]
                self.assertEqual(result["summary"]["error_count"], 1)
                self.assertEqual(issue["code"], "icon_missing_fill_color")
                self.assertEqual(issue["tag"], "icon")

    def test_lint_xml_reports_icon_transparent_fill_color(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <icon iconType="iconpark/Base/setting.svg" topLeftX="80" topLeftY="80" width="48" height="48">
                  <fill><fillColor color="rgba(37, 99, 235, 0)"/></fill>
                </icon>
              </data>
            </slide>
            """
        )
        issue = result["issues"][0]
        self.assertEqual(result["summary"]["error_count"], 1)
        self.assertEqual(issue["code"], "icon_transparent_fill_color")
        self.assertEqual(issue["tag"], "icon")
        self.assertEqual(issue["attr"], "fillColor")

    def test_lint_xml_reports_iconpark_icon_type_outside_index(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <icon iconType="iconpark/Base/settng.svg" topLeftX="80" topLeftY="80" width="48" height="48">
                  <fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
                </icon>
              </data>
            </slide>
            """
        )
        issue = result["issues"][0]
        self.assertEqual(result["summary"]["error_count"], 1)
        self.assertEqual(issue["code"], "iconpark_unsupported_icon_type")
        self.assertEqual(issue["tag"], "icon")
        self.assertEqual(issue["attr"], "iconType")
        self.assertEqual(issue["iconType"], "iconpark/Base/settng.svg")
        self.assertIn("iconpark-index.json", issue["hint"])
        self.assertIn("iconpark/Base/setting.svg", issue["hint"])

    def test_lint_xml_skips_iconpark_validation_inside_embedded_svg(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="http://www.larkoffice.com/sml/2.0">
              <data>
                <embed topLeftX="80" topLeftY="120" width="240" height="140">
                  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 140">
                    <rect x="10" y="10" width="220" height="120" fill="#EFF6FF"/>
                    <icon iconType="not-an-iconpark-name"/>
                    <foreignObject x="0" y="0" width="60" height="60">
                      <icon xmlns="http://www.w3.org/1999/xhtml" iconType="not-an-iconpark-name"/>
                    </foreignObject>
                  </svg>
                </embed>
              </data>
            </slide>
            """
        )
        codes = [issue["code"] for issue in result["document"]["errors"]]
        self.assertNotIn("iconpark_unsupported_icon_type", codes)
        self.assertNotIn("icon_missing_fill_color", codes)
        self.assertEqual(result["summary"]["error_count"], 0)

    def test_lint_xml_detects_overlapping_text_boxes(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide xmlns="https://www.larkoffice.com/sml/2.0">
                <data>
                  <shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
                    <content textType="title"><p>Title</p></content>
                  </shape>
                  <shape type="text" topLeftX="80" topLeftY="80" width="300" height="80">
                    <content textType="body"><p>Body</p></content>
                  </shape>
                </data>
              </slide>
            </presentation>
            """
        )
        self.assertEqual(result["summary"]["error_count"], 1)
        self.assertEqual(result["summary"]["warning_count"], 0)
        self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap")

    def test_lint_xml_detects_current_itinerary_cjk_caption_occlusion(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide id="pQO" xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape width="190" height="80" topLeftX="580" topLeftY="170" presetHandlers="0" type="rect" id="blI">
                  <fill><fillColor color="rgba(255, 255, 255, 0.9)"/></fill>
                  <border color="rgba(220, 205, 185, 1)" width="1"/>
                  <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
                </shape>
                <shape width="160" height="25" topLeftX="595" topLeftY="180" type="text" id="blX">
                  <content fontSize="14" fontFamily="思源黑体" color="rgba(120, 80, 40, 1)" bold="true"><p>日照金山</p></content>
                </shape>
                <shape width="160" height="40" topLeftX="595" topLeftY="205" type="text" id="blY">
                  <content textType="caption" fontSize="11" fontFamily="思源黑体" color="rgba(130, 100, 70, 1)"><p>清晨躺在床上看玉龙雪山日照金山奇观</p></content>
                </shape>
                <shape width="180" height="80" topLeftX="730" topLeftY="170" presetHandlers="0" type="rect" id="blH">
                  <fill><fillColor color="rgba(255, 255, 255, 0.9)"/></fill>
                  <border color="rgba(220, 205, 185, 1)" width="1"/>
                  <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
                </shape>
                <shape width="150" height="25" topLeftX="745" topLeftY="180" type="text" id="blp">
                  <content fontSize="14" fontFamily="思源黑体" color="rgba(120, 80, 40, 1)" bold="true"><p>午餐返程</p></content>
                </shape>
                <shape width="150" height="40" topLeftX="745" topLeftY="205" type="text" id="blV">
                  <content textType="caption" fontSize="11" fontFamily="思源黑体" color="rgba(130, 100, 70, 1)"><p>享用特色午餐,带着美好回忆返程</p></content>
                </shape>
                <shape width="190" height="80" topLeftX="580" topLeftY="310" presetHandlers="0" type="rect" id="blP">
                  <fill><fillColor color="rgba(255, 255, 255, 0.9)"/></fill>
                  <border color="rgba(220, 205, 185, 1)" width="1"/>
                  <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
                </shape>
                <shape width="160" height="25" topLeftX="595" topLeftY="320" type="text" id="blG">
                  <content fontSize="14" fontFamily="思源黑体" color="rgba(120, 80, 40, 1)" bold="true"><p>高路徒步</p></content>
                </shape>
                <shape width="160" height="40" topLeftX="595" topLeftY="345" type="text" id="blQ">
                  <content textType="caption" fontSize="11" fontFamily="思源黑体" color="rgba(130, 100, 70, 1)"><p>经典高路徒步,28道拐,龙洞瀑布,中虎跳峡</p></content>
                </shape>
                <shape width="180" height="80" topLeftX="730" topLeftY="310" presetHandlers="0" type="rect" id="blw">
                  <fill><fillColor color="rgba(255, 255, 255, 0.9)"/></fill>
                  <border color="rgba(220, 205, 185, 1)" width="1"/>
                  <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
                </shape>
                <shape width="150" height="25" topLeftX="745" topLeftY="320" type="text" id="blZ">
                  <content fontSize="14" fontFamily="思源黑体" color="rgba(120, 80, 40, 1)" bold="true"><p>伴手礼</p></content>
                </shape>
                <shape width="150" height="40" topLeftX="745" topLeftY="345" type="text" id="blS">
                  <content textType="caption" fontSize="11" fontFamily="思源黑体" color="rgba(130, 100, 70, 1)"><p>酒店精心准备的归途伴手礼,留下难忘纪念</p></content>
                </shape>
              </data>
            </slide>
            """
        )
        overlap_pairs = {tuple(issue["elements"]) for issue in result["slides"][0]["issues"]}
        self.assertEqual(result["summary"]["error_count"], 2)
        self.assertIn(("blY", "blV"), overlap_pairs)
        self.assertIn(("blQ", "blS"), overlap_pairs)

    def test_lint_xml_detects_horizontal_text_overflow_across_declared_box_gap(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="source" type="text" topLeftX="80" topLeftY="100" width="160" height="40">
                  <content fontSize="18" wrap="false"><p>这是一个足够长的中文文本用于检测跨越间隙的横向溢出</p></content>
                </shape>
                <shape id="target" type="text" topLeftX="260" topLeftY="100" width="160" height="40">
                  <content fontSize="18"><p>目标</p></content>
                </shape>
              </data>
            </slide>
            """
        )
        self.assertEqual(result["summary"]["error_count"], 1)
        self.assertEqual(result["summary"]["warning_count"], 0)
        issue = result["slides"][0]["issues"][0]
        self.assertEqual(issue["code"], "bbox_overlap")
        self.assertEqual(issue["elements"], ["source", "target"])
        self.assertGreater(issue["measurement"]["intersection_area"], 0)
        self.assertIsNotNone(issue.get("hint"))

    def test_lint_xml_allows_horizontal_text_with_default_wrap(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="source" type="text" topLeftX="80" topLeftY="100" width="160" height="40">
                  <content fontSize="18"><p>这是一个足够长的中文文本用于检测默认自动换行</p></content>
                </shape>
                <shape id="target" type="text" topLeftX="260" topLeftY="100" width="160" height="40">
                  <content fontSize="18"><p>目标</p></content>
                </shape>
              </data>
            </slide>
            """
        )
        self.assertEqual(result["summary"]["error_count"], 1)
        self.assertEqual(result["summary"]["warning_count"], 0)
        self.assertEqual(result["slides"][0]["issues"][0]["code"], "text_may_overflow_shape")
        self.assertEqual(result["slides"][0]["issues"][0]["level"], "error")
        self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["source"])

    def test_lint_xml_reports_text_out_of_canvas_and_warns_for_text_height(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide xmlns="https://www.larkoffice.com/sml/2.0">
                <data>
                  <shape type="text" topLeftX="80" topLeftY="80" width="180" height="20">
                    <content textType="body" fontSize="18"><p>This paragraph is intentionally much longer than the box can safely contain.</p></content>
                  </shape>
                  <shape type="text" topLeftX="1000" topLeftY="500" width="120" height="80">
                    <content textType="body"><p>Body text outside the canvas</p></content>
                  </shape>
                </data>
              </slide>
            </presentation>
            """
        )
        issue = result["slides"][0]["issues"][0]
        self.assertEqual(result["summary"]["error_count"], 2)
        self.assertEqual(result["summary"]["warning_count"], 0)
        self.assertEqual(issue["code"], "shape_out_of_canvas")
        self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 160, "bottom": 40})

    def test_lint_xml_warns_when_text_may_overflow_its_own_shape(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="overflowing" type="text" topLeftX="80" topLeftY="80" width="360" height="80">
                  <content fontSize="20" lineSpacing="multiple:1.5" autoFit="no-auto-fit">
                    <p>第一段</p><p>第二段</p><p>第三段</p><p>第四段</p>
                  </content>
                </shape>
                <shape id="fitting" type="text" topLeftX="480" topLeftY="80" width="360" height="120">
                  <content fontSize="20" lineSpacing="multiple:1.5">
                    <p>第一段</p><p>第二段</p><p>第三段</p><p>第四段</p>
                  </content>
                </shape>
                <shape id="auto-fit" type="text" topLeftX="80" topLeftY="240" width="360" height="80">
                  <content fontSize="20" lineSpacing="multiple:1.5" autoFit="normal-auto-fit">
                    <p>第一段</p><p>第二段</p><p>第三段</p><p>第四段</p>
                  </content>
                </shape>
                <shape id="shape-auto-fit" type="text" topLeftX="480" topLeftY="240" width="360" height="30">
                  <content fontSize="20" lineSpacing="multiple:1.5" autoFit="shape-auto-fit">
                    <p>第一段</p><p>第二段</p><p>第三段</p>
                  </content>
                </shape>
              </data>
            </slide>
            """
        )
        issues = result["slides"][0]["issues"]
        overflow_issues = [issue for issue in issues if issue["code"] == "text_may_overflow_shape"]
        self.assertEqual(result["summary"]["error_count"], 1)
        overflow_ids = {issue["elements"][0] for issue in overflow_issues}
        self.assertIn("overflowing", overflow_ids)
        self.assertNotIn("auto-fit", overflow_ids)
        self.assertNotIn("shape-auto-fit", overflow_ids)
        self.assertNotIn("fitting", overflow_ids)
        overflowing_issue = next(issue for issue in overflow_issues if issue["elements"] == ["overflowing"])
        self.assertEqual(overflowing_issue["line_count"], 4)
        self.assertEqual(overflowing_issue["estimated_height"], 110)
        self.assertEqual(overflowing_issue["available_height"], 80)
        self.assertEqual(overflowing_issue["overflow"], 30)
        self.assertIn('wrap="true" autoFit="normal-auto-fit"', overflowing_issue["message"])

    def test_lint_xml_uses_fixed_line_spacing_for_text_height_warning(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="fixed-overflow" type="text" topLeftX="80" topLeftY="80" width="360" height="50">
                  <content fontSize="20" lineSpacing="fixed:20" autoFit="no-auto-fit">
                    <p>第一段</p><p>第二段</p><p>第三段</p>
                  </content>
                </shape>
              </data>
            </slide>
            """
        )
        issue = result["slides"][0]["issues"][0]
        self.assertEqual(result["summary"]["warning_count"], 1)
        self.assertEqual(result["summary"]["error_count"], 0)
        self.assertEqual(issue["level"], "warning")
        self.assertEqual(issue["line_height"], 20)
        self.assertEqual(issue["estimated_height"], 60)
        self.assertEqual(issue["overflow"], 10)

    def test_lint_xml_ignores_subpixel_text_height_overflow_tolerance(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="minor-overflow" type="text" topLeftX="80" topLeftY="80" width="360" height="39.8">
                  <content fontSize="20" lineSpacing="fixed:20" autoFit="no-auto-fit">
                    <p>第一段</p><p>第二段</p>
                  </content>
                </shape>
              </data>
            </slide>
            """
        )
        overflow_issues = [
            issue
            for issue in result["slides"][0]["issues"]
            if issue["code"] == "text_may_overflow_shape"
        ]
        self.assertEqual(overflow_issues, [])

    def test_lint_xml_allows_single_line_width_estimation_jitter(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="metric" type="text" topLeftX="80" topLeftY="80" width="152" height="54">
                  <content fontSize="36" lineSpacing="multiple:1.2" autoFit="no-auto-fit"><p>4.16万亿</p></content>
                </shape>
              </data>
            </slide>
            """
        )
        overflow_issues = [
            issue
            for issue in result["slides"][0]["issues"]
            if issue["code"] == "text_may_overflow_shape"
        ]
        self.assertEqual(overflow_issues, [])

    def test_lint_xml_allows_short_metric_text_with_separators_as_single_line(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="metric" type="text" topLeftX="80" topLeftY="80" width="150" height="50">
                  <content textType="title" fontSize="36" autoFit="no-auto-fit"><p>4.16万亿</p></content>
                </shape>
                <shape id="table-number" type="text" topLeftX="80" topLeftY="160" width="25" height="20">
                  <content fontSize="10" textAlign="center" autoFit="no-auto-fit"><p>1,380</p></content>
                </shape>
              </data>
            </slide>
            """
        )
        overflow_issues = [
            issue
            for issue in result["slides"][0]["issues"]
            if issue["code"] == "text_may_overflow_shape"
        ]
        self.assertEqual(overflow_issues, [])

    def test_lint_xml_reports_plain_short_metric_when_it_wraps(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="plain-age" type="text" topLeftX="80" topLeftY="80" width="50" height="80">
                  <content textType="title" fontSize="36" bold="true" autoFit="no-auto-fit"><p>82岁</p></content>
                </shape>
              </data>
            </slide>
            """
        )
        overflow_issues = [
            issue
            for issue in result["slides"][0]["issues"]
            if issue["code"] == "text_may_overflow_shape"
        ]
        self.assertEqual(len(overflow_issues), 1)
        self.assertEqual(overflow_issues[0]["elements"], ["plain-age"])

    def test_lint_xml_allows_centered_short_label_near_fit_as_single_line(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="centered-label" type="text" topLeftX="80" topLeftY="80" width="200" height="30">
                  <content fontSize="14" bold="true" textAlign="center" autoFit="no-auto-fit">
                    <p>参数服务器 (Parameter Server)</p>
                  </content>
                </shape>
              </data>
            </slide>
            """
        )
        overflow_issues = [
            issue
            for issue in result["slides"][0]["issues"]
            if issue["code"] == "text_may_overflow_shape"
        ]
        self.assertEqual(overflow_issues, [])

    def test_lint_xml_allows_headline_near_fit_as_single_line(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="headline" type="text" topLeftX="80" topLeftY="80" width="700" height="50">
                  <content textType="headline" fontSize="26" bold="true" lineSpacing="multiple:1.3" autoFit="no-auto-fit">
                    <p>全球半导体市场规模持续高速增长,AI驱动新一轮景气周期</p>
                  </content>
                </shape>
              </data>
            </slide>
            """
        )
        overflow_issues = [
            issue
            for issue in result["slides"][0]["issues"]
            if issue["code"] == "text_may_overflow_shape"
        ]
        self.assertEqual(overflow_issues, [])

    def test_lint_xml_allows_dense_body_line_spacing_estimation_slack(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="dense-body" type="text" topLeftX="80" topLeftY="80" width="360" height="140">
                  <content fontSize="13" bold="true" lineSpacing="multiple:1.7" autoFit="no-auto-fit">
                    <p>总体目标:</p>
                    <p>建立深度神经网络高效训练的统一理论框架,实现训练效率与模型性能的协同优化。</p>
                    <p>具体目标:</p>
                    <p>提出自适应优化算法,收敛速度提升 2-3 倍</p>
                    <p>实现结构化压缩方法,模型体积减少 10 倍以上</p>
                    <p>构建分布式训练策略,64 GPU 加速比 &gt; 50x</p>
                  </content>
                </shape>
              </data>
            </slide>
            """
        )
        overflow_issues = [
            issue
            for issue in result["slides"][0]["issues"]
            if issue["code"] == "text_may_overflow_shape"
        ]
        self.assertEqual(overflow_issues, [])

    def test_lint_xml_reports_dense_body_when_adjusted_height_still_overflows(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="dense-body" type="text" topLeftX="80" topLeftY="80" width="360" height="100">
                  <content fontSize="13" bold="true" lineSpacing="multiple:1.7" autoFit="no-auto-fit">
                    <p>总体目标:</p>
                    <p>建立深度神经网络高效训练的统一理论框架,实现训练效率与模型性能的协同优化。</p>
                    <p>具体目标:</p>
                    <p>提出自适应优化算法,收敛速度提升 2-3 倍</p>
                    <p>实现结构化压缩方法,模型体积减少 10 倍以上</p>
                    <p>构建分布式训练策略,64 GPU 加速比 &gt; 50x</p>
                  </content>
                </shape>
              </data>
            </slide>
            """
        )
        overflow_issues = [
            issue
            for issue in result["slides"][0]["issues"]
            if issue["code"] == "text_may_overflow_shape"
        ]
        self.assertEqual(len(overflow_issues), 1)
        self.assertEqual(overflow_issues[0]["elements"], ["dense-body"])

    def test_lint_xml_reports_letter_spaced_caption_near_fit(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="caption" type="text" topLeftX="80" topLeftY="80" width="120" height="20">
                  <content textType="caption" fontSize="11" letterSpacing="1" autoFit="no-auto-fit">
                    <p>RISKS &amp; CHALLENGES</p>
                  </content>
                </shape>
              </data>
            </slide>
            """
        )
        overflow_issues = [
            issue
            for issue in result["slides"][0]["issues"]
            if issue["code"] == "text_may_overflow_shape"
        ]
        self.assertEqual(len(overflow_issues), 1)
        self.assertEqual(overflow_issues[0]["elements"], ["caption"])

    def test_lint_xml_reports_micro_caption_when_wrapping_overflows(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="micro-caption" type="text" topLeftX="80" topLeftY="60" width="200" height="16">
                  <content textType="caption" fontSize="3" lineSpacing="multiple:1.3" letterSpacing="160" autoFit="no-auto-fit">
                    <p>MARKET INSIGHT · 市场洞察</p>
                  </content>
                </shape>
              </data>
            </slide>
            """
        )
        overflow_issues = [
            issue
            for issue in result["slides"][0]["issues"]
            if issue["code"] == "text_may_overflow_shape"
        ]
        self.assertEqual(len(overflow_issues), 1)
        self.assertEqual(overflow_issues[0]["elements"], ["micro-caption"])

    def test_lint_xml_text_may_overflow_shape_upgrades_to_error_above_threshold(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="just-warning" type="text" topLeftX="80" topLeftY="80" width="360" height="50">
                  <content fontSize="20" lineSpacing="fixed:20" autoFit="no-auto-fit">
                    <p>第一段</p><p>第二段</p><p>第三段</p>
                  </content>
                </shape>
                <shape id="error-overflow" type="text" topLeftX="80" topLeftY="200" width="360" height="30">
                  <content fontSize="20" lineSpacing="fixed:20" autoFit="no-auto-fit">
                    <p>第一段</p><p>第二段</p><p>第三段</p>
                  </content>
                </shape>
              </data>
            </slide>
            """
        )
        issues = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
        self.assertEqual(issues["just-warning"]["level"], "warning")
        self.assertEqual(issues["just-warning"]["overflow"], 10)
        self.assertEqual(issues["error-overflow"]["level"], "error")
        self.assertEqual(issues["error-overflow"]["overflow"], 30)
        self.assertEqual(result["summary"]["error_count"], 1)
        self.assertEqual(result["summary"]["warning_count"], 1)

    def test_lint_xml_text_may_overflow_shape_downgrades_background_decoration_to_info(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="bg-deco" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
                  <content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
                </shape>
                <shape id="foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60">
                  <content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
                </shape>
              </data>
            </slide>
            """
        )
        issues = {
            issue["elements"][0]: issue
            for issue in result["slides"][0]["issues"]
            if issue["code"] == "text_may_overflow_shape"
        }
        self.assertEqual(issues["bg-deco"]["level"], "info")
        self.assertEqual(result["summary"]["warning_count"], 0)
        self.assertEqual(result["summary"]["info_count"], 1)
        self.assertEqual(result["slides"][0]["infos"], [issues["bg-deco"]])
        self.assertIn("background decoration", issues["bg-deco"]["message"])

    def test_lint_xml_reports_shape_alpha_ghost_text_out_of_canvas_but_allows_overlap(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="ghost-number" type="text" topLeftX="-60" topLeftY="30" width="360" height="180" alpha="0.2">
                  <content fontSize="160" lineSpacing="fixed:160" wrap="false"><p>01</p></content>
                </shape>
                <shape id="title" type="text" topLeftX="80" topLeftY="80" width="360" height="80">
                  <content fontSize="30" lineSpacing="fixed:36"><p>Annual Review</p></content>
                </shape>
              </data>
            </slide>
            """
        )
        codes = [issue["code"] for issue in result["slides"][0]["issues"]]
        self.assertEqual(result["summary"]["error_count"], 1)
        self.assertIn("shape_out_of_canvas", codes)
        self.assertNotIn("bbox_overlap", codes)

    def test_lint_xml_reports_content_color_alpha_ghost_text_out_of_canvas_but_allows_overlap(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="ghost-year" type="text" topLeftX="760" topLeftY="20" width="260" height="160">
                  <content fontSize="140" color="rgba(0,0,0,0.2)" lineSpacing="fixed:140" wrap="false"><p>2026</p></content>
                </shape>
                <shape id="headline" type="text" topLeftX="700" topLeftY="70" width="220" height="80">
                  <content fontSize="28" lineSpacing="fixed:34"><p>Forecast</p></content>
                </shape>
              </data>
            </slide>
            """
        )
        codes = [issue["code"] for issue in result["slides"][0]["issues"]]
        self.assertEqual(result["summary"]["error_count"], 1)
        self.assertIn("shape_out_of_canvas", codes)
        self.assertNotIn("bbox_overlap", codes)

    def test_lint_xml_reports_faint_medium_ghost_text_out_of_canvas_but_allows_overlap(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="medium-ghost" type="text" topLeftX="820" topLeftY="300" width="270" height="72" alpha="0.32">
                  <content fontSize="40" lineSpacing="fixed:40" wrap="false"><p>OFF EDGE</p></content>
                </shape>
                <shape id="caption" type="text" topLeftX="760" topLeftY="315" width="180" height="36">
                  <content fontSize="16" lineSpacing="fixed:20"><p>Readable caption</p></content>
                </shape>
              </data>
            </slide>
            """
        )
        codes = [issue["code"] for issue in result["slides"][0]["issues"]]
        self.assertEqual(result["summary"]["error_count"], 1)
        self.assertIn("shape_out_of_canvas", codes)
        self.assertNotIn("bbox_overlap", codes)

    def test_lint_xml_allows_ghost_text_image_overlap(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="ghost-label" type="text" topLeftX="100" topLeftY="40" width="560" height="160" alpha="0.2">
                  <content fontSize="120" lineSpacing="fixed:120" wrap="false"><p>2026</p></content>
                </shape>
                <img id="photo" src="token" topLeftX="160" topLeftY="70" width="260" height="160"/>
                <shape id="title" type="text" topLeftX="610" topLeftY="95" width="320" height="60">
                  <content fontSize="28" lineSpacing="fixed:34"><p>Annual Review</p></content>
                </shape>
              </data>
            </slide>
            """
        )
        codes = [issue["code"] for issue in result["slides"][0]["issues"]]
        self.assertNotIn("image_covers_text", codes)
        self.assertNotIn("bbox_overlap", codes)

    def test_lint_slide_allows_ghost_text_whiteboard_overlap(self) -> None:
        result = xml_lint.lint_slide(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <whiteboard id="board" topLeftX="180" topLeftY="70" width="420" height="300"/>
                <shape id="ghost-label" type="text" topLeftX="100" topLeftY="40" width="560" height="160" alpha="0.2">
                  <content fontSize="120" lineSpacing="fixed:120" wrap="false"><p>2026</p></content>
                </shape>
                <shape id="title" type="text" topLeftX="610" topLeftY="95" width="220" height="60">
                  <content fontSize="28" lineSpacing="fixed:34"><p>Annual Review</p></content>
                </shape>
              </data>
            </slide>
            """,
            1,
        )
        codes = [issue["code"] for issue in result["issues"]]
        self.assertNotIn("whiteboard_external_overlap", codes)

    def test_lint_xml_reports_faint_ghost_text_out_of_canvas_without_area_threshold(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="small-ghost" type="text" topLeftX="940" topLeftY="300" width="40" height="40" alpha="0.32">
                  <content fontSize="36" lineSpacing="fixed:36" wrap="false"><p>土</p></content>
                </shape>
              </data>
            </slide>
            """
        )
        self.assertEqual(result["summary"]["error_count"], 1)
        self.assertEqual(result["slides"][0]["issues"][0]["code"], "shape_out_of_canvas")

    def test_lint_xml_keeps_out_of_canvas_error_for_medium_text_without_faint_alpha(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="medium-not-ghost" type="text" topLeftX="820" topLeftY="300" width="270" height="72" alpha="0.36">
                  <content fontSize="54" lineSpacing="fixed:54" wrap="false"><p>OFF EDGE</p></content>
                </shape>
              </data>
            </slide>
            """
        )
        self.assertEqual(result["summary"]["error_count"], 1)
        self.assertEqual(result["slides"][0]["issues"][0]["code"], "shape_out_of_canvas")
        self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["medium-not-ghost"])

    def test_lint_xml_keeps_out_of_canvas_error_for_half_alpha_large_text(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="half-alpha" type="text" topLeftX="760" topLeftY="20" width="260" height="160">
                  <content fontSize="140" color="rgba(0,0,0,0.5)" lineSpacing="fixed:140" wrap="false"><p>2026</p></content>
                </shape>
              </data>
            </slide>
            """
        )
        self.assertEqual(result["summary"]["error_count"], 1)
        self.assertEqual(result["slides"][0]["issues"][0]["code"], "shape_out_of_canvas")
        self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["half-alpha"])

    def test_lint_xml_text_may_overflow_shape_keeps_error_when_alpha_not_low(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="opaque-big" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.9">
                  <content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
                </shape>
                <shape id="foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60">
                  <content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
                </shape>
              </data>
            </slide>
            """
        )
        issue = next(
            issue
            for issue in result["slides"][0]["issues"]
            if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["opaque-big"]
        )
        self.assertEqual(issue["level"], "error")

    def test_lint_xml_text_may_overflow_shape_keeps_error_when_no_foreground_text(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="lonely-big" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
                  <content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
                </shape>
              </data>
            </slide>
            """
        )
        issue = next(
            issue
            for issue in result["slides"][0]["issues"]
            if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["lonely-big"]
        )
        self.assertEqual(issue["level"], "error")

    def test_lint_xml_text_may_overflow_shape_keeps_error_when_foreground_alpha_zero(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="bg-deco" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
                  <content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
                </shape>
                <shape id="transparent-foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60" alpha="0">
                  <content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
                </shape>
              </data>
            </slide>
            """
        )
        issue = next(
            issue
            for issue in result["slides"][0]["issues"]
            if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["bg-deco"]
        )
        self.assertEqual(issue["level"], "error")

    def test_lint_xml_text_may_overflow_shape_keeps_error_when_foreground_is_below_in_order(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60">
                  <content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
                </shape>
                <shape id="top-big" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
                  <content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
                </shape>
              </data>
            </slide>
            """
        )
        issue = next(
            issue
            for issue in result["slides"][0]["issues"]
            if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["top-big"]
        )
        self.assertEqual(issue["level"], "error")

    def test_lint_xml_uses_paragraph_spacing_overrides_for_text_height_warning(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="paragraph-overflow" type="text" topLeftX="80" topLeftY="80" width="360" height="30">
                  <content fontSize="20" lineSpacing="multiple:1.5" autoFit="no-auto-fit">
                    <p lineSpacing="fixed:10" beforeLineSpacing="fixed:5" afterLineSpacing="fixed:5">第一行<br/>第二行</p>
                  </content>
                </shape>
                <shape id="paragraph-fitting" type="text" topLeftX="480" topLeftY="80" width="360" height="40">
                  <content fontSize="20" lineSpacing="multiple:1.5">
                    <p lineSpacing="fixed:10">第一行<br/>第二行<br/>第三行</p>
                  </content>
                </shape>
              </data>
            </slide>
            """
        )
        issues = result["slides"][0]["issues"]
        self.assertEqual(result["summary"]["warning_count"], 1)
        self.assertEqual(issues[0]["elements"], ["paragraph-overflow"])
        self.assertEqual(issues[0]["line_count"], 2)
        self.assertEqual(issues[0]["line_height"], 10)
        self.assertEqual(issues[0]["estimated_height"], 40)
        self.assertEqual(issues[0]["overflow"], 10)

    def test_lint_xml_uses_letter_spacing_for_text_overflow_warning(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="baseline" type="text" topLeftX="0" topLeftY="0" width="120" height="30">
                  <content fontSize="20" lineSpacing="multiple:1.5" autoFit="no-auto-fit"><p>一二三四五六</p></content>
                </shape>
                <shape id="content-spaced" type="text" topLeftX="200" topLeftY="0" width="120" height="30">
                  <content fontSize="20" lineSpacing="multiple:1.5" letterSpacing="2" autoFit="no-auto-fit"><p>一二三四五六</p></content>
                </shape>
                <shape id="paragraph-spaced" type="text" topLeftX="400" topLeftY="0" width="120" height="30">
                  <content fontSize="20" lineSpacing="multiple:1.5" autoFit="no-auto-fit"><p letterSpacing="2">一二三四五六</p></content>
                </shape>
              </data>
            </slide>
            """
        )
        issues = result["slides"][0]["issues"]
        overflow_ids = [issue["elements"][0] for issue in issues if issue["code"] == "text_may_overflow_shape"]
        self.assertNotIn("baseline", overflow_ids)
        self.assertIn("content-spaced", overflow_ids)
        self.assertIn("paragraph-spaced", overflow_ids)
        by_id = {issue["elements"][0]: issue for issue in issues if issue["code"] == "text_may_overflow_shape"}
        self.assertEqual(by_id["content-spaced"]["line_count"], 2)
        self.assertEqual(by_id["content-spaced"]["estimated_height"], 50)
        self.assertEqual(by_id["content-spaced"]["overflow"], 20)
        self.assertEqual(by_id["paragraph-spaced"]["line_count"], 2)

    def test_strip_xml_paragraphs_preserves_br_as_hard_line_break(self) -> None:
        self.assertEqual(
            xml_lint.strip_xml_paragraphs("<p>第一行<br/>第二行<br />第三行</p>"),
            "第一行\n第二行\n第三行",
        )

    def test_lint_xml_allows_template_style_images_outside_canvas(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide xmlns="https://www.larkoffice.com/sml/2.0">
                <data>
                  <img src="tok" topLeftX="-120" topLeftY="20" width="360" height="360"/>
                  <shape type="text" topLeftX="40" topLeftY="80" width="180" height="80">
                    <content textType="title" fontSize="44"><p>Title</p></content>
                  </shape>
                  <shape type="text" topLeftX="40" topLeftY="120" width="180" height="40">
                    <content textType="sub-headline" fontSize="20"><p>Subtitle</p></content>
                  </shape>
                </data>
              </slide>
            </presentation>
            """
        )
        self.assertEqual(result["summary"]["error_count"], 0)
        self.assertEqual(result["summary"]["warning_count"], 0)

    def test_extract_elements_preserves_supported_element_geometry_order_and_text_metadata(self) -> None:
        elements = xml_lint.extract_elements(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <img id="photo" src="tok" topLeftX="10" topLeftY="20" width="100" height="80"/>
                <shape id="headline" type="text" topLeftX="40" topLeftY="60" width="320" height="90">
                  <content textType="headline" textAlign="center" autoFit="normal-auto-fit" fontSize="28">
                    <p><![CDATA[Growth & scale]]></p>
                    <p>Focused execution</p>
                  </content>
                </shape>
                <table id="table" topLeftX="400" topLeftY="60" width="220" height="120"></table>
                <chart id="chart" topLeftX="640" topLeftY="60" width="220" height="120"/>
                <whiteboard id="wb" topLeftX="80" topLeftY="220" width="760" height="240"/>
                <embed id="emb" topLeftX="600" topLeftY="320" width="240" height="140">
                  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 140"><rect x="0" y="0" width="240" height="140"/></svg>
                </embed>
                <shape id="missing-height" type="text" topLeftX="80" topLeftY="480" width="320">
                  <content><p>Skipped</p></content>
                </shape>
              </data>
            </slide>
            """
        )
        self.assertEqual([element["id"] for element in elements], ["photo", "headline", "table", "chart", "wb", "emb"])
        self.assertEqual([element["kind"] for element in elements], ["img", "shape", "table", "chart", "whiteboard", "embed"])
        self.assertEqual([element["order"] for element in elements], [0, 1, 2, 3, 4, 5])
        self.assertEqual(elements[1]["type"], "text")
        self.assertEqual(elements[1]["textType"], "headline")
        self.assertEqual(elements[1]["textAlign"], "center")
        self.assertEqual(elements[1]["autoFit"], "normal-auto-fit")
        self.assertEqual(elements[1]["fontSize"], 28)
        self.assertEqual(elements[1]["text"], "Growth & scale\nFocused execution")

    def test_lint_xml_ignores_small_out_of_bounds_images(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide xmlns="https://www.larkoffice.com/sml/2.0">
                <data>
                  <img src="tok" topLeftX="-20" topLeftY="20" width="120" height="120"/>
                </data>
              </slide>
            </presentation>
            """
        )
        self.assertEqual(result["summary"]["error_count"], 0)

    def test_lint_xml_ignores_out_of_canvas_images(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide xmlns="https://www.larkoffice.com/sml/2.0">
                <data>
                  <img src="right" topLeftX="780" topLeftY="0" width="500" height="540"/>
                  <img src="bottom" topLeftX="0" topLeftY="430" width="900" height="280"/>
                </data>
              </slide>
            </presentation>
            """
        )
        self.assertEqual(result["summary"]["error_count"], 0)

    def test_lint_xml_ignores_full_bleed_images_outside_canvas(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide xmlns="https://www.larkoffice.com/sml/2.0">
                <data>
                  <img src="tok" topLeftX="-80" topLeftY="-20" width="1080" height="600"/>
                </data>
              </slide>
            </presentation>
            """
        )
        self.assertEqual(result["summary"]["error_count"], 0)

    def test_lint_xml_reports_text_and_chart_but_not_image_out_of_canvas(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide xmlns="https://www.larkoffice.com/sml/2.0">
                <data>
                  <shape id="outside-shape" type="text" topLeftX="-10" topLeftY="40" width="50" height="50"/>
                  <img id="outside-img" src="token" topLeftX="120" topLeftY="-20" width="50" height="50"/>
                  <chart id="outside-chart" topLeftX="900" topLeftY="100" width="100" height="100">
                    <chartPlotArea><chartPlot type="line"/></chartPlotArea>
                    <chartData>
                      <dim1><chartField name="category" valueType="string">A</chartField></dim1>
                      <dim2><chartField name="value" valueType="number">1</chartField></dim2>
                    </chartData>
                  </chart>
                </data>
              </slide>
            </presentation>
            """
        )
        issues = result["slides"][0]["issues"]
        self.assertEqual(result["summary"]["error_count"], 2)
        self.assertEqual(
            [(issue["code"], issue["elements"], issue["overflow"]) for issue in issues],
            [
                ("shape_out_of_canvas", ["outside-shape"], {"left": 10, "top": 0, "right": 0, "bottom": 0}),
                ("chart_out_of_canvas", ["outside-chart"], {"left": 0, "top": 0, "right": 40, "bottom": 0}),
            ],
        )

    def test_lint_xml_ignores_line_out_of_canvas(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="body" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
                  <content fontSize="18"><p>Visible content</p></content>
                </shape>
                <line id="connector" startX="80" startY="120" endX="980" endY="120"><border/></line>
              </data>
            </slide>
            """
        )

        self.assertEqual(result["summary"]["error_count"], 0)
        self.assertEqual(result["slides"][0]["issues"], [])

    def test_lint_xml_reports_horizontal_line_crossing_headline_glyphs(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="title" type="text" topLeftX="80" topLeftY="200" width="500" height="90">
                  <content fontSize="60"><p>测试文字 ABC</p></content>
                </shape>
                <line id="strike" startX="80" startY="245" endX="560" endY="245">
                  <border color="rgb(255, 0, 0)" width="4"/>
                </line>
              </data>
            </slide>
            """
        )
        crossing = [
            issue for issue in result["slides"][0]["errors"] if set(issue["elements"]) == {"strike", "title"}
        ]
        self.assertEqual(len(crossing), 1)
        self.assertEqual(crossing[0]["code"], "bbox_overlap")

    def test_lint_xml_reports_vertical_line_crossing_multiline_text(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="col" type="text" topLeftX="700" topLeftY="180" width="240" height="180">
                  <content fontSize="20"><p>第一行文字内容</p><p>第二行文字内容</p><p>第三行文字内容</p></content>
                </shape>
                <line id="vbar" startX="740" startY="170" endX="740" endY="360">
                  <border color="rgb(0, 0, 255)" width="3"/>
                </line>
              </data>
            </slide>
            """
        )
        crossing = [
            issue for issue in result["slides"][0]["errors"] if set(issue["elements"]) == {"vbar", "col"}
        ]
        self.assertEqual(len(crossing), 1)

    def test_lint_xml_reports_diagonal_line_crossing_text_block(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="para" type="text" topLeftX="80" topLeftY="400" width="420" height="140">
                  <content fontSize="18"><p>这是一段测试文字用于验证线条穿过</p></content>
                </shape>
                <line id="diag" startX="80" startY="410" endX="500" endY="530">
                  <border color="rgb(255, 0, 0)" width="3"/>
                </line>
              </data>
            </slide>
            """
        )
        crossing = [
            issue for issue in result["slides"][0]["errors"] if set(issue["elements"]) == {"diag", "para"}
        ]
        self.assertEqual(len(crossing), 1)

    def test_lint_xml_ignores_diagonal_line_whose_bbox_but_not_segment_crosses_text(self) -> None:
        # The diagonal's axis-aligned bounding box overlaps the text, but the segment itself passes
        # through empty space in the opposite corner -- a naive bbox test would false-positive here.
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="corner-text" type="text" topLeftX="80" topLeftY="80" width="120" height="40">
                  <content fontSize="18"><p>corner</p></content>
                </shape>
                <line id="far-diag" startX="700" startY="80" endX="90" endY="500">
                  <border color="rgb(255, 0, 0)" width="3"/>
                </line>
              </data>
            </slide>
            """
        )
        crossing = [
            issue for issue in result["slides"][0]["errors"] if set(issue["elements"]) == {"far-diag", "corner-text"}
        ]
        self.assertEqual(crossing, [])

    def test_lint_xml_ignores_line_touching_text_frame_but_not_glyphs(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="lbl" type="text" topLeftX="80" topLeftY="80" width="300" height="200">
                  <content fontSize="18" verticalAlign="top"><p>短标签</p></content>
                </shape>
                <line id="below" startX="80" startY="270" endX="380" endY="270">
                  <border color="rgb(255, 0, 0)" width="2"/>
                </line>
              </data>
            </slide>
            """
        )
        self.assertEqual(result["summary"]["error_count"], 0)

    def test_lint_xml_ignores_invisible_line_crossing_text(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="title" type="text" topLeftX="80" topLeftY="200" width="500" height="90">
                  <content fontSize="60"><p>测试文字 ABC</p></content>
                </shape>
                <line id="ghost-line" startX="80" startY="245" endX="560" endY="245">
                  <border color="rgba(255, 0, 0, 0.03)" width="4"/>
                </line>
              </data>
            </slide>
            """
        )
        self.assertEqual(result["summary"]["error_count"], 0)

    def test_lint_xml_ignores_vertical_line_grazing_text_left_edge(self) -> None:
        # Verbatim from deck GpGusGCwplQyK8dFN9LczmBXnwQ slide 4: a vertical line sitting on the text
        # frame's left edge renders before the first glyph, so it must not be flagged.
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape width="240" height="60" topLeftX="120" topLeftY="100" type="text" id="bmm">
                  <content fontSize="20" fontFamily="Arial" color="rgba(31, 35, 41, 1)" lineSpacing="fixed:24">
                    <p>Vertical edge graze</p>
                  </content>
                </shape>
                <line id="bmX" startX="120.00000000000001" startY="90" endX="120.00000000000001" endY="150.00833275470998">
                  <border color="rgba(0, 0, 0, 1)"/>
                </line>
              </data>
            </slide>
            """
        )
        self.assertEqual(result["summary"]["error_count"], 0)

    def test_lint_xml_ignores_polyline_crossing_text(self) -> None:
        # Verbatim from deck GpGusGCwplQyK8dFN9LczmBXnwQ slide 6: the crossing check is scoped to
        # <line> only, so a <polyline> over text is not flagged.
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape width="240" height="60" topLeftX="120" topLeftY="100" type="text" id="bmr">
                  <content fontSize="20" fontFamily="Arial" color="rgba(31, 35, 41, 1)" lineSpacing="fixed:24">
                    <p>Polyline target</p>
                  </content>
                </shape>
                <polyline id="bmH" width="270" height="55" topLeftX="110" topLeftY="95">
                  <border color="rgba(0, 0, 0, 1)"/>
                </polyline>
              </data>
            </slide>
            """
        )
        self.assertEqual(result["summary"]["error_count"], 0)

    def test_lint_xml_ignores_line_below_visual_glyph_height(self) -> None:
        # Verbatim from deck GpGusGCwplQyK8dFN9LczmBXnwQ slide 7: the shape frame is 80px tall but the
        # single 20px line of glyphs occupies only its top; a line at the frame's lower region grazes
        # under the visual glyph box (underline look) and must not be flagged.
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape width="240" height="80" topLeftX="120" topLeftY="100" type="text" id="bmB">
                  <content fontSize="20" fontFamily="Arial" color="rgba(31, 35, 41, 1)" lineSpacing="fixed:24">
                    <p>Visual height target</p>
                  </content>
                </shape>
                <line id="bmQ" startX="110" startY="150" endX="380.00185184550116" endY="150">
                  <border color="rgba(0, 0, 0, 1)"/>
                </line>
              </data>
            </slide>
            """
        )
        self.assertEqual(result["summary"]["error_count"], 0)

    def test_lint_xml_uses_rotated_text_and_chart_bounds_for_canvas_validation(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide xmlns="https://www.larkoffice.com/sml/2.0">
                <data>
                  <shape id="rotated-text" type="text" topLeftX="0" topLeftY="0" width="100" height="100" rotation="45"/>
                  <chart id="rotated-chart" topLeftX="860" topLeftY="200" width="100" height="100" rotation="45">
                    <chartPlotArea><chartPlot type="line"/></chartPlotArea>
                    <chartData>
                      <dim1><chartField name="category" valueType="string">A</chartField></dim1>
                      <dim2><chartField name="value" valueType="number">1</chartField></dim2>
                    </chartData>
                  </chart>
                </data>
              </slide>
            </presentation>
            """
        )
        issues_by_element = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
        self.assertEqual(result["summary"]["error_count"], 2)
        self.assertEqual(issues_by_element["rotated-text"]["code"], "shape_out_of_canvas")
        self.assertAlmostEqual(issues_by_element["rotated-text"]["overflow"]["left"], 20.710678, places=5)
        self.assertAlmostEqual(issues_by_element["rotated-text"]["overflow"]["top"], 20.710678, places=5)
        self.assertEqual(issues_by_element["rotated-chart"]["code"], "chart_out_of_canvas")
        self.assertAlmostEqual(issues_by_element["rotated-chart"]["overflow"]["right"], 20.710678, places=5)

    def test_lint_xml_uses_declared_bounds_for_rect_and_ignores_images(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide xmlns="https://www.larkoffice.com/sml/2.0">
                <data>
                  <shape id="rotated-rect" type="rect" topLeftX="900" topLeftY="0" width="100" height="100" rotation="45"/>
                  <img id="rotated-image" src="token" topLeftX="860" topLeftY="200" width="100" height="100" rotation="45"/>
                </data>
              </slide>
            </presentation>
            """
        )
        issues_by_element = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
        self.assertEqual(result["summary"]["error_count"], 1)
        self.assertEqual(issues_by_element["rotated-rect"]["code"], "shape_out_of_canvas")
        self.assertEqual(issues_by_element["rotated-rect"]["overflow"], {"left": 0, "top": 0, "right": 40, "bottom": 0})
        self.assertNotIn("rotated-image", issues_by_element)

    def test_detect_elements_out_of_canvas_limits_detection_to_whitelist(self) -> None:
        issues = xml_lint.detect_elements_out_of_canvas(
            [
                {"id": "table", "kind": "table", "x": 95, "y": 0, "width": 10, "height": 10, "rotation": 45},
                {"id": "chart", "kind": "chart", "x": 95, "y": 0, "width": 10, "height": 10, "rotation": 0},
                {
                    "id": "text",
                    "kind": "shape",
                    "type": "text",
                    "x": 95,
                    "y": 0,
                    "width": 10,
                    "height": 10,
                    "rotation": 0,
                },
                {
                    "id": "rect",
                    "kind": "shape",
                    "type": "rect",
                    "x": 95,
                    "y": 0,
                    "width": 10,
                    "height": 10,
                    "rotation": 45,
                },
                {"id": "image", "kind": "img", "x": 95, "y": 0, "width": 10, "height": 10, "rotation": 0},
                {
                    "id": "ellipse",
                    "kind": "shape",
                    "type": "ellipse",
                    "x": 95,
                    "y": 0,
                    "width": 10,
                    "height": 10,
                    "rotation": 0,
                },
            ],
            100,
            100,
        )

        self.assertEqual([issue["elements"] for issue in issues], [["table"], ["chart"], ["text"], ["rect"]])
        self.assertEqual(issues[-1]["bbox"], {"x": 95, "y": 0, "width": 10, "height": 10})

    def test_lint_xml_rejects_non_finite_rotation_values_from_xsd(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide xmlns="https://www.larkoffice.com/sml/2.0">
                <data>
                  <shape id="infinite" type="text" topLeftX="-10" topLeftY="0" width="20" height="20" rotation="inf"/>
                  <shape id="negative-infinite" type="text" topLeftX="0" topLeftY="-10" width="20" height="20" rotation="-inf"/>
                  <chart id="not-a-number" topLeftX="950" topLeftY="0" width="20" height="20" rotation="nan">
                    <chartPlotArea><chartPlot type="line"/></chartPlotArea>
                    <chartData>
                      <dim1><chartField name="category" valueType="string">A</chartField></dim1>
                      <dim2><chartField name="value" valueType="number">1</chartField></dim2>
                    </chartData>
                  </chart>
                </data>
              </slide>
            </presentation>
            """
        )
        self.assertEqual(result["summary"]["error_count"], 3)
        slide_issues = result["slides"][0]["issues"]
        self.assertTrue(all(issue["code"] == "sxsd_invalid_scalar" for issue in slide_issues))
        self.assertEqual({issue["actual"] for issue in slide_issues}, {"inf", "-inf", "nan"})

    def test_lint_xml_reports_table_bottom_overflow_from_declared_bounds(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide xmlns="https://www.larkoffice.com/sml/2.0">
                <data>
                  <table id="score-table" topLeftX="54" topLeftY="238" width="414" height="385">
                    <tr><td><content><p>Score</p></content></td></tr>
                  </table>
                </data>
              </slide>
            </presentation>
            """
        )
        issue = result["slides"][0]["issues"][0]
        self.assertEqual(result["summary"]["error_count"], 1)
        self.assertEqual(issue["code"], "table_out_of_canvas")
        self.assertEqual(issue["elements"], ["score-table"])
        self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 0, "bottom": 83})
        self.assertEqual(issue["bbox"], {"x": 54, "y": 238, "width": 414, "height": 385})

    def test_lint_xml_reports_table_right_overflow_from_declared_bounds(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide xmlns="https://www.larkoffice.com/sml/2.0">
                <data>
                  <table id="wide-table" topLeftX="850" topLeftY="80" width="180" height="120">
                    <tr><td><content><p>Score</p></content></td></tr>
                  </table>
                </data>
              </slide>
            </presentation>
            """
        )
        issue = result["slides"][0]["issues"][0]
        self.assertEqual(result["summary"]["error_count"], 1)
        self.assertEqual(issue["code"], "table_out_of_canvas")
        self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 70, "bottom": 0})

    def test_lint_xml_allows_table_with_declared_bounds_inside_canvas(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide xmlns="https://www.larkoffice.com/sml/2.0">
                <data>
                  <table id="inside-table" topLeftX="40" topLeftY="120" width="880" height="360">
                    <tr><td><content><p>Score</p></content></td></tr>
                  </table>
                </data>
              </slide>
            </presentation>
            """
        )
        self.assertEqual(result["summary"]["error_count"], 0)
        self.assertEqual(result["summary"]["warning_count"], 0)

    def test_lint_xml_reports_resolved_table_bounds_when_declared_sizes_are_missing(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide xmlns="https://www.larkoffice.com/sml/2.0">
                <data>
                  <table id="implicit-size-table" topLeftX="850" topLeftY="480">
                    <colgroup><col/><col/></colgroup>
                    <tr><td/><td/></tr>
                    <tr><td/><td/></tr>
                  </table>
                </data>
              </slide>
            </presentation>
            """
        )
        issue = result["slides"][0]["issues"][0]
        self.assertEqual(result["summary"]["error_count"], 1)
        self.assertEqual(issue["code"], "table_out_of_canvas")
        self.assertEqual(issue["bbox"], {"x": 850, "y": 480, "width": 220, "height": 74})
        self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 110, "bottom": 14})

    def test_lint_xml_xml_path_preserves_source_index_after_filtered_table(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="http://www.larkoffice.com/sml/2.0">
              <data>
                <table id="t1" topLeftX="20" topLeftY="20">
                  <tr><td/></tr>
                </table>
                <table id="t2" topLeftX="20" topLeftY="100" width="9999" height="100">
                  <tr><td/></tr>
                </table>
              </data>
            </slide>
            """
        )

        issue = next(
            issue
            for issue in result["slides"][0]["issues"]
            if issue["code"] == "table_out_of_canvas"
        )
        self.assertEqual(issue["element_ids"], ["t2"])
        self.assertEqual(
            issue["related_objects"][0]["xml_path"],
            "slide[1]/data/table[2]",
        )

    def test_lint_xml_duplicate_id_keeps_issue_bound_to_original_shape(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="dup" type="rect" topLeftX="-20" topLeftY="40" width="50" height="50"/>
                <shape id="dup" type="rect" topLeftX="100" topLeftY="40" width="50" height="50"/>
              </data>
            </slide>
            """
        )

        canvas_issue = next(
            issue for issue in result["slides"][0]["issues"]
            if issue["code"] == "shape_out_of_canvas"
        )
        self.assertEqual(canvas_issue["element_ids"], ["dup"])
        self.assertEqual(
            canvas_issue["related_objects"],
            [
                {
                    "element_id": "dup",
                    "kind": "shape",
                    "type": "rect",
                    "bbox": {"x": -20, "y": 40, "width": 50, "height": 50},
                    "xml_path": "slide[1]/data/shape[1]",
                }
            ],
        )
        self.assertTrue(
            canvas_issue["hint"].startswith(
                "Locate via related_objects[].xml_path. "
            )
        )
        duplicate_issue = next(
            issue for issue in result["slides"][0]["issues"]
            if issue["code"] == "duplicate_element_id"
        )
        self.assertEqual(
            duplicate_issue["hint"],
            "Locate via related_objects[].xml_path. "
            "Do not invent replacement IDs. For newly authored elements, remove the id attribute. "
            "When updating read-back XML, keep the server ID on the original element only and remove it "
            "from copied or new elements.",
        )
        self.assertEqual(duplicate_issue["element_ids"], ["dup", "dup"])
        self.assertEqual(
            [obj["xml_path"] for obj in duplicate_issue["related_objects"]],
            ["slide[1]/data/shape[1]", "slide[1]/data/shape[2]"],
        )

    def test_lint_xml_blocks_duplicate_table_cell_ids(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <table id="table-1" topLeftX="80" topLeftY="80" width="800" height="120">
                  <colgroup><col width="400"/><col width="400"/></colgroup>
                  <tr height="120">
                    <td id="bjs"><content fontSize="24"><p>Original cell</p></content></td>
                    <td id="bjs"><content fontSize="24"><p>Copied cell</p></content></td>
                  </tr>
                </table>
              </data>
            </slide>
            """
        )

        issue = next(
            issue
            for issue in result["slides"][0]["issues"]
            if issue["code"] == "duplicate_element_id"
        )
        self.assertFalse(result["summary"]["release_ready"])
        self.assertEqual(issue["element_ids"], ["bjs", "bjs"])
        self.assertEqual(
            issue["related_objects"],
            [
                {
                    "element_id": "bjs",
                    "kind": "td",
                    "type": "td",
                    "xml_path": "slide[1]/data/table[1]/tr[1]/td[1]",
                },
                {
                    "element_id": "bjs",
                    "kind": "td",
                    "type": "td",
                    "xml_path": "slide[1]/data/table[1]/tr[1]/td[2]",
                },
            ],
        )

    def test_lint_xml_blocks_duplicate_id_shared_by_shape_and_table_cell(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="baa" type="rect" topLeftX="40" topLeftY="40" width="80" height="80"/>
                <table id="table-1" topLeftX="160" topLeftY="40" width="200" height="80">
                  <tr height="80"><td id="baa"><content fontSize="12"><p>Cell</p></content></td></tr>
                </table>
              </data>
            </slide>
            """
        )

        issue = next(
            issue
            for issue in result["slides"][0]["issues"]
            if issue["code"] == "duplicate_element_id"
        )
        self.assertEqual(issue["element_ids"], ["baa", "baa"])
        self.assertEqual(
            [obj["xml_path"] for obj in issue["related_objects"]],
            ["slide[1]/data/shape[1]", "slide[1]/data/table[1]/tr[1]/td[1]"],
        )

    def test_lint_xml_blocks_duplicate_id_shared_by_shape_and_undefined(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="dup" type="rect" topLeftX="40" topLeftY="40" width="80" height="80"/>
                <undefined id="dup" type="video"/>
              </data>
            </slide>
            """
        )

        issue = next(
            issue
            for issue in result["slides"][0]["issues"]
            if issue["code"] == "duplicate_element_id"
        )
        self.assertFalse(result["summary"]["release_ready"])
        self.assertEqual(issue["element_ids"], ["dup", "dup"])
        self.assertEqual(
            issue["related_objects"],
            [
                {
                    "element_id": "dup",
                    "kind": "shape",
                    "type": "rect",
                    "bbox": {"x": 40, "y": 40, "width": 80, "height": 80},
                    "xml_path": "slide[1]/data/shape[1]",
                },
                {
                    "element_id": "dup",
                    "kind": "undefined",
                    "type": "video",
                    "xml_path": "slide[1]/data/undefined[1]",
                },
            ],
        )

    def test_lint_xml_does_not_report_unique_table_cell_and_note_ids(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <table id="table-1" topLeftX="80" topLeftY="80" width="800" height="120">
                  <colgroup><col width="400"/><col width="400"/></colgroup>
                  <tr height="120"><td id="baa"/><td id="bab"/></tr>
                </table>
              </data>
              <note id="bac"><content fontSize="12"><p>Note</p></content></note>
            </slide>
            """
        )

        self.assertNotIn(
            "duplicate_element_id",
            [issue["code"] for issue in result["slides"][0]["issues"]],
        )

    def test_lint_xml_blocks_duplicate_ids_across_slides(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide>
                <data>
                  <shape id="dup" type="rect" topLeftX="40" topLeftY="40" width="80" height="80"/>
                </data>
              </slide>
              <slide>
                <data>
                  <shape id="dup" type="rect" topLeftX="140" topLeftY="40" width="80" height="80"/>
                </data>
              </slide>
            </presentation>
            """
        )

        issue = next(
            issue
            for issue in result["document"]["errors"]
            if issue["code"] == "duplicate_element_id"
        )
        self.assertFalse(result["summary"]["release_ready"])
        self.assertEqual(issue["element_ids"], ["dup", "dup"])
        self.assertEqual(
            issue["related_objects"],
            [
                {
                    "element_id": "dup",
                    "kind": "shape",
                    "type": "rect",
                    "bbox": {"x": 40, "y": 40, "width": 80, "height": 80},
                    "xml_path": "slide[1]/data/shape[1]",
                },
                {
                    "element_id": "dup",
                    "kind": "shape",
                    "type": "rect",
                    "bbox": {"x": 140, "y": 40, "width": 80, "height": 80},
                    "xml_path": "slide[2]/data/shape[1]",
                },
            ],
        )

    def test_lint_xml_does_not_treat_slide_ids_as_element_ids(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide id="dup"><data/></slide>
              <slide id="dup"><data/></slide>
            </presentation>
            """
        )

        self.assertNotIn(
            "duplicate_element_id",
            [issue["code"] for issue in result["document"]["errors"]],
        )

    def test_lint_xml_does_not_treat_presentation_id_as_element_id(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" id="dup" width="960" height="540">
              <slide>
                <data><undefined id="dup" type="video"/></data>
              </slide>
            </presentation>
            """
        )

        self.assertNotIn(
            "duplicate_element_id",
            [
                issue["code"]
                for issue in [
                    *result["document"]["errors"],
                    *result["slides"][0]["errors"],
                ]
            ],
        )

    def test_lint_xml_blocks_duplicate_note_ids_across_slides(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide>
                <note id="baa"><content fontSize="12"><p>First note</p></content></note>
              </slide>
              <slide>
                <note id="baa"><content fontSize="12"><p>Copied note</p></content></note>
              </slide>
            </presentation>
            """
        )

        issue = next(
            issue
            for issue in result["document"]["errors"]
            if issue["code"] == "duplicate_element_id"
        )
        self.assertFalse(result["summary"]["release_ready"])
        self.assertEqual(issue["element_ids"], ["baa", "baa"])
        self.assertEqual(
            issue["related_objects"],
            [
                {
                    "element_id": "baa",
                    "kind": "note",
                    "type": "note",
                    "xml_path": "slide[1]/note[1]",
                },
                {
                    "element_id": "baa",
                    "kind": "note",
                    "type": "note",
                    "xml_path": "slide[2]/note[1]",
                },
            ],
        )

    def test_lint_xml_cross_kind_duplicate_id_does_not_change_related_object_kind(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="dup" type="rect" topLeftX="-20" topLeftY="40" width="50" height="50"/>
                <img id="dup" src="token" topLeftX="100" topLeftY="40" width="50" height="50"/>
              </data>
            </slide>
            """
        )

        issue = next(
            issue for issue in result["slides"][0]["issues"]
            if issue["code"] == "shape_out_of_canvas"
        )
        self.assertEqual(issue["related_objects"][0]["kind"], "shape")
        self.assertEqual(
            issue["related_objects"][0]["xml_path"],
            "slide[1]/data/shape[1]",
        )
        duplicate_issue = next(
            issue
            for issue in result["slides"][0]["issues"]
            if issue["code"] == "duplicate_element_id"
        )
        self.assertEqual(
            [obj["xml_path"] for obj in duplicate_issue["related_objects"]],
            ["slide[1]/data/shape[1]", "slide[1]/data/img[1]"],
        )

    def test_normalize_issue_does_not_repeat_xml_path_hint_prefix(self) -> None:
        xml_path = "slide[1]/data/shape[1]"
        element = {
            "id": "box",
            "_source_id": "box",
            "_ref": xml_path,
            "xml_path": xml_path,
            "kind": "shape",
            "type": "rect",
            "x": 0,
            "y": 0,
            "width": 40,
            "height": 40,
        }
        prefix = "Locate via related_objects[].xml_path."

        issue = xml_lint.normalize_issue(
            {
                "level": "error",
                "code": "shape_out_of_canvas",
                "elements": [xml_path],
                "canvas": {"width": 960, "height": 540},
                "bbox": {"x": -10, "y": 0, "width": 40, "height": 40},
                "overflow": {"left": 10, "top": 0, "right": 0, "bottom": 0},
                "hint": f"{prefix} Move the shape inside the canvas.",
            },
            1,
            {xml_path: element},
        )

        self.assertEqual(issue["hint"].count(prefix), 1)

    def test_lint_xml_elements_keep_locator_for_anonymous_related_object(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape type="text" topLeftX="100" topLeftY="100" width="300" height="100">
                  <content fontSize="24"><p>Important text</p></content>
                </shape>
                <img id="srv-42" src="token" topLeftX="100" topLeftY="100" width="300" height="100"/>
              </data>
            </slide>
            """
        )

        issue = next(
            issue
            for issue in result["slides"][0]["issues"]
            if issue["code"] == "image_covers_text"
        )
        self.assertEqual(
            issue["elements"],
            ["srv-42", "slide[1]/data/shape[1]"],
        )
        self.assertEqual(issue["element_ids"], ["srv-42"])
        self.assertEqual(len(issue["related_objects"]), 2)

    def test_normalize_issue_deduplicates_repeated_element_refs(self) -> None:
        xml_path = "slide[1]/data/shape[1]"
        element = {
            "id": "srv-42",
            "_source_id": "srv-42",
            "_ref": xml_path,
            "xml_path": xml_path,
            "kind": "shape",
            "type": "rect",
            "x": 0,
            "y": 0,
            "width": 40,
            "height": 40,
        }

        issue = xml_lint.normalize_issue(
            {
                "level": "warning",
                "code": "blank_slide",
                "measurement": {
                    "visible_element_count": 0,
                    "declared_element_count": 1,
                },
                "elements": [xml_path, xml_path],
            },
            1,
            {xml_path: element},
        )

        self.assertEqual(issue["elements"], ["srv-42"])
        self.assertEqual(issue["element_ids"], ["srv-42"])
        self.assertEqual(len(issue["related_objects"]), 1)

    def test_lint_xml_reports_duplicate_ids_for_every_linted_element_kind(self) -> None:
        duplicate_pairs = {
            "shape": (
                '<shape id="dup" type="rect" topLeftX="10" topLeftY="10" width="40" height="40"/>',
                '<shape id="dup" type="rect" topLeftX="60" topLeftY="10" width="40" height="40"/>',
            ),
            "chart": (
                '<chart id="dup" topLeftX="10" topLeftY="10" width="40" height="40"><chartPlotArea><chartPlot type="line"/></chartPlotArea><chartData><dim1><chartField name="category" valueType="string">A</chartField></dim1><dim2><chartField name="value" valueType="number">1</chartField></dim2></chartData></chart>',
                '<chart id="dup" topLeftX="60" topLeftY="10" width="40" height="40"><chartPlotArea><chartPlot type="line"/></chartPlotArea><chartData><dim1><chartField name="category" valueType="string">A</chartField></dim1><dim2><chartField name="value" valueType="number">1</chartField></dim2></chartData></chart>',
            ),
            "table": (
                '<table id="dup" topLeftX="10" topLeftY="10" width="40" height="40"><colgroup><col width="40"/></colgroup><tr height="40"><td/></tr></table>',
                '<table id="dup" topLeftX="60" topLeftY="10" width="40" height="40"><colgroup><col width="40"/></colgroup><tr height="40"><td/></tr></table>',
            ),
            "img": (
                '<img id="dup" src="token" topLeftX="10" topLeftY="10" width="40" height="40"/>',
                '<img id="dup" src="token" topLeftX="60" topLeftY="10" width="40" height="40"/>',
            ),
            "line": (
                '<line id="dup" startX="10" startY="10" endX="40" endY="40"><border color="rgb(0, 0, 0)"/></line>',
                '<line id="dup" startX="60" startY="10" endX="90" endY="40"><border color="rgb(0, 0, 0)"/></line>',
            ),
            "icon": (
                '<icon id="dup" iconType="iconpark/Base/setting.svg" topLeftX="10" topLeftY="10" width="40" height="40"><fill><fillColor color="rgb(0, 0, 0)"/></fill></icon>',
                '<icon id="dup" iconType="iconpark/Base/setting.svg" topLeftX="60" topLeftY="10" width="40" height="40"><fill><fillColor color="rgb(0, 0, 0)"/></fill></icon>',
            ),
            "polyline": (
                '<polyline id="dup" topLeftX="10" topLeftY="10" width="40" height="40"><border color="rgb(0, 0, 0)"/></polyline>',
                '<polyline id="dup" topLeftX="60" topLeftY="10" width="40" height="40"><border color="rgb(0, 0, 0)"/></polyline>',
            ),
        }
        for kind, pair in duplicate_pairs.items():
            with self.subTest(kind=kind):
                result = xml_lint.lint_xml(
                    f'<slide xmlns="https://www.larkoffice.com/sml/2.0"><data>{pair[0]}{pair[1]}</data></slide>'
                )
                issue = next(
                    issue
                    for issue in result["slides"][0]["issues"]
                    if issue["code"] == "duplicate_element_id"
                )
                self.assertEqual(issue["element_ids"], ["dup", "dup"])
                self.assertEqual(
                    [obj["xml_path"] for obj in issue["related_objects"]],
                    [
                        f"slide[1]/data/{kind}[1]",
                        f"slide[1]/data/{kind}[2]",
                    ],
                )

    def test_lint_xml_missing_id_does_not_collide_with_explicit_synthetic_like_id(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape type="rect" topLeftX="-20" topLeftY="40" width="50" height="50"/>
                <shape id="shape-1" type="rect" topLeftX="100" topLeftY="40" width="50" height="50"/>
              </data>
            </slide>
            """
        )

        issue = next(
            issue for issue in result["slides"][0]["issues"]
            if issue["code"] == "shape_out_of_canvas"
        )
        self.assertEqual(issue["element_ids"], [])
        self.assertNotIn("element_id", issue["related_objects"][0])
        self.assertEqual(
            issue["related_objects"][0]["xml_path"],
            "slide[1]/data/shape[1]",
        )
        self.assertTrue(
            issue["hint"].startswith("Locate via related_objects[].xml_path. ")
        )
        self.assertNotIn(
            "duplicate_element_id",
            [candidate["code"] for candidate in result["slides"][0]["issues"]],
        )

    def test_lint_xml_empty_id_is_not_exposed_as_an_element_id(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="" type="rect" topLeftX="-20" topLeftY="40" width="50" height="50"/>
              </data>
            </slide>
            """
        )

        issue = next(
            issue
            for issue in result["slides"][0]["issues"]
            if issue["code"] == "shape_out_of_canvas"
        )
        self.assertEqual(issue["element_ids"], [])
        self.assertNotIn("element_id", issue["related_objects"][0])
        self.assertEqual(
            issue["related_objects"][0]["xml_path"],
            "slide[1]/data/shape[1]",
        )

    def test_lint_xml_uses_resolved_table_bounds_for_canvas_validation(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide xmlns="https://www.larkoffice.com/sml/2.0">
                <data>
                  <table id="resolved-overflow-table" topLeftX="800" topLeftY="80" width="100" height="40">
                    <colgroup><col width="100"/><col width="100"/></colgroup>
                    <tr height="40"><td/><td/></tr>
                  </table>
                </data>
              </slide>
            </presentation>
            """
        )
        issues = result["slides"][0]["issues"]
        canvas_issue = next(issue for issue in issues if issue["code"] == "table_out_of_canvas")
        mismatch_issue = next(issue for issue in issues if issue["code"] == "table_resolved_size_mismatch")
        self.assertEqual(result["summary"]["error_count"], 1)
        self.assertEqual(canvas_issue["bbox"], {"x": 800, "y": 80, "width": 200, "height": 40})
        self.assertEqual(canvas_issue["overflow"]["right"], 40)
        self.assertEqual(mismatch_issue["dimension"], "width")
        self.assertEqual(mismatch_issue["resolved_size"], canvas_issue["bbox"]["width"])

    def test_lint_xml_uses_the_same_anonymous_table_path_for_all_table_diagnostics(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide xmlns="https://www.larkoffice.com/sml/2.0">
                <data>
                  <shape id="title" type="text" topLeftX="40" topLeftY="40" width="200" height="40"/>
                  <img id="logo" src="token" topLeftX="40" topLeftY="100" width="40" height="40"/>
                  <table topLeftX="900" topLeftY="80" width="100" height="40">
                    <colgroup><col width="100"/><col width="100"/></colgroup>
                    <tr height="40"><td/><td/></tr>
                  </table>
                </data>
              </slide>
            </presentation>
            """
        )
        issues = result["slides"][0]["issues"]
        canvas_issue = next(issue for issue in issues if issue["code"] == "table_out_of_canvas")
        mismatch_issue = next(issue for issue in issues if issue["code"] == "table_resolved_size_mismatch")
        self.assertEqual(canvas_issue["elements"], ["slide[1]/data/table[1]"])
        self.assertEqual(mismatch_issue["elements"], ["slide[1]/data/table[1]"])
        self.assertEqual(
            canvas_issue["related_objects"][0]["xml_path"],
            "slide[1]/data/table[1]",
        )
        self.assertEqual(
            mismatch_issue["related_objects"][0]["xml_path"],
            "slide[1]/data/table[1]",
        )
        self.assertNotIn("element_id", canvas_issue["related_objects"][0])
        self.assertNotIn("element_id", mismatch_issue["related_objects"][0])

    def test_lint_xml_reports_info_when_table_target_size_resolves_larger_than_declared(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide xmlns="https://www.larkoffice.com/sml/2.0">
                <data>
                  <table id="size-mismatch" topLeftX="40" topLeftY="120" width="200" height="80">
                    <colgroup><col span="2" width="100"/><col width="50"/></colgroup>
                    <tr height="40"><td/><td/><td/></tr>
                    <tr height="60"><td/><td/><td/></tr>
                  </table>
                </data>
              </slide>
            </presentation>
            """
        )
        issues_by_dimension = {issue["dimension"]: issue for issue in result["slides"][0]["issues"]}
        self.assertEqual(result["summary"]["error_count"], 0)
        self.assertEqual(result["summary"]["warning_count"], 0)
        self.assertEqual(result["summary"]["info_count"], 2)
        self.assertEqual(issues_by_dimension["width"]["level"], "info")
        self.assertEqual(issues_by_dimension["width"]["code"], "table_resolved_size_mismatch")
        self.assertEqual(issues_by_dimension["width"]["resolved_sizes"], [100, 100, 50])
        self.assertEqual(issues_by_dimension["width"]["resolved_size"], 250)
        self.assertEqual(issues_by_dimension["height"]["resolved_sizes"], [40, 60])
        self.assertEqual(issues_by_dimension["height"]["resolved_size"], 100)

    def test_lint_xml_does_not_report_info_when_table_target_size_is_resolved_exactly(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide xmlns="https://www.larkoffice.com/sml/2.0">
                <data>
                  <table id="size-match" topLeftX="40" topLeftY="120" width="300" height="100">
                    <colgroup><col width="100"/><col/></colgroup>
                    <tr height="40"><td/><td/></tr>
                    <tr><td/><td/></tr>
                  </table>
                </data>
              </slide>
            </presentation>
            """
        )
        self.assertEqual(result["summary"]["error_count"], 0)
        self.assertEqual(result["summary"]["warning_count"], 0)
        self.assertEqual(result["slides"][0]["issues"], [])

    def test_lint_xml_keeps_resolved_table_sizes_positive_when_target_is_too_small(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide xmlns="https://www.larkoffice.com/sml/2.0">
                <data>
                  <table id="narrow-table" topLeftX="40" topLeftY="120" width="1">
                    <colgroup><col/><col/></colgroup>
                    <tr><td/><td/></tr>
                  </table>
                </data>
              </slide>
            </presentation>
            """
        )
        issue = result["slides"][0]["issues"][0]
        self.assertEqual(issue["dimension"], "width")
        self.assertEqual(issue["resolved_sizes"], [1, 1])
        self.assertEqual(issue["resolved_size"], 2)

    def test_fill_last_size_gap_preserves_target_when_positive_sizes_are_possible(self) -> None:
        final_sizes = xml_lint.fill_last_size_gap([10, 10], 3)
        self.assertEqual(final_sizes, [2, 1])
        self.assertEqual(sum(final_sizes), 3)

    def test_cli_reports_table_layout_size_info_for_weighted_min_layout_cases(self) -> None:
        cases = {
            "target-exact": (
                """
                <table topLeftX="40" topLeftY="120" width="360" height="150">
                  <colgroup><col width="100"/><col width="200"/></colgroup>
                  <tr height="40"><td/><td/></tr><tr height="60"><td/><td/></tr>
                </table>
                """,
                0,
            ),
            "declared-size-exceeds-target": (
                """
                <table topLeftX="40" topLeftY="120" width="200" height="80">
                  <colgroup><col span="2" width="100"/><col width="50"/></colgroup>
                  <tr height="40"><td/><td/><td/></tr><tr height="60"><td/><td/><td/></tr>
                </table>
                """,
                2,
            ),
            "remaining-space-insufficient": (
                """
                <table topLeftX="40" topLeftY="120" width="80" height="30">
                  <colgroup><col width="80"/><col/></colgroup>
                  <tr height="40"><td/><td/></tr><tr><td/><td/></tr>
                </table>
                """,
                2,
            ),
            "no-target-size": (
                """
                <table topLeftX="40" topLeftY="120">
                  <colgroup><col width="80"/><col/></colgroup>
                  <tr height="40"><td/><td/></tr><tr><td/><td/></tr>
                </table>
                """,
                0,
            ),
        }
        script_path = Path(xml_lint.__file__).resolve()
        with tempfile.TemporaryDirectory() as temp_dir:
            for name, (table_xml, expected_info_count) in cases.items():
                with self.subTest(case=name):
                    input_path = Path(temp_dir) / f"{name}.xml"
                    input_path.write_text(
                        f"""
                        <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
                          <slide xmlns="https://www.larkoffice.com/sml/2.0"><data>{table_xml}</data></slide>
                        </presentation>
                        """,
                        encoding="utf-8",
                    )
                    completed = subprocess.run(
                        [sys.executable, str(script_path), "--input", str(input_path)],
                        capture_output=True,
                        check=False,
                        text=True,
                    )
                    result = json.loads(completed.stdout)
                    self.assertEqual(completed.returncode, 0, completed.stderr)
                    self.assertEqual(result["summary"]["error_count"], 0)
                    self.assertEqual(result["summary"]["warning_count"], 0)
                    self.assertEqual(result["summary"]["info_count"], expected_info_count)
                    self.assertTrue(
                        all(issue["level"] == "info" for issue in result["slides"][0]["issues"]),
                        result["slides"][0]["issues"],
                    )

    def test_lint_xml_detects_invalid_template_text_stack_overlap(self) -> None:
        cases = [
            (
                "subtitle-too-high",
                """
                <shape type="text" topLeftX="40" topLeftY="80" width="240" height="90">
                  <content textType="title" fontSize="44"><p>Title</p></content>
                </shape>
                <shape type="text" topLeftX="40" topLeftY="90" width="240" height="80">
                  <content textType="sub-headline" fontSize="20"><p>Subtitle</p></content>
                </shape>
                """,
            ),
        ]
        for name, shapes in cases:
            with self.subTest(name=name):
                result = xml_lint.lint_xml(
                    f"""
                    <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
                      <slide xmlns="https://www.larkoffice.com/sml/2.0">
                        <data>{shapes}</data>
                      </slide>
                    </presentation>
                    """
                )
                self.assertEqual(result["summary"]["error_count"], 1)
                self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap")


    def test_lint_xml_reports_vertical_text_image_overlap_as_warning(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0"><data>
              <shape id="text" type="text" vert="vert" topLeftX="100" topLeftY="100" width="100" height="100">
                <content><p>Vertical</p></content>
              </shape>
              <img id="image" src="token" topLeftX="120" topLeftY="120" width="20" height="20"/>
            </data></slide>
            """
        )
        issue = next(issue for issue in result["slides"][0]["issues"] if issue["code"] == "image_may_cover_vertical_text")
        self.assertEqual(issue["level"], "info")
        self.assertEqual(result["summary"]["error_count"], 0)
        self.assertEqual(result["summary"]["info_count"], 1)

    def test_lint_xml_related_objects_include_source_xml_paths(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide>
                <data>
                  <shape type="text" topLeftX="80" topLeftY="80" width="400" height="60">
                    <content fontSize="24"><p>Control slide</p></content>
                  </shape>
                </data>
              </slide>
              <slide>
                <data>
                  <shape type="text" topLeftX="70" topLeftY="55" width="820" height="70">
                    <content fontSize="32"><p>shape-3 mapping experiment</p></content>
                  </shape>
                  <shape type="text" topLeftX="80" topLeftY="165" width="400" height="70">
                    <content fontSize="18"><p>First, preserve source order.</p></content>
                  </shape>
                  <shape type="text" topLeftX="80" topLeftY="315" width="400" height="64">
                    <content fontSize="26"><p>TARGET_SHAPE_THREE</p></content>
                  </shape>
                  <img src="token" topLeftX="80" topLeftY="305" width="400" height="110"/>
                </data>
              </slide>
            </presentation>
            """
        )

        issue = next(
            issue
            for issue in result["slides"][1]["issues"]
            if issue["code"] == "image_covers_text"
        )
        self.assertEqual(
            [(obj["kind"], obj["xml_path"]) for obj in issue["related_objects"]],
            [
                ("img", "slide[2]/data/img[1]"),
                ("shape", "slide[2]/data/shape[3]"),
            ],
        )
        self.assertTrue(
            all("element_id" not in obj for obj in issue["related_objects"])
        )

    def test_lint_xml_related_objects_include_line_xml_path(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="http://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="label" type="text" topLeftX="100" topLeftY="100" width="200" height="80">
                  <content fontSize="24"><p>Crossed text</p></content>
                </shape>
                <line id="connector" startX="80" startY="130" endX="330" endY="130">
                  <border color="rgb(15, 23, 42)" width="2"/>
                </line>
              </data>
            </slide>
            """
        )

        issue = next(
            issue
            for issue in result["slides"][0]["issues"]
            if issue["code"] == "bbox_overlap" and issue["elements"][0] == "connector"
        )
        self.assertEqual(
            {
                obj["element_id"]: obj["xml_path"]
                for obj in issue["related_objects"]
            },
            {
                "connector": "slide[1]/data/line[1]",
                "label": "slide[1]/data/shape[1]",
            },
        )


class XmlTextOverlapLintDensityTest(unittest.TestCase):
    def test_lint_xml_sparse_container_related_objects_include_icon_xml_path(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="http://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="card" type="rect" topLeftX="60" topLeftY="120" width="400" height="300"/>
                <icon id="visual" iconType="iconpark/Base/setting.svg" topLeftX="80" topLeftY="140" width="32" height="32">
                  <fill><fillColor color="rgb(37, 99, 235)"/></fill>
                </icon>
              </data>
            </slide>
            """
        )

        issue = next(
            issue
            for issue in result["slides"][0]["issues"]
            if issue["code"] == "sparse_container_content"
        )
        self.assertEqual(
            {
                obj["element_id"]: obj["xml_path"]
                for obj in issue["related_objects"]
            },
            {
                "card": "slide[1]/data/shape[1]",
                "visual": "slide[1]/data/icon[1]",
            },
        )

    def test_lint_xml_blocks_blank_slide(self) -> None:
        result = xml_lint.lint_xml(
            """
            <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540">
              <slide id="content-slide">
                <data>
                  <shape id="title" type="text" topLeftX="60" topLeftY="60" width="400" height="50">
                    <content fontSize="28"><p>Investment report</p></content>
                  </shape>
                </data>
              </slide>
              <slide id="blank-slide">
                <style><fill><fillColor color="rgba(255, 255, 255, 1)"/></fill></style>
                <data/>
                <note><content/></note>
              </slide>
            </presentation>
            """
        )

        self.assertEqual(result["summary"]["slide_count"], 2)
        self.assertEqual(result["summary"]["warning_count"], 0)
        self.assertEqual(result["summary"]["error_count"], 1)
        self.assertEqual(result["summary"]["status"], "blocked")
        self.assertFalse(result["summary"]["release_ready"])
        self.assertEqual(result["slides"][0]["issues"], [])
        self.assertEqual(result["slides"][1]["element_count"], 0)
        issue = result["slides"][1]["errors"][0]
        self.assertEqual(issue["level"], "error")
        self.assertEqual(issue["code"], "blank_slide")
        self.assertEqual(issue["element_ids"], [])
        self.assertEqual(issue["rule"]["id"], "blank_slide")
        self.assertEqual(issue["measurement"]["visible_element_count"], 0)
        self.assertEqual(issue["related_objects"], [])

    def test_lint_xml_blocks_blank_slide_with_only_transparent_image(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <img id="ghost" src="token" topLeftX="60" topLeftY="60" width="200" height="200" alpha="0"/>
              </data>
            </slide>
            """
        )

        self.assertEqual(result["summary"]["error_count"], 1)
        issue = result["slides"][0]["errors"][0]
        self.assertEqual(issue["code"], "blank_slide")

    def test_lint_xml_warns_when_large_container_is_mostly_empty(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="trend-card" type="rect" topLeftX="500" topLeftY="135" width="410" height="370"/>
                <shape id="trend-title" type="text" topLeftX="515" topLeftY="147" width="380" height="28">
                  <content fontSize="15"><p>Core trends</p></content>
                </shape>
                <shape id="trend-copy" type="text" topLeftX="515" topLeftY="177" width="380" height="315">
                  <content fontSize="12"><p>First point</p><p>Second point</p><p>Third point</p></content>
                </shape>
              </data>
            </slide>
            """
        )

        issue = result["slides"][0]["issues"][0]
        self.assertEqual(issue["code"], "sparse_container_content")
        self.assertEqual(issue["target"]["container_id"], "trend-card")
        self.assertEqual(issue["target"], {
            "slide_number": 1,
            "container_id": "trend-card",
            "container_xml_path": "slide[1]/data/shape[1]",
            "container_type": "rect",
            "bbox": {"x": 500, "y": 135, "width": 410, "height": 370},
        })
        self.assertLess(issue["measurement"]["content_coverage_ratio"], 0.15)
        self.assertEqual(issue["rule"], {
            "name": "large_container_visible_content_coverage",
            "threshold": 0.15,
            "comparison": "content_coverage_ratio < threshold",
            "id": "sparse_container_content",
        })
        self.assertEqual(issue["measurement"]["container_area"], 151700)
        self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0.03)
        self.assertEqual(issue["elements"], ["trend-card", "trend-title", "trend-copy"])
        self.assertEqual(issue["element_ids"], ["trend-card", "trend-title", "trend-copy"])
        self.assertEqual(
            [obj["element_id"] for obj in issue["related_objects"]],
            ["trend-card", "trend-title", "trend-copy"],
        )
        self.assertEqual(result["slides"][0]["status"], "needs_screenshot_review")
        self.assertEqual(result["slides"][0]["warnings"], result["slides"][0]["issues"])

    def test_lint_xml_uses_xml_path_in_anonymous_sparse_container_message(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape type="rect" topLeftX="500" topLeftY="135" width="410" height="370"/>
                <shape id="trend-title" type="text" topLeftX="515" topLeftY="147" width="380" height="28">
                  <content fontSize="15"><p>Core trends</p></content>
                </shape>
              </data>
            </slide>
            """
        )

        issue = next(
            issue
            for issue in result["slides"][0]["issues"]
            if issue["code"] == "sparse_container_content"
        )
        self.assertNotIn("container_id", issue["target"])
        self.assertEqual(
            issue["target"]["container_xml_path"],
            "slide[1]/data/shape[1]",
        )
        self.assertEqual(
            issue["message"],
            "large card slide[1]/data/shape[1] content coverage 1.0% is below 15.0%",
        )

    def test_lint_xml_warns_for_sparse_short_cards(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="card-1" type="rect" topLeftX="60" topLeftY="180" width="400" height="105"/>
                <shape id="text-1" type="text" topLeftX="80" topLeftY="220" width="360" height="30">
                  <content fontSize="14"><p>期待认识大家</p></content>
                </shape>
                <shape id="card-2" type="rect" topLeftX="490" topLeftY="180" width="400" height="105"/>
                <shape id="text-2" type="text" topLeftX="510" topLeftY="220" width="360" height="30">
                  <content fontSize="14"><p>化学一起讨论</p></content>
                </shape>
                <shape id="card-3" type="rect" topLeftX="60" topLeftY="310" width="400" height="105"/>
                <shape id="text-3" type="text" topLeftX="80" topLeftY="350" width="360" height="30">
                  <content fontSize="14"><p>吉他随时交流</p></content>
                </shape>
                <shape id="card-4" type="rect" topLeftX="490" topLeftY="310" width="400" height="105"/>
                <shape id="text-4" type="text" topLeftX="510" topLeftY="350" width="360" height="30">
                  <content fontSize="14"><p>共度美好四年</p></content>
                </shape>
              </data>
            </slide>
            """
        )

        container_issues = [
            issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content"
        ]
        self.assertEqual(
            [issue["target"]["container_id"] for issue in container_issues],
            ["card-1", "card-2", "card-3", "card-4"],
        )
        self.assertTrue(all(issue["target"]["bbox"]["height"] == 105 for issue in container_issues))
        self.assertTrue(all(issue["measurement"]["content_coverage_ratio"] < 0.15 for issue in container_issues))
        self.assertEqual(
            [issue["code"] for issue in result["slides"][0]["issues"]],
            [
                "sparse_container_content",
                "sparse_container_content",
                "sparse_container_content",
                "sparse_container_content",
                "sparse_slide_content",
            ],
        )

    def test_lint_xml_warns_when_whole_slide_has_too_little_effective_content(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="background" type="rect" topLeftX="0" topLeftY="0" width="960" height="540"/>
                <shape id="text-1" type="text" topLeftX="60" topLeftY="80" width="200" height="30">
                  <content fontSize="14"><p>One short line</p></content>
                </shape>
                <shape id="text-2" type="text" topLeftX="500" topLeftY="180" width="200" height="30">
                  <content fontSize="14"><p>Another line</p></content>
                </shape>
                <shape id="text-3" type="text" topLeftX="60" topLeftY="310" width="200" height="30">
                  <content fontSize="14"><p>Third line</p></content>
                </shape>
                <shape id="text-4" type="text" topLeftX="500" topLeftY="410" width="200" height="30">
                  <content fontSize="14"><p>Fourth line</p></content>
                </shape>
              </data>
            </slide>
            """
        )

        issues = [issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_slide_content"]
        self.assertEqual(len(issues), 1)
        issue = issues[0]
        self.assertEqual(issue["target"]["bbox"], {"x": 0, "y": 0, "width": 960, "height": 540})
        self.assertEqual(issue["rule"]["threshold"], 0.035)
        self.assertLess(issue["measurement"]["content_coverage_ratio"], 0.035)
        self.assertEqual(issue["measurement"]["content_element_count"], 4)
        self.assertNotIn("background", issue["elements"])

    def test_lint_xml_ignores_isolated_short_layout_bar(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="summary-bar" type="rect" topLeftX="52" topLeftY="82" width="856" height="105"/>
                <shape id="summary" type="text" topLeftX="72" topLeftY="115" width="816" height="30">
                  <content fontSize="14"><p>One concise summary</p></content>
                </shape>
              </data>
            </slide>
            """
        )

        self.assertEqual(result["slides"][0]["issues"], [])

    def test_lint_xml_counts_rect_own_content_as_visible_content(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="load-card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
                  <content fontSize="18">
                    <p>被吊物</p>
                    <p><span fontSize="36">32.0 t</span></p>
                    <p>钢结构模块</p>
                  </content>
                </shape>
              </data>
            </slide>
            """
        )

        self.assertEqual(result["slides"][0]["issues"], [])

    def test_lint_xml_reports_nonzero_coverage_for_rect_own_content_reproduction(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="load-card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
                  <content fontSize="18">
                    <p>被吊物</p>
                    <p>32.0 t</p>
                    <p>钢结构模块</p>
                  </content>
                </shape>
              </data>
            </slide>
            """
        )

        issue = result["slides"][0]["issues"][0]
        self.assertGreater(issue["measurement"]["visible_content_area"], 0)
        self.assertEqual(issue["measurement"]["content_element_count"], 1)
        self.assertGreater(issue["measurement"]["content_coverage_ratio"], 0)

    def test_lint_xml_still_warns_for_sparse_rect_own_content(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="sparse-card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
                  <content fontSize="12"><p>A</p></content>
                </shape>
              </data>
            </slide>
            """
        )

        issue = result["slides"][0]["issues"][0]
        self.assertEqual(issue["target"]["container_id"], "sparse-card")
        self.assertGreater(issue["measurement"]["visible_content_area"], 0)
        self.assertEqual(issue["measurement"]["content_element_count"], 1)
        self.assertEqual(issue["elements"], ["sparse-card"])

    def test_lint_xml_unions_rect_own_content_with_child_content(self) -> None:
        self_only = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
                  <content fontSize="12"><p>A</p></content>
                </shape>
              </data>
            </slide>
            """
        )
        with_overlapping_child = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
                  <content fontSize="12"><p>A</p></content>
                </shape>
                <shape id="child" type="text" topLeftX="60" topLeftY="140" width="220" height="184">
                  <content fontSize="12"><p>A</p></content>
                </shape>
              </data>
            </slide>
            """
        )

        self_issue = self_only["slides"][0]["issues"][0]
        mixed_issue = with_overlapping_child["slides"][0]["issues"][0]
        self.assertEqual(
            mixed_issue["measurement"]["visible_content_area"],
            self_issue["measurement"]["visible_content_area"],
        )
        self.assertEqual(mixed_issue["measurement"]["content_element_count"], 2)

    def test_extract_density_elements_reads_nested_font_size_from_rect_content(self) -> None:
        elements = xml_lint.extract_density_elements(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
                  <content fontSize="12"><p><span fontSize="36">32.0 t</span></p></content>
                </shape>
              </data>
            </slide>
            """
        )

        self.assertEqual(elements[0]["fontSize"], 36)

    def test_extract_density_elements_does_not_attach_following_text_to_self_closing_rect(self) -> None:
        elements = xml_lint.extract_density_elements(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184"/>
                <shape id="title" type="text" topLeftX="80" topLeftY="160" width="180" height="30">
                  <content fontSize="18"><p>Following title</p></content>
                </shape>
              </data>
            </slide>
            """
        )

        self.assertEqual(elements[0]["text"], "")
        self.assertEqual(elements[1]["text"], "Following title")

    def test_lint_xml_allows_container_with_large_visual_child(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="chart-card" type="rect" topLeftX="500" topLeftY="135" width="410" height="300"/>
                <chart id="chart" topLeftX="525" topLeftY="170" width="350" height="220">
                  <chartPlotArea><chartPlot type="line"/></chartPlotArea>
                  <chartData>
                    <dim1><chartField name="category" valueType="string">A</chartField></dim1>
                    <dim2><chartField name="value" valueType="number">1</chartField></dim2>
                  </chartData>
                </chart>
              </data>
            </slide>
            """
        )

        self.assertEqual(result["summary"]["warning_count"], 0)

    def test_lint_xml_does_not_let_transparent_visual_child_suppress_sparse_warning(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="title" type="text" topLeftX="40" topLeftY="40" width="300" height="40">
                  <content fontSize="20"><p>Section title</p></content>
                </shape>
                <shape id="chart-card" type="rect" topLeftX="500" topLeftY="135" width="410" height="300"/>
                <chart id="chart" topLeftX="525" topLeftY="170" width="350" height="220" alpha="0">
                  <chartPlotArea><chartPlot type="line"/></chartPlotArea>
                  <chartData>
                    <dim1><chartField name="category" valueType="string">A</chartField></dim1>
                    <dim2><chartField name="value" valueType="number">1</chartField></dim2>
                  </chartData>
                </chart>
              </data>
            </slide>
            """
        )

        issue = next(
            issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content"
        )
        self.assertEqual(issue["target"]["container_id"], "chart-card")

    def test_lint_xml_warns_for_small_empty_visual_placeholder_cards(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="letter-placeholder" type="rect" topLeftX="520" topLeftY="180" width="200" height="200"/>
                <shape id="letter" type="text" topLeftX="540" topLeftY="250" width="160" height="70">
                  <content fontSize="46"><p>Z</p></content>
                </shape>
                <shape id="empty-placeholder" type="rect" topLeftX="744" topLeftY="180" width="144" height="200"/>
              </data>
            </slide>
            """
        )

        issues = result["slides"][0]["issues"]
        self.assertEqual(
            [issue["target"]["container_id"] for issue in issues],
            ["letter-placeholder", "empty-placeholder"],
        )
        self.assertEqual(issues[1]["measurement"]["content_element_count"], 0)

    def test_lint_xml_applies_global_threshold_to_normal_text_card(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="card" type="rect" topLeftX="70" topLeftY="184" width="260" height="288"/>
                <shape id="title" type="text" topLeftX="90" topLeftY="215" width="220" height="30">
                  <content fontSize="18"><p>梦境与现实</p></content>
                </shape>
                <shape id="copy" type="text" topLeftX="90" topLeftY="330" width="220" height="70">
                  <content fontSize="13"><p>边界溶解,逻辑失效。观众被拽入潜意识的迷宫。</p></content>
                </shape>
              </data>
            </slide>
            """
        )

        issue = result["slides"][0]["issues"][0]
        self.assertEqual(issue["target"]["container_id"], "card")
        self.assertEqual(issue["rule"]["threshold"], 0.15)

    def test_lint_xml_allows_image_overlay_rect(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <img id="hero" topLeftX="560" topLeftY="0" width="400" height="540"/>
                <shape id="tint" type="rect" topLeftX="560" topLeftY="0" width="400" height="540"/>
              </data>
            </slide>
            """
        )

        self.assertEqual(result["summary"]["warning_count"], 0)

    def test_lint_xml_does_not_let_transparent_image_overlay_suppress_sparse_warning(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="title" type="text" topLeftX="40" topLeftY="40" width="300" height="40">
                  <content fontSize="20"><p>Section title</p></content>
                </shape>
                <shape id="card" type="rect" topLeftX="330" topLeftY="120" width="300" height="300"/>
                <img id="ghost-overlay" src="token" topLeftX="330" topLeftY="120" width="300" height="300" alpha="0"/>
              </data>
            </slide>
            """
        )

        issue = next(
            issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content"
        )
        self.assertEqual(issue["target"]["container_id"], "card")

    def test_lint_xml_allows_edge_spanning_layout_panel_and_nested_decoration(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="panel" type="rect" topLeftX="600" topLeftY="0" width="360" height="540"/>
                <shape id="decoration" type="rect" topLeftX="660" topLeftY="150" width="240" height="240"/>
              </data>
            </slide>
            """
        )

        self.assertEqual(result["summary"]["warning_count"], 0)

    def test_lint_xml_counts_icons_as_visible_content(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="card" type="rect" topLeftX="80" topLeftY="140" width="320" height="240"/>
                <icon id="visual" iconType="iconpark/Safe/shield.svg" topLeftX="100" topLeftY="160" width="180" height="180">
                  <fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
                </icon>
              </data>
            </slide>
            """
        )

        self.assertEqual(result["summary"]["warning_count"], 0)

    def test_lint_xml_does_not_count_transparent_icon_as_visible_content(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="title" type="text" topLeftX="40" topLeftY="40" width="300" height="40">
                  <content fontSize="20"><p>Section title</p></content>
                </shape>
                <shape id="card" type="rect" topLeftX="80" topLeftY="140" width="320" height="240"/>
                <icon id="visual" iconType="iconpark/Safe/shield.svg" topLeftX="100" topLeftY="160" width="180" height="180" alpha="0">
                  <fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
                </icon>
              </data>
            </slide>
            """
        )

        issue = next(
            issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content"
        )
        self.assertEqual(issue["target"]["container_id"], "card")
        self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0)

    def test_lint_xml_warns_when_coverage_is_below_global_threshold(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="card" type="rect" topLeftX="80" topLeftY="140" width="200" height="200"/>
                <icon id="visual" iconType="iconpark/Safe/shield.svg" topLeftX="100" topLeftY="160" width="70" height="70">
                  <fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
                </icon>
              </data>
            </slide>
            """
        )

        issue = result["slides"][0]["issues"][0]
        self.assertEqual(issue["target"]["container_id"], "card")
        self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0.122)
        self.assertEqual(issue["rule"]["threshold"], 0.15)

    def test_lint_xml_allows_quarter_coverage_under_lower_threshold(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="card" type="rect" topLeftX="80" topLeftY="140" width="200" height="200"/>
                <icon id="visual" iconType="iconpark/Safe/shield.svg" topLeftX="100" topLeftY="160" width="100" height="100">
                  <fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
                </icon>
              </data>
            </slide>
            """
        )

        self.assertEqual(result["slides"][0]["issues"], [])

    def test_lint_xml_allows_large_metric_card_above_lower_threshold(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="metric-card" type="rect" topLeftX="80" topLeftY="140" width="360" height="300"/>
                <shape id="metric" type="text" topLeftX="104" topLeftY="190" width="340" height="90">
                  <content fontSize="12"><p><strong><span fontSize="62">400</span></strong>+ 项</p></content>
                </shape>
              </data>
            </slide>
            """
        )

        self.assertEqual(result["slides"][0]["issues"], [])

    def test_lint_xml_does_not_report_blank_slide_for_embed_only_content(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="http://www.larkoffice.com/sml/2.0">
              <data>
                <embed id="emb" topLeftX="280" topLeftY="130" width="400" height="280">
                  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 280">
                    <circle cx="200" cy="140" r="100" fill="#2563EB"/>
                  </svg>
                </embed>
              </data>
            </slide>
            """
        )

        self.assertEqual(result["summary"]["error_count"], 0)
        codes = [issue["code"] for issue in result["slides"][0]["issues"]]
        self.assertNotIn("blank_slide", codes)

    def test_lint_xml_does_not_report_blank_slide_for_line_only_content(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <line id="l1" startX="100" startY="100" endX="800" endY="100"><border/></line>
                <line id="l2" startX="100" startY="200" endX="800" endY="200"><border/></line>
                <line id="l3" startX="100" startY="300" endX="800" endY="300"><border/></line>
                <line id="l4" startX="100" startY="400" endX="800" endY="400"><border/></line>
              </data>
            </slide>
            """
        )

        self.assertEqual(result["summary"]["error_count"], 0)
        codes = [issue["code"] for issue in result["slides"][0]["issues"]]
        self.assertNotIn("blank_slide", codes)

    def test_lint_xml_reports_bbox_overlap_measurement_from_decision_time_visual_bbox(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="left" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
                  <content fontSize="14"><p>overlap text <span fontSize="96">big</span></p></content>
                </shape>
                <shape id="right" type="text" topLeftX="80" topLeftY="80" width="300" height="80">
                  <content fontSize="14"><p>other overlap text</p></content>
                </shape>
              </data>
            </slide>
            """
        )

        issue = result["slides"][0]["issues"][0]
        self.assertEqual(issue["code"], "bbox_overlap")
        # Must match the visual bbox that should_flag_overlap actually decided with (fontSize=14
        # from extract_elements), not the fontSize=96 max-descendant value that
        # extract_density_elements computes for the same "left" element id.
        self.assertEqual(issue["measurement"]["intersection_width"], 109.2)
        self.assertEqual(issue["measurement"]["intersection_height"], 6.8)
        self.assertEqual(issue["measurement"]["intersection_area"], 742.56)

    def test_has_similar_short_card_peer_excludes_the_element_itself(self) -> None:
        card_a = {"kind": "shape", "type": "rect", "x": 0, "y": 0, "width": 300, "height": 100}
        card_b = {"kind": "shape", "type": "rect", "x": 400, "y": 0, "width": 300, "height": 100}
        card_c = {"kind": "shape", "type": "rect", "x": 0, "y": 200, "width": 300, "height": 100}

        self.assertFalse(
            xml_lint.has_similar_short_card_peer(card_a, [card_a, card_b])
        )
        self.assertTrue(
            xml_lint.has_similar_short_card_peer(card_a, [card_a, card_b, card_c])
        )

    def test_lint_xml_reports_schema_version_2_for_sparse_issues(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184"/>
              </data>
            </slide>
            """
        )

        issue = next(
            issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content"
        )
        self.assertEqual(issue["schema_version"], "2.0")

    def test_lint_xml_does_not_report_blank_slide_for_textless_decorative_shapes(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="deco1" type="ellipse" topLeftX="60" topLeftY="60" width="300" height="300">
                  <fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
                </shape>
                <shape id="deco2" type="triangle" topLeftX="500" topLeftY="200" width="200" height="200">
                  <fill><fillColor color="rgba(220, 38, 38, 1)"/></fill>
                </shape>
              </data>
            </slide>
            """
        )

        self.assertEqual(result["summary"]["error_count"], 0)
        codes = [issue["code"] for issue in result["slides"][0]["issues"]]
        self.assertNotIn("blank_slide", codes)

    def test_lint_xml_still_warns_for_sparse_slide_content_despite_full_bleed_background(self) -> None:
        # A plain textless shape now counts as "not blank" (see the test above), but a
        # full-bleed background rect must still NOT count toward sparse_slide_content's
        # meaningful-content coverage ratio -- otherwise every slide with a background would
        # trivially "pass" that density check.
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="background" type="rect" topLeftX="0" topLeftY="0" width="960" height="540"/>
                <shape id="text-1" type="text" topLeftX="60" topLeftY="80" width="200" height="30">
                  <content fontSize="14"><p>One short line</p></content>
                </shape>
                <shape id="text-2" type="text" topLeftX="500" topLeftY="180" width="200" height="30">
                  <content fontSize="14"><p>Another line</p></content>
                </shape>
                <shape id="text-3" type="text" topLeftX="60" topLeftY="310" width="200" height="30">
                  <content fontSize="14"><p>Third line</p></content>
                </shape>
                <shape id="text-4" type="text" topLeftX="500" topLeftY="410" width="200" height="30">
                  <content fontSize="14"><p>Fourth line</p></content>
                </shape>
              </data>
            </slide>
            """
        )

        codes = [issue["code"] for issue in result["slides"][0]["issues"]]
        self.assertIn("sparse_slide_content", codes)

    def test_lint_xml_accepts_whitespace_around_attribute_equals_sign(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="visible" type="text" topLeftX = "80" topLeftY = "80" width = "300" height = "60">
                  <content><p>hello</p></content>
                </shape>
              </data>
            </slide>
            """
        )

        self.assertEqual(result["slides"][0]["element_count"], 1)
        codes = [issue["code"] for issue in result["slides"][0]["issues"]]
        self.assertNotIn("blank_slide", codes)

    def test_lint_xml_reports_blank_slide_for_full_canvas_background_only(self) -> None:
        result = xml_lint.lint_xml(
            """
            <slide xmlns="https://www.larkoffice.com/sml/2.0">
              <data>
                <shape id="background" type="rect" topLeftX="0" topLeftY="0" width="960" height="540">
                  <fill><fillColor color="rgba(240, 235, 220, 1)"/></fill>
                </shape>
              </data>
            </slide>
            """
        )

        codes = [issue["code"] for issue in result["slides"][0]["issues"]]
        self.assertIn("blank_slide", codes)

    def test_has_similar_short_card_peer_ignores_invisible_peers(self) -> None:
        visible_card = {"kind": "shape", "type": "rect", "x": 0, "y": 0, "width": 300, "height": 100}
        ghost_1 = {
            "kind": "shape", "type": "rect", "x": 400, "y": 0, "width": 300, "height": 100, "alpha": 0,
        }
        ghost_2 = {
            "kind": "shape", "type": "rect", "x": 800, "y": 0, "width": 300, "height": 100, "alpha": 0,
        }

        self.assertFalse(
            xml_lint.has_similar_short_card_peer(
                visible_card, [visible_card, ghost_1, ghost_2]
            )
        )


SML_NAMESPACE = "https://www.larkoffice.com/sml/2.0"


class SxsdSyntaxTestCase(unittest.TestCase):
    def validate(self, xml: str) -> list[dict[str, object]]:
        result = xml_lint.lint_xml(xml)
        return [
            *result.get("issues", []),
            *(issue for slide in result["slides"] for issue in slide["issues"]),
        ]

    def assert_issue(
        self,
        issues: list[dict[str, object]],
        code: str,
        *,
        path: str | None = None,
        attr: str | None = None,
    ) -> dict[str, object]:
        for issue in issues:
            if issue.get("code") != code:
                continue
            if path is not None and issue.get("path") != path:
                continue
            if attr is not None and issue.get("attr") != attr:
                continue
            return issue
        self.fail(f"missing issue code={code!r} path={path!r} attr={attr!r}: {issues!r}")

    def assert_no_issue(self, issues: list[dict[str, object]], code: str) -> None:
        self.assertNotIn(code, [issue.get("code") for issue in issues])


class SxsdSyntaxAttributeTest(SxsdSyntaxTestCase):

    def test_xsd_pattern_translation_only_expands_whitespace_classes(self) -> None:
        self.assertEqual(
            sxsd_validator.python_pattern_for_xsd(r"\s+\S+\w+\d+"),
            "[ \\t\\n\\r]+[^ \\t\\n\\r]+\\w+\\d+",
        )

    def test_href_domain_pattern_does_not_use_backtracking_regex(self) -> None:
        pattern = r"[\w.-]+[.:]\S*"
        adversarial_value = ("a." * 20_000) + " "
        original_fullmatch = sxsd_validator.re.fullmatch
        translated_pattern = sxsd_validator.python_pattern_for_xsd(pattern)

        def reject_unsafe_pattern(candidate: str, value: str):
            if candidate == translated_pattern:
                raise AssertionError("href domain pattern must not use re.fullmatch")
            return original_fullmatch(candidate, value)

        with mock.patch.object(sxsd_validator.re, "fullmatch", side_effect=reject_unsafe_pattern):
            self.assertFalse(sxsd_validator.xsd_pattern_matches(pattern, adversarial_value))

    def test_href_domain_pattern_keeps_xsd_matching_behavior(self) -> None:
        pattern = r"[\w.-]+[.:]\S*"
        reference_pattern = sxsd_validator.re.compile(
            sxsd_validator.python_pattern_for_xsd(pattern)
        )

        for length in range(5):
            for characters in itertools.product("a.:-/ ©", repeat=length):
                value = "".join(characters)
                with self.subTest(value=value):
                    self.assertEqual(
                        sxsd_validator.xsd_pattern_matches(pattern, value),
                        reference_pattern.fullmatch(value) is not None,
                    )

    def test_accepts_valid_shape_attributes(self) -> None:
        issues = self.validate(
            f"""
            <slide xmlns="{SML_NAMESPACE}">
              <data>
                <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
                  <content textType="body"><p>Valid</p></content>
                </shape>
              </data>
            </slide>
            """
        )

        self.assertEqual(issues, [])

    def test_reports_missing_required_shape_attribute(self) -> None:
        issues = self.validate(
            f"""
            <slide xmlns="{SML_NAMESPACE}">
              <data><shape type="text" topLeftX="10" topLeftY="20" width="300"/></data>
            </slide>
            """
        )

        issue = self.assert_issue(
            issues,
            "sxsd_missing_required_attr",
            path="slide/data/shape",
            attr="height",
        )
        self.assertEqual(issue["expected"], "required attribute of type PositiveSize")
        self.assertIsNone(issue["actual"])

    def test_reports_invalid_scalar_value(self) -> None:
        issues = self.validate(
            f"""
            <slide xmlns="{SML_NAMESPACE}">
              <data>
                <shape type="text" topLeftX="NaN" topLeftY="20" width="300" height="80"/>
              </data>
            </slide>
            """
        )

        issue = self.assert_issue(issues, "sxsd_invalid_scalar", attr="topLeftX")
        self.assertEqual(issue["actual"], "NaN")

    def test_rejects_python_only_numeric_separator(self) -> None:
        issues = self.validate(
            f"""
            <slide xmlns="{SML_NAMESPACE}">
              <data>
                <shape type="rect" topLeftX="1_0" topLeftY="20" width="300" height="80"/>
              </data>
            </slide>
            """
        )

        self.assert_issue(issues, "sxsd_invalid_scalar", attr="topLeftX")

    def test_accepts_xsd_double_lexical_forms(self) -> None:
        for top_left_x in ("10", "-0.5", ".5", "1.", "1e2"):
            with self.subTest(top_left_x=top_left_x):
                issues = self.validate(
                    f"""
                    <slide xmlns="{SML_NAMESPACE}">
                      <data>
                        <shape type="rect" topLeftX="{top_left_x}" topLeftY="20" width="300" height="80"/>
                      </data>
                    </slide>
                    """
                )

                self.assertEqual(issues, [])

    def test_accepts_bullet_char_length_boundaries(self) -> None:
        for bullet_char in ("A", "12345678"):
            with self.subTest(bullet_char=bullet_char):
                issues = self.validate(
                    f"""
                    <slide xmlns="{SML_NAMESPACE}">
                      <data>
                        <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
                          <content bulletChar="{bullet_char}"><p>Text</p></content>
                        </shape>
                      </data>
                    </slide>
                    """
                )

                self.assertEqual(issues, [])

    def test_rejects_bullet_char_outside_length_boundaries(self) -> None:
        for bullet_char in ("", "123456789"):
            with self.subTest(bullet_char=bullet_char):
                issues = self.validate(
                    f"""
                    <slide xmlns="{SML_NAMESPACE}">
                      <data>
                        <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
                          <content bulletChar="{bullet_char}"><p>Text</p></content>
                        </shape>
                      </data>
                    </slide>
                    """
                )

                self.assert_issue(issues, "sxsd_value_out_of_range", attr="bulletChar")

    def test_rejects_zero_size_that_violates_xsd(self) -> None:
        issues = self.validate(
            f"""
            <slide xmlns="{SML_NAMESPACE}">
              <data>
                <shape type="text" topLeftX="10" topLeftY="20" width="0" height="80"/>
              </data>
            </slide>
            """
        )

        self.assert_issue(issues, "sxsd_value_out_of_range", attr="width")

    def test_reports_negative_size_rejected_by_xsd(self) -> None:
        issues = self.validate(
            f"""
            <slide xmlns="{SML_NAMESPACE}">
              <data>
                <shape type="text" topLeftX="10" topLeftY="20" width="-1" height="80"/>
              </data>
            </slide>
            """
        )

        self.assert_issue(issues, "sxsd_value_out_of_range", attr="width")

    def test_rejects_shape_enum_that_violates_xsd(self) -> None:
        issues = self.validate(
            f"""
            <slide xmlns="{SML_NAMESPACE}">
              <data>
                <shape type="not-a-shape" topLeftX="10" topLeftY="20" width="300" height="80"/>
              </data>
            </slide>
            """
        )

        issue = self.assert_issue(issues, "sxsd_invalid_enum", attr="type")
        self.assertLess(len(str(issue["message"])), 300)
        self.assertEqual(issue["actual"], "not-a-shape")

    def test_rejects_rotation_upper_bound_that_violates_xsd(self) -> None:
        issues = self.validate(
            f"""
            <slide xmlns="{SML_NAMESPACE}">
              <data>
                <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80" rotation="360"/>
              </data>
            </slide>
            """
        )

        self.assert_issue(issues, "sxsd_value_out_of_range", attr="rotation")

    def test_rejects_fill_color_that_violates_xsd(self) -> None:
        issues = self.validate(
            f"""
            <slide xmlns="{SML_NAMESPACE}">
              <style><fill><fillColor color="red"/></fill></style>
              <data/>
            </slide>
            """
        )

        self.assert_issue(issues, "sxsd_pattern_mismatch", attr="color")

    def test_reports_missing_required_image_src(self) -> None:
        issues = self.validate(
            f"""
            <slide xmlns="{SML_NAMESPACE}">
              <data><img topLeftX="10" topLeftY="20" width="300" height="80"/></data>
            </slide>
            """
        )

        self.assert_issue(issues, "sxsd_missing_required_attr", attr="src")

    def test_accepts_inline_attribute_simple_type(self) -> None:
        issues = self.validate(
            f"""
            <slide xmlns="{SML_NAMESPACE}">
              <data>
                <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
                  <content><p><a href="https://example.com">Link</a></p></content>
                </shape>
              </data>
            </slide>
            """
        )

        self.assertEqual(issues, [])

    def test_reports_inline_attribute_pattern_mismatch(self) -> None:
        issues = self.validate(
            f"""
            <slide xmlns="{SML_NAMESPACE}">
              <data>
                <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
                  <content><p><a href="not a uri">Link</a></p></content>
                </shape>
              </data>
            </slide>
            """
        )

        self.assert_issue(issues, "sxsd_pattern_mismatch", attr="href")

    def test_accepts_values_matching_inline_union_members(self) -> None:
        for bullet_size in ("25%", "100%", "400%", "6", "14", "400"):
            with self.subTest(bullet_size=bullet_size):
                issues = self.validate(
                    f"""
                    <slide xmlns="{SML_NAMESPACE}">
                      <data>
                        <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
                          <content bulletSize="{bullet_size}"><p>Text</p></content>
                        </shape>
                      </data>
                    </slide>
                    """
                )

                self.assertEqual(issues, [])

    def test_rejects_values_outside_inline_union_members(self) -> None:
        for bullet_size in ("24%", "401%", "5", "401", "abc"):
            with self.subTest(bullet_size=bullet_size):
                issues = self.validate(
                    f"""
                    <slide xmlns="{SML_NAMESPACE}">
                      <data>
                        <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
                          <content bulletSize="{bullet_size}"><p>Text</p></content>
                        </shape>
                      </data>
                    </slide>
                    """
                )

                self.assert_issue(issues, "sxsd_pattern_mismatch", attr="bulletSize")

    def test_rejects_symbol_outside_python_word_semantics_in_href(self) -> None:
        issues = self.validate(
            f"""
            <slide xmlns="{SML_NAMESPACE}">
              <data>
                <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
                  <content><p><a href="©:resource">Link</a></p></content>
                </shape>
              </data>
            </slide>
            """
        )

        self.assert_issue(issues, "sxsd_pattern_mismatch", attr="href")

    def test_accepts_common_email_href_with_python_regex_semantics(self) -> None:
        issues = self.validate(
            f"""
            <slide xmlns="{SML_NAMESPACE}">
              <data>
                <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
                  <content><p><a href="mailto:[email protected]">Email</a></p></content>
                </shape>
              </data>
            </slide>
            """
        )

        self.assertEqual(issues, [])

    def test_accepts_common_gradient_with_python_regex_semantics(self) -> None:
        issues = self.validate(
            f"""
            <slide xmlns="{SML_NAMESPACE}">
              <style>
                <fill>
                  <fillColor color="linear-gradient(90deg, rgb(255, 0, 0) 0%, rgb(0, 0, 255) 100%)"/>
                </fill>
              </style>
              <data>
                <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
                  <content><p>Gradient</p></content>
                </shape>
              </data>
            </slide>
            """
        )

        self.assertEqual(issues, [])

    def test_rejects_non_xsd_whitespace_in_color_pattern(self) -> None:
        issues = self.validate(
            f"""
            <slide xmlns="{SML_NAMESPACE}">
              <style><fill><fillColor color="rgb(1,\u00a02,3)"/></fill></style>
              <data/>
            </slide>
            """
        )

        self.assert_issue(issues, "sxsd_pattern_mismatch", attr="color")

class SxsdSyntaxStructureTest(SxsdSyntaxTestCase):
    def test_accepts_nested_content_in_referenced_rich_text_shadow(self) -> None:
        issues = self.validate(
            f"""
            <slide xmlns="{SML_NAMESPACE}">
              <data>
                <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
                  <content>
                    <p><span><shadow color="rgba(0, 0, 0, 1)"><strong>Text</strong></shadow></span></p>
                  </content>
                </shape>
              </data>
            </slide>
            """
        )

        self.assertEqual(issues, [])

    def test_keeps_shape_effect_shadow_as_childless_local_type(self) -> None:
        issues = self.validate(
            f"""
            <slide xmlns="{SML_NAMESPACE}">
              <data>
                <shape type="rect" topLeftX="10" topLeftY="20" width="300" height="80">
                  <shadow><strong>Not rich text</strong></shadow>
                </shape>
              </data>
            </slide>
            """
        )

        self.assert_issue(
            issues,
            "sxsd_unexpected_child",
            path="slide/data/shape/shadow/strong",
        )

    def test_accepts_standalone_slide_fragment_without_namespace(self) -> None:
        issues = self.validate(
            '<slide><data><shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">'
            '<content><p>Text</p></content></shape></data></slide>'
        )

        self.assertEqual(issues, [])

    def test_rejects_presentation_without_namespace(self) -> None:
        issues = self.validate(
            '<presentation width="960" height="540"><slide/></presentation>'
        )

        self.assert_issue(issues, "sxsd_invalid_namespace", path="presentation")

    def test_rejects_wrong_namespace_that_violates_xsd(self) -> None:
        issues = self.validate('<slide xmlns="https://example.com/not-sml"><data/></slide>')

        self.assert_issue(issues, "sxsd_invalid_namespace", path="slide")

    def test_rejects_descendant_outside_document_namespace(self) -> None:
        issues = self.validate(
            f"""
            <slide xmlns="{SML_NAMESPACE}">
              <data xmlns="">
                <shape xmlns="{SML_NAMESPACE}" type="rect" topLeftX="10" topLeftY="20" width="300" height="80"/>
              </data>
            </slide>
            """
        )

        self.assert_issue(issues, "sxsd_invalid_namespace", path="slide/data")

    def test_rejects_unexpected_child_that_violates_xsd(self) -> None:
        issues = self.validate(
            f"""
            <slide xmlns="{SML_NAMESPACE}">
              <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80"/>
            </slide>
            """
        )

        self.assert_issue(issues, "sxsd_unexpected_child", path="slide/shape")

    def test_rejects_child_order_that_violates_xsd(self) -> None:
        issues = self.validate(
            f"""
            <presentation xmlns="{SML_NAMESPACE}" width="1920" height="1080">
              <slide/>
              <title>Late title</title>
            </presentation>
            """
        )

        self.assert_issue(issues, "sxsd_invalid_child_order", path="presentation/title")

    def test_enforces_presentation_slide_minimum_from_xsd(self) -> None:
        issues = self.validate(
            f'<presentation xmlns="{SML_NAMESPACE}" width="1920" height="1080"/>'
        )

        self.assert_issue(issues, "sxsd_missing_required_child", path="presentation")

    def test_enforces_presentation_slide_maximum_from_xsd(self) -> None:
        slides = "".join("<slide/>" for _ in range(101))
        issues = self.validate(
            f'<presentation xmlns="{SML_NAMESPACE}" width="1920" height="1080">{slides}</presentation>'
        )

        self.assert_issue(issues, "sxsd_too_many_children", path="presentation/slide")

    def test_rejects_multiple_choice_children_that_violate_xsd(self) -> None:
        issues = self.validate(
            f"""
            <slide xmlns="{SML_NAMESPACE}">
              <style>
                <fill><fillColor/><fillImg src="token"/></fill>
              </style>
            </slide>
            """
        )

        self.assert_issue(issues, "sxsd_too_many_children", path="slide/style/fill")

    def test_rejects_line_without_required_border_from_xsd(self) -> None:
        issues = self.validate(
            f"""
            <slide xmlns="{SML_NAMESPACE}">
              <data><line startX="0" startY="0" endX="100" endY="100"/></data>
            </slide>
            """
        )

        self.assert_issue(issues, "sxsd_missing_required_child", path="slide/data/line")

    def test_reports_missing_required_chart_structure(self) -> None:
        issues = self.validate(
            f"""
            <slide xmlns="{SML_NAMESPACE}">
              <data><chart topLeftX="0" topLeftY="0" width="300" height="200"/></data>
            </slide>
            """
        )

        self.assert_issue(issues, "sxsd_missing_required_child", path="slide/data/chart")

    def test_reports_missing_required_nested_sequence_child(self) -> None:
        issues = self.validate(
            f"""
            <slide xmlns="{SML_NAMESPACE}">
              <data><table topLeftX="0" topLeftY="0"><tr/></table></data>
            </slide>
            """
        )

        issue = self.assert_issue(issues, "sxsd_missing_required_child", path="slide/data/table/tr")
        self.assertEqual(issue["expected"], "td (at least 1)")


class SxsdSchemaModelTest(unittest.TestCase):
    def test_reports_unsupported_xsd_pattern_without_crashing(self) -> None:
        schema = rf"""
        <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
                   xmlns:sml="{SML_NAMESPACE}"
                   targetNamespace="{SML_NAMESPACE}"
                   elementFormDefault="qualified">
          <xs:simpleType name="UnsupportedPatternType">
            <xs:union>
              <xs:simpleType>
                <xs:restriction base="xs:string"><xs:pattern value="[\S]"/></xs:restriction>
              </xs:simpleType>
              <xs:simpleType>
                <xs:restriction base="xs:string"><xs:pattern value="z+"/></xs:restriction>
              </xs:simpleType>
            </xs:union>
          </xs:simpleType>
          <xs:complexType name="SlideType">
            <xs:attribute name="value" type="sml:UnsupportedPatternType"/>
          </xs:complexType>
        </xs:schema>
        """
        with tempfile.TemporaryDirectory() as temp_dir:
            schema_path = Path(temp_dir) / "schema.xsd"
            schema_path.write_text(schema, encoding="utf-8")
            try:
                issues = sxsd_validator.validate_sxsd(
                    ET.fromstring(f'<slide xmlns="{SML_NAMESPACE}" value="A"/>'),
                    schema_path,
                )
            except (ValueError, sxsd_validator.re.error) as error:
                self.fail(f"SXSD pattern capability errors must be reported, not raised: {error}")

        self.assertEqual([issue["code"] for issue in issues], ["sxsd_unsupported_pattern"])
        self.assertEqual(issues[0]["attr"], "value")
        self.assertIn("pattern interpreter", str(issues[0]["hint"]).lower())

    def test_standalone_slide_uses_slide_type_without_global_element(self) -> None:
        schema = f"""
        <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
                   xmlns:sml="{SML_NAMESPACE}"
                   targetNamespace="{SML_NAMESPACE}"
                   elementFormDefault="qualified">
          <xs:complexType name="SlideType"><xs:sequence/></xs:complexType>
          <xs:complexType name="PresentationType">
            <xs:sequence><xs:element name="slide" type="sml:SlideType"/></xs:sequence>
          </xs:complexType>
          <xs:element name="presentation" type="sml:PresentationType"/>
        </xs:schema>
        """
        with tempfile.TemporaryDirectory() as temp_dir:
            schema_path = Path(temp_dir) / "schema.xsd"
            schema_path.write_text(schema, encoding="utf-8")
            issues = sxsd_validator.validate_sxsd(
                ET.fromstring(f'<slide xmlns="{SML_NAMESPACE}"/>'),
                schema_path,
            )

        self.assertEqual(issues, [])

    def test_standalone_slide_requires_slide_type_in_xsd(self) -> None:
        schema = f"""
        <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
                   xmlns:sml="{SML_NAMESPACE}"
                   targetNamespace="{SML_NAMESPACE}"
                   elementFormDefault="qualified">
          <xs:complexType name="PresentationType"><xs:sequence/></xs:complexType>
          <xs:element name="presentation" type="sml:PresentationType"/>
        </xs:schema>
        """
        with tempfile.TemporaryDirectory() as temp_dir:
            schema_path = Path(temp_dir) / "schema.xsd"
            schema_path.write_text(schema, encoding="utf-8")
            issues = sxsd_validator.validate_sxsd(
                ET.fromstring(f'<slide xmlns="{SML_NAMESPACE}"/>'),
                schema_path,
            )

        self.assertEqual([issue["code"] for issue in issues], ["sxsd_unexpected_root"])


if __name__ == "__main__":
    unittest.main()
scripts/xml_lint.py
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
"""Validate Slides XML structure and page layout through one release gate."""

from __future__ import annotations

import copy
import json
import math
import re
import sys
import unicodedata
import xml.parsers.expat as expat
import xml.etree.ElementTree as ET
from difflib import SequenceMatcher, get_close_matches
from pathlib import Path
from typing import Any

import sxsd_validator


XS_NS = "{http://www.w3.org/2001/XMLSchema}"
XML_NS = "{http://www.w3.org/XML/1998/namespace}"
SVG_NS = "{http://www.w3.org/2000/svg}"
SML_NAMESPACE = "https://www.larkoffice.com/sml/2.0"
SXSD_SCHEMA_PATH = Path(__file__).resolve().parents[1] / "references" / "xml" / "slides_xml_schema_definition.xml"
ICONPARK_INDEX_PATH = Path(__file__).resolve().parents[1] / "references" / "xml" / "iconpark-index.json"
SXSD_TAG_ALIASES = {
    "textbox": "<shape type=\"text\">",
    "textBox": "<shape type=\"text\">",
    "image": "<img>",
    "picture": "<img>",
}
SXSD_ATTR_ALIASES = {
    "x": "topLeftX",
    "left": "topLeftX",
    "y": "topLeftY",
    "top": "topLeftY",
    "w": "width",
    "h": "height",
    "fontColor": "color",
}
SERVER_FILLED_SXSD_ATTRS = {"id"}
ROUNDTRIP_SXSD_ATTRS = {
    ("chart", "updated"),
    ("chartData", "isStaticData"),
}
# Slides readback echoes each chartField's CSV text as per-value <chartParsedValues> children;
# it is server-emitted and absent from the write schema, so it must not block page linting.
ROUNDTRIP_SXSD_TAGS = {("chartField", "chartParsedValues")}
DEFAULT_TABLE_COLUMN_WIDTH = 110
DEFAULT_TABLE_ROW_HEIGHT = 37
DEFAULT_TEXT_LINE_SPACING_MULTIPLE = 1.5
TEXT_WRAP_WIDTH_TOLERANCE_PX = 1.0
TEXT_HEIGHT_OVERFLOW_TOLERANCE_PX = 0.5
SINGLE_LINE_METRIC_WIDTH_RATIO = 1.18
CENTERED_SHORT_LABEL_WIDTH_RATIO = 1.12
HEADLINE_NEAR_FIT_WIDTH_RATIO = 1.04
DENSE_BODY_LINE_SPACING_MAX_MULTIPLE = 1.6
GHOST_TEXT_MIN_FONT_SIZE = 96
GHOST_TEXT_MAX_ALPHA = 0.5
GHOST_TEXT_FAINT_MIN_FONT_SIZE = 36
GHOST_TEXT_FAINT_MAX_ALPHA = 0.35
# A <line> crossing text glyphs is a legibility defect (see line_crosses_text_glyphs). We erode the
# glyph box by this margin before testing intersection so a line that only skims a glyph edge or the
# padding-only text frame -- but does not actually cut through the letterforms -- is not flagged.
LINE_TEXT_GRAZE_MIN_PX = 2.0
LINE_TEXT_GRAZE_FONT_RATIO = 0.12
# A line whose effective stroke alpha is below this is not visibly rendered, so it cannot occlude text.
LINE_MIN_VISIBLE_ALPHA = 0.08
# Sub-pixel canvas overflow is floating-point rounding noise (e.g. rotated-bbox math), not a
# visible defect; keep this well under 1px so real overflow is still always caught.
CANVAS_OVERFLOW_TOLERANCE = 0.5
XML_PATH_HINT_PREFIX = "Locate via related_objects[].xml_path."
_SXSD_TAG_ATTRIBUTES_CACHE: dict[str, set[str]] | None = None
_ICONPARK_ICON_TYPES_CACHE: set[str] | None = None


class XmlLayoutLintError(Exception):
    pass


def fail(message: str) -> None:
    raise XmlLayoutLintError(message)


def read_file(file_path: str | Path) -> str:
    return Path(file_path).read_text(encoding="utf-8")


def parse_args(argv: list[str]) -> dict[str, Any]:
    options: dict[str, Any] = {}
    index = 0
    while index < len(argv):
        token = argv[index]
        if not token.startswith("--"):
            fail(f"unexpected argument: {token}, need --input")
        key = token[2:]
        next_token = argv[index + 1] if index + 1 < len(argv) else None
        if next_token is None or next_token.startswith("--"):
            options[key] = True
            index += 1
            continue
        options[key] = next_token
        index += 2
    return options


def extract_attribute(tag_source: str, name: str) -> str | None:
    match = re.search(
        fr"(?:^|\s){re.escape(name)}\s*=\s*(?:\"([^\"]+)\"|'([^']+)')", tag_source
    )
    if not match:
        return None
    return match.group(1) if match.group(1) is not None else match.group(2)


def extract_numeric_attribute(tag_source: str, name: str) -> int | float | None:
    raw = extract_attribute(tag_source, name)
    if raw is None:
        return None
    try:
        value = float(raw)
    except ValueError:
        return None
    return int(value) if value.is_integer() else value


def extract_bool_attribute(tag_source: str, name: str) -> bool:
    value = extract_attribute(tag_source, name)
    return value in {"true", "1", "yes"}


def extract_color_alpha(color: str | None) -> int | float | None:
    if color is None:
        return None
    normalized = re.sub(r"\s+", "", color).lower()
    if normalized == "transparent":
        return 0
    rgba_match = re.fullmatch(
        r"rgba\([^,]+,[^,]+,[^,]+,([+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+))\)",
        normalized,
    )
    if rgba_match is None:
        return None
    try:
        alpha = float(rgba_match.group(1))
    except ValueError:
        return None
    return int(alpha) if alpha.is_integer() else alpha


def effective_text_alpha(shape_alpha: int | float | None, text_color: str | None) -> int | float:
    base_alpha = shape_alpha if isinstance(shape_alpha, (int, float)) else 1
    color_alpha = extract_color_alpha(text_color)
    if not isinstance(color_alpha, (int, float)):
        return base_alpha
    return base_alpha * color_alpha


def detect_inline_style_presence(content_xml: str, style_tags: set[str]) -> bool:
    for tag_name in style_tags:
        if re.search(fr"<{re.escape(tag_name)}\b[\s>]", content_xml) is not None:
            return True
    return False


def detect_any_span_bool_attribute(content_xml: str, attr_name: str) -> bool:
    for attrs in re.findall(r"<span\b([^>]*)>", content_xml):
        if extract_bool_attribute(attrs, attr_name):
            return True
    return False


def sum_sizes(sizes: list[int | float]) -> int | float:
    return sum(sizes)


def is_filled_size(size: int | float | None) -> bool:
    return isinstance(size, (int, float)) and math.isfinite(size) and size > 0


def fill_last_size_gap(sizes: list[int | float], target_size: int | float) -> list[int | float]:
    if not sizes:
        return sizes
    final_sizes = [
        size if index == len(sizes) - 1 else max(1, math.floor(size + 0.5))
        for index, size in enumerate(sizes)
    ]
    remaining_size = target_size - sum_sizes(final_sizes[:-1])
    if remaining_size >= 1:
        final_sizes[-1] = remaining_size
        return final_sizes

    size_to_redistribute = 1 - remaining_size
    for index in range(len(final_sizes) - 2, -1, -1):
        reduction = min(final_sizes[index] - 1, size_to_redistribute)
        final_sizes[index] -= reduction
        size_to_redistribute -= reduction
        if size_to_redistribute == 0:
            final_sizes[-1] = 1
            return final_sizes

    final_sizes[-1] = 1
    return final_sizes


def solve_weighted_min_layout(
    input_sizes: list[int | float | None], default_size: int | float, target_min_size: int | float | None
) -> dict[str, Any]:
    filled_indexes: list[int] = []
    empty_indexes: list[int] = []
    base_sizes: list[int | float] = []
    for index, size in enumerate(input_sizes):
        if is_filled_size(size):
            filled_indexes.append(index)
            base_sizes.append(size)
        else:
            empty_indexes.append(index)
            base_sizes.append(0)
    filled_sum = sum_sizes(base_sizes)

    if target_min_size is None:
        final_sizes = [default_size if index in empty_indexes else size for index, size in enumerate(base_sizes)]
        return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": 1}

    if not filled_indexes:
        average_size = target_min_size / len(input_sizes)
        final_sizes = fill_last_size_gap([average_size] * len(input_sizes), target_min_size)
        return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": 1}

    if empty_indexes:
        remaining_size = target_min_size - filled_sum
        final_sizes = [*base_sizes]
        if remaining_size > 0:
            average_size = remaining_size / len(empty_indexes)
            empty_sizes = fill_last_size_gap([average_size] * len(empty_indexes), remaining_size)
            for index, empty_size in zip(empty_indexes, empty_sizes):
                final_sizes[index] = empty_size
        else:
            for index in empty_indexes:
                final_sizes[index] = default_size
        return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": 1}

    ratio = max(1, target_min_size / filled_sum)
    actual_size = max(target_min_size, filled_sum)
    if ratio == 1:
        return {"final_sizes": [*base_sizes], "actual_size": actual_size, "ratio": ratio}
    final_sizes = fill_last_size_gap([size * ratio for size in base_sizes], actual_size)
    return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": ratio}


def strip_xml(value: str, preserve_line_breaks: bool = False) -> str:
    stripped = re.sub(r"<!\[CDATA\[([\s\S]*?)\]\]>", r"\1", value)
    if preserve_line_breaks:
        stripped = re.sub(r"<br\b[^>]*>", "\n", stripped)
    stripped = re.sub(r"<[^>]+>", " ", stripped)
    stripped = stripped.replace("&nbsp;", " ")
    stripped = stripped.replace("&amp;", "&")
    stripped = stripped.replace("&lt;", "<")
    stripped = stripped.replace("&gt;", ">")
    stripped = stripped.replace("&quot;", '"')
    stripped = stripped.replace("&#39;", "'")
    if preserve_line_breaks:
        return "\n".join(re.sub(r"\s+", " ", line).strip() for line in stripped.split("\n"))
    return re.sub(r"\s+", " ", stripped).strip()


def strip_xml_paragraphs(value: str) -> str:
    paragraphs = re.findall(r"<p\b[^>]*>([\s\S]*?)</p\s*>", value)
    if paragraphs:
        return "\n".join(strip_xml(paragraph, preserve_line_breaks=True) for paragraph in paragraphs)
    return strip_xml(value, preserve_line_breaks=True)


def extract_text_paragraphs(value: str, default_font_size: int | float) -> list[dict[str, Any]]:
    paragraphs = []
    for attrs, body in re.findall(r"<p\b([^>]*)>([\s\S]*?)</p\s*>", value):
        paragraphs.append(
            {
                "text": strip_xml(body, preserve_line_breaks=True),
                "fontSize": extract_max_span_font_size(body, default_font_size),
                "textAlign": extract_attribute(attrs, "textAlign"),
                "lineSpacing": extract_attribute(attrs, "lineSpacing"),
                "beforeLineSpacing": extract_attribute(attrs, "beforeLineSpacing"),
                "afterLineSpacing": extract_attribute(attrs, "afterLineSpacing"),
                "letterSpacing": extract_numeric_attribute(attrs, "letterSpacing"),
            }
        )
    return paragraphs


def extract_max_span_font_size(value: str, default_font_size: int | float) -> int | float:
    font_sizes = [
        font_size
        for attrs in re.findall(r"<span\b([^>]*)>", value)
        if (font_size := extract_numeric_attribute(attrs, "fontSize")) is not None
    ]
    return max([default_font_size, *font_sizes])


def extract_tag_attributes(value: str, tag: str) -> str:
    match = re.search(fr"<{re.escape(tag)}\b([^>]*)>", value)
    return match.group(1) if match else ""


def xml_local_name(tag: str) -> str:
    return tag.rsplit("}", 1)[-1] if tag.startswith("{") else tag


def xml_namespace(tag: str) -> str | None:
    return tag.split("}", 1)[0] + "}" if tag.startswith("{") else None


def load_sxsd_tag_attributes() -> dict[str, set[str]]:
    global _SXSD_TAG_ATTRIBUTES_CACHE
    if _SXSD_TAG_ATTRIBUTES_CACHE is not None:
        return _SXSD_TAG_ATTRIBUTES_CACHE

    _SXSD_TAG_ATTRIBUTES_CACHE = sxsd_validator.load_tag_attributes(SXSD_SCHEMA_PATH)
    return _SXSD_TAG_ATTRIBUTES_CACHE


def load_iconpark_icon_types() -> set[str]:
    global _ICONPARK_ICON_TYPES_CACHE
    if _ICONPARK_ICON_TYPES_CACHE is not None:
        return _ICONPARK_ICON_TYPES_CACHE

    try:
        index_data = json.loads(ICONPARK_INDEX_PATH.read_text(encoding="utf-8"))
    except json.JSONDecodeError as error:
        fail(f"invalid iconpark index JSON: {error}")
    icons = index_data.get("icons")
    if not isinstance(icons, list):
        fail("iconpark index must contain an icons array")

    icon_types = {
        icon["iconType"]
        for icon in icons
        if isinstance(icon, dict) and isinstance(icon.get("iconType"), str) and icon["iconType"]
    }
    _ICONPARK_ICON_TYPES_CACHE = icon_types
    return icon_types


def build_sxsd_tag_hint(tag_name: str, supported_tags: set[str]) -> str:
    alias = SXSD_TAG_ALIASES.get(tag_name)
    if alias:
        return f"Use {alias} instead of <{tag_name}>."
    if tag_name == "svg":
        return 'Inside <embed> or <whiteboard>, write SVG as <svg xmlns="http://www.w3.org/2000/svg">...</svg>.'
    close_matches = get_close_matches(tag_name, sorted(supported_tags), n=3, cutoff=0.72)
    if close_matches:
        return "Unsupported SXSD tag. Did you mean " + ", ".join(f"<{match}>" for match in close_matches) + "?"
    return "Unsupported SXSD tag. Use only tags defined in slides_xml_schema_definition.xml."


def suggest_sxsd_attrs(attr_name: str, allowed_attrs: set[str]) -> list[str]:
    alias = SXSD_ATTR_ALIASES.get(attr_name)
    if alias and alias in allowed_attrs:
        return [alias]
    return get_close_matches(attr_name, sorted(allowed_attrs), n=3, cutoff=0.68)


def build_sxsd_attr_hint(tag_name: str, attr_name: str, allowed_attrs: set[str]) -> str:
    suggestions = suggest_sxsd_attrs(attr_name, allowed_attrs)
    if suggestions:
        if SXSD_ATTR_ALIASES.get(attr_name) == suggestions[0]:
            return f'Use "{suggestions[0]}" on <{tag_name}> instead of "{attr_name}".'
        return "Unsupported SXSD attribute. Did you mean " + ", ".join(f'"{match}"' for match in suggestions) + "?"
    allowed_summary = ", ".join(sorted(allowed_attrs)[:8])
    if len(allowed_attrs) > 8:
        allowed_summary += ", ..."
    return f"Unsupported SXSD attribute for <{tag_name}>. Allowed attributes include: {allowed_summary}."


def should_skip_sxsd_subtree(element: ET.Element, ancestors: list[str]) -> bool:
    return ("whiteboard" in ancestors or "embed" in ancestors) and xml_namespace(element.tag) == SVG_NS


def should_skip_sxsd_attribute(tag_name: str, attr_name: str) -> bool:
    return attr_name in SERVER_FILLED_SXSD_ATTRS or (tag_name, attr_name) in ROUNDTRIP_SXSD_ATTRS


def should_skip_sxsd_tag(parent_name: str | None, tag_name: str) -> bool:
    return (parent_name, tag_name) in ROUNDTRIP_SXSD_TAGS


def without_server_filled_sxsd_fields(root: ET.Element) -> ET.Element:
    sanitized_root = copy.deepcopy(root)

    def sanitize(element: ET.Element) -> None:
        tag_name = xml_local_name(element.tag)
        for raw_attr_name in list(element.attrib):
            if should_skip_sxsd_attribute(tag_name, xml_local_name(raw_attr_name)):
                del element.attrib[raw_attr_name]
        for child in list(element):
            if should_skip_sxsd_tag(tag_name, xml_local_name(child.tag)):
                element.remove(child)
                continue
            sanitize(child)

    sanitize(sanitized_root)
    return sanitized_root


def validate_sxsd_document(xml: str, root: ET.Element) -> list[dict[str, Any]]:
    tag_attributes = load_sxsd_tag_attributes()
    supported_tags = set(tag_attributes)
    issues: list[dict[str, Any]] = []
    suggested_attr_candidates: dict[tuple[str, str], list[set[str]]] = {}

    def visit(element: ET.Element, ancestors: list[str], path: str) -> None:
        if should_skip_sxsd_subtree(element, ancestors):
            return

        tag_name = xml_local_name(element.tag)
        current_path = f"{path}/{tag_name}" if path else tag_name
        parent_name = ancestors[-1] if ancestors else None
        if should_skip_sxsd_tag(parent_name, tag_name):
            return
        if tag_name not in supported_tags:
            issues.append(
                {
                    "level": "error",
                    "code": "sxsd_unsupported_tag",
                    "tag": tag_name,
                    "path": current_path,
                    "message": f"unsupported SXSD tag <{tag_name}> at {current_path}",
                    "hint": build_sxsd_tag_hint(tag_name, supported_tags),
                }
            )
            return
        else:
            allowed_attrs = tag_attributes[tag_name]
            for raw_attr_name in element.attrib:
                if raw_attr_name.startswith(XML_NS):
                    continue
                attr_name = xml_local_name(raw_attr_name)
                if should_skip_sxsd_attribute(tag_name, attr_name):
                    continue
                if attr_name in allowed_attrs:
                    continue
                suggestions = suggest_sxsd_attrs(attr_name, allowed_attrs)
                if suggestions:
                    suggested_attr_candidates.setdefault((current_path, tag_name), []).append(
                        set(suggestions)
                    )
                issues.append(
                    {
                        "level": "error",
                        "code": "sxsd_unsupported_attr",
                        "tag": tag_name,
                        "attr": attr_name,
                        "path": current_path,
                        "message": f'unsupported SXSD attribute "{attr_name}" on <{tag_name}> at {current_path}',
                        "hint": build_sxsd_attr_hint(tag_name, attr_name, allowed_attrs),
                    }
                )

        for child in element:
            visit(child, [*ancestors, tag_name], current_path)

    visit(root, [], "")
    existing = {
        (issue.get("code"), issue.get("path"), issue.get("tag"), issue.get("attr"))
        for issue in issues
    }
    unsupported_tag_locations = {
        (issue.get("path"), issue.get("tag"))
        for issue in issues
        if issue.get("code") == "sxsd_unsupported_tag"
    }
    schema_issues = _validate_sxsd_schema_constraints(xml, root)
    missing_attrs_by_location: dict[tuple[str, str], set[str]] = {}
    for schema_issue in schema_issues:
        if schema_issue.get("code") != "sxsd_missing_required_attr":
            continue
        location = (schema_issue.get("path"), schema_issue.get("tag"))
        missing_attrs_by_location.setdefault(location, set()).add(schema_issue.get("attr"))

    suggested_attrs: set[tuple[str, str, str]] = set()
    for location, candidate_groups in suggested_attr_candidates.items():
        missing_attrs = missing_attrs_by_location.get(location, set())
        for candidates in candidate_groups:
            matching_missing_attrs = candidates & missing_attrs
            if len(matching_missing_attrs) == 1:
                suggested_attrs.add((*location, next(iter(matching_missing_attrs))))

    for schema_issue in schema_issues:
        if schema_issue.get("code") == "sxsd_unexpected_child" and (
            schema_issue.get("path"),
            schema_issue.get("tag"),
        ) in unsupported_tag_locations:
            continue
        if schema_issue.get("code") == "sxsd_missing_required_attr" and (
            schema_issue.get("path"),
            schema_issue.get("tag"),
            schema_issue.get("attr"),
        ) in suggested_attrs:
            continue
        key = (
            schema_issue.get("code"),
            schema_issue.get("path"),
            schema_issue.get("tag"),
            schema_issue.get("attr"),
        )
        if key not in existing:
            issues.append(schema_issue)
    return issues


def _validate_sxsd_schema_constraints(xml: str, root: ET.Element) -> list[dict[str, Any]]:
    issues: list[dict[str, Any]] = []
    if re.match(r"^\s*<\?xml\b", xml):
        issues.append(
            {
                "level": "error",
                "code": "sxsd_unsupported_declaration",
                "path": xml_local_name(root.tag),
                "tag": xml_local_name(root.tag),
                "expected": "SXSD document without an XML declaration",
                "actual": "<?xml ...?>",
                "message": "XML declarations are not supported by the Slides SXSD write format",
                "hint": "Remove the <?xml ...?> declaration and keep the SXSD root element.",
            }
        )

    issues.extend(
        sxsd_validator.validate_sxsd(
            without_server_filled_sxsd_fields(root),
            SXSD_SCHEMA_PATH,
        )
    )
    issues.extend(validate_embed_svg_roots(root))
    return issues


def validate_embed_svg_roots(root: ET.Element) -> list[dict[str, Any]]:
    issues: list[dict[str, Any]] = []
    document_namespace = sxsd_validator.element_namespace(root.tag)
    is_bare_slide_fragment = (
        xml_local_name(root.tag) == "slide" and document_namespace is None
    )
    if (
        document_namespace not in sxsd_validator.ACCEPTED_SML_NAMESPACES
        and not is_bare_slide_fragment
    ):
        return issues

    def visit(element: ET.Element, ancestors: list[str], parent_path: str) -> None:
        if should_skip_sxsd_subtree(element, ancestors):
            return

        tag_name = xml_local_name(element.tag)
        path = f"{parent_path}/{tag_name}" if parent_path else tag_name
        if (
            tag_name == "embed"
            and sxsd_validator.element_namespace(element.tag) == document_namespace
        ):
            for child in element:
                if xml_namespace(child.tag) != SVG_NS or xml_local_name(child.tag) == "svg":
                    continue
                child_name = xml_local_name(child.tag)
                child_path = f"{path}/{child_name}"
                issues.append(
                    {
                        "level": "error",
                        "code": "sxsd_unexpected_child",
                        "path": child_path,
                        "tag": child_name,
                        "expected": '<svg xmlns="http://www.w3.org/2000/svg">',
                        "actual": child_name,
                        "message": f"embedded SVG content must use an <svg> root at {child_path}",
                        "hint": 'Wrap the SVG content in <svg xmlns="http://www.w3.org/2000/svg">...</svg>.',
                    }
                )
        for child in element:
            visit(child, [*ancestors, tag_name], path)

    visit(root, [], "")
    return issues


def build_iconpark_icon_type_hint(icon_type: str, supported_icon_types: set[str]) -> str:
    close_matches = get_close_matches(icon_type, sorted(supported_icon_types), n=3, cutoff=0.58)
    if close_matches:
        return (
            "iconType must exist in iconpark-index.json. Did you mean "
            + ", ".join(f'"{match}"' for match in close_matches)
            + "?"
        )
    return "iconType must exist in iconpark-index.json. Use scripts/iconpark_tool.py to search supported icons."


def validate_iconpark_icon_types(root: ET.Element) -> list[dict[str, Any]]:
    supported_icon_types: set[str] | None = None
    issues: list[dict[str, Any]] = []

    def direct_child(element: ET.Element, local_name: str) -> ET.Element | None:
        return next((child for child in element if xml_local_name(child.tag) == local_name), None)

    def is_transparent_color(color: str) -> bool:
        normalized = re.sub(r"\s+", "", color).lower()
        if normalized == "transparent":
            return True
        rgba_match = re.fullmatch(r"rgba\([^,]+,[^,]+,[^,]+,([0-9.]+)\)", normalized)
        if not rgba_match:
            return False
        try:
            return float(rgba_match.group(1)) <= 0
        except ValueError:
            return False

    def append_missing_fill_color_issue(current_path: str) -> None:
        issues.append(
            {
                "level": "error",
                "code": "icon_missing_fill_color",
                "tag": "icon",
                "path": current_path,
                "message": f"<icon> must set explicit non-transparent fillColor for visual visibility at {current_path}",
                "hint": 'Add <fill><fillColor color="rgba(R, G, B, 1)"/></fill> inside <icon>. This is a visual lint rule, not an SXSD required field.',
            }
        )

    def visit(element: ET.Element, ancestors: list[str], path: str) -> None:
        nonlocal supported_icon_types
        if should_skip_sxsd_subtree(element, ancestors):
            return

        tag_name = xml_local_name(element.tag)
        current_path = f"{path}/{tag_name}" if path else tag_name
        if tag_name == "icon":
            icon_type = element.attrib.get("iconType")
            if icon_type is not None:
                if supported_icon_types is None:
                    supported_icon_types = load_iconpark_icon_types()
                if icon_type not in supported_icon_types:
                    issues.append(
                        {
                            "level": "error",
                            "code": "iconpark_unsupported_icon_type",
                            "tag": "icon",
                            "attr": "iconType",
                            "iconType": icon_type,
                            "path": current_path,
                            "message": f'unsupported iconpark iconType "{icon_type}" at {current_path}',
                            "hint": build_iconpark_icon_type_hint(icon_type, supported_icon_types),
                        }
                    )
            fill = direct_child(element, "fill")
            fill_color = direct_child(fill, "fillColor") if fill is not None else None
            color = fill_color.attrib.get("color") if fill_color is not None else None
            if not color:
                append_missing_fill_color_issue(current_path)
            elif is_transparent_color(color):
                issues.append(
                    {
                        "level": "error",
                        "code": "icon_transparent_fill_color",
                        "tag": "icon",
                        "attr": "fillColor",
                        "path": current_path,
                        "color": color,
                        "message": f'<icon> fillColor must not be transparent for visual visibility at {current_path}: "{color}"',
                        "hint": 'Use an opaque visible color, for example <fillColor color="rgba(37, 99, 235, 1)"/>.',
                    }
                )
        for child in element:
            visit(child, [*ancestors, tag_name], current_path)

    visit(root, [], "")
    return issues


def extract_error_context(xml: str, line: int | None, column: int | None, radius: int = 40) -> str | None:
    if line is None or column is None:
        return None
    lines = xml.splitlines()
    if line < 1 or line > len(lines):
        return None
    source_line = lines[line - 1]
    start = max(column - radius, 0)
    end = min(column + radius, len(source_line))
    return source_line[start:end].strip()


def build_xml_error_issue(error: ET.ParseError, xml: str) -> dict[str, Any]:
    line, column = getattr(error, "position", (None, None))
    return {
        "level": "error",
        "code": "xml_not_well_formed",
        "message": f"XML is not well-formed: {error}",
        "line": line,
        "column": column,
        "context": extract_error_context(xml, line, column),
        "hint": (
            "Escape raw user text before placing it in XML. In text nodes and attribute values, bare & must be "
            "written as &amp;. In text nodes, write < as &lt; and > as &gt;. For attribute URLs, use a=1&amp;b=2."
        ),
    }


def validate_sml_tag_prefixes(xml: str) -> list[dict[str, Any]]:
    namespace_map: dict[str, str] = {}
    pending_declarations: list[tuple[str, str | None]] = []
    declarations_by_element: list[list[tuple[str, str | None]]] = []
    element_stack: list[str] = []
    issues: list[dict[str, Any]] = []

    parser = expat.ParserCreate(namespace_separator="|")
    parser.namespace_prefixes = True

    def handle_namespace_decl(prefix: str | None, namespace: str) -> None:
        normalized_prefix = prefix or ""
        previous_namespace = namespace_map.get(normalized_prefix)
        namespace_map[normalized_prefix] = namespace
        pending_declarations.append((normalized_prefix, previous_namespace))

    def handle_start_element(name: str, _attrs: dict[str, str]) -> None:
        declarations_by_element.append(pending_declarations.copy())
        pending_declarations.clear()
        name_parts = name.rsplit("|", 2)
        if len(name_parts) == 3:
            _namespace, local_name, prefix = name_parts
            element_name = f"{prefix}:{local_name}"
        else:
            prefix = ""
            local_name = name_parts[-1]
            element_name = local_name
        element_stack.append(element_name)
        if not prefix:
            return

        actual_namespace = namespace_map.get(prefix)
        if actual_namespace not in sxsd_validator.ACCEPTED_SML_NAMESPACES:
            return
        path = "/".join(element_stack)
        issues.append(
            {
                "level": "error",
                "code": "sml_prefixed_tag",
                "tag": element_name,
                "namespace": actual_namespace,
                "path": path,
                "line": parser.CurrentLineNumber,
                "column": parser.CurrentColumnNumber,
                "message": f"SML tag <{element_name}> must not use a namespace prefix at {path}",
                "hint": (
                    f'Use <{local_name}> under the default namespace '
                    f'<{local_name} xmlns="{SML_NAMESPACE}">, or use an unprefixed SML tag.'
                ),
            }
        )

    def handle_end_element(_name: str) -> None:
        for prefix, previous_namespace in reversed(declarations_by_element.pop()):
            if previous_namespace is None:
                namespace_map.pop(prefix, None)
            else:
                namespace_map[prefix] = previous_namespace
        element_stack.pop()

    parser.StartNamespaceDeclHandler = handle_namespace_decl
    parser.StartElementHandler = handle_start_element
    parser.EndElementHandler = handle_end_element
    parser.Parse(xml, True)
    return issues


def parse_xml_root(xml: str) -> tuple[ET.Element | None, dict[str, Any] | None]:
    try:
        root = ET.fromstring(xml)
    except ET.ParseError as error:
        return None, build_xml_error_issue(error, xml)

    root_name = xml_local_name(root.tag)
    if root_name not in {"presentation", "slide"}:
        fail("input must contain a <presentation> or <slide> root")
    return root, None


def validate_xml_well_formed(xml: str) -> dict[str, Any] | None:
    _, xml_error = parse_xml_root(xml)
    return xml_error


def serialize_slide_for_layout(slide_root: ET.Element) -> str:
    slide_copy = copy.deepcopy(slide_root)
    for element in slide_copy.iter():
        if not isinstance(element.tag, str):
            continue
        element.tag = xml_local_name(element.tag)
        attributes = {
            xml_local_name(attribute_name): value
            for attribute_name, value in element.attrib.items()
        }
        element.attrib.clear()
        element.attrib.update(attributes)
    return ET.tostring(slide_copy, encoding="unicode")


def parse_presentation(root: ET.Element) -> dict[str, Any]:
    root_name = xml_local_name(root.tag)
    if root_name == "slide":
        slide_roots = [root]
        width = 960
        height = 540
    elif root_name == "presentation":
        slide_roots = [child for child in root if xml_local_name(child.tag) == "slide"]
        width = int(float(root.attrib.get("width", 960)))
        height = int(float(root.attrib.get("height", 540)))
    else:
        fail("input must contain a <presentation> or <slide> root")
    return {
        "width": width,
        "height": height,
        "slides": [serialize_slide_for_layout(slide_root) for slide_root in slide_roots],
        "slide_roots": slide_roots,
    }


def build_source_xml_paths(slide_xml: str, slide_number: int) -> dict[str, list[str]]:
    root = ET.fromstring(slide_xml)
    data = next((child for child in root if xml_local_name(child.tag) == "data"), None)
    paths: dict[str, list[str]] = {}
    if data is None:
        return paths
    counts: dict[str, int] = {}
    for child in data:
        kind = xml_local_name(child.tag)
        counts[kind] = counts.get(kind, 0) + 1
        paths.setdefault(kind, []).append(
            f"slide[{slide_number}]/data/{kind}[{counts[kind]}]"
        )
    return paths


def attach_source_xml_paths(
    elements: list[dict[str, Any]], source_paths: dict[str, list[str]]
) -> None:
    offsets: dict[str, int] = {}
    for element in elements:
        kind = element["kind"]
        source_kind_index = element.get("_source_kind_index")
        offset = (
            source_kind_index - 1
            if isinstance(source_kind_index, int) and source_kind_index > 0
            else offsets.get(kind, 0)
        )
        kind_paths = source_paths.get(kind, [])
        if offset < len(kind_paths):
            element["xml_path"] = kind_paths[offset]
            element["_ref"] = kind_paths[offset]
        offsets[kind] = max(offsets.get(kind, 0), offset + 1)


def extract_source_id_elements(slide_xml: str, slide_number: int) -> list[dict[str, Any]]:
    root = ET.fromstring(slide_xml)
    elements: list[dict[str, Any]] = []
    root_path = f"slide[{slide_number}]"

    def walk(parent: ET.Element, parent_path: str) -> None:
        child_counts: dict[str, int] = {}
        for child in parent:
            kind = xml_local_name(child.tag)
            child_counts[kind] = child_counts.get(kind, 0) + 1
            xml_path = (
                f"{parent_path}/data"
                if parent is root and kind == "data"
                else f"{parent_path}/{kind}[{child_counts[kind]}]"
            )
            source_id = extract_attribute(
                ET.tostring(child, encoding="unicode").split(">", 1)[0], "id"
            )
            if source_id:
                elements.append(
                    {
                        "id": source_id,
                        "_source_id": source_id,
                        "kind": kind,
                        "type": child.attrib.get("type") or kind,
                        "xml_path": xml_path,
                        "_ref": xml_path,
                        "_slide_number": slide_number,
                    }
                )
            walk(child, xml_path)

    walk(root, root_path)
    return elements


def element_ref(element: dict[str, Any]) -> str:
    ref = element.get("_ref") or element.get("xml_path")
    if isinstance(ref, str) and ref:
        return ref
    # Low-level detector tests and external callers may pass already-extracted objects.
    # The lint_xml pipeline always attaches the source path before issue detection.
    fallback = element.get("id")
    if isinstance(fallback, str) and fallback:
        return fallback
    raise AssertionError("lint element must have a source xml path or fallback id")


def source_element_id(element: dict[str, Any]) -> str | None:
    value = element.get("_source_id")
    return value if isinstance(value, str) and value else None


def element_label(element: dict[str, Any]) -> str:
    return source_element_id(element) or element_ref(element)


def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
    elements: list[dict[str, Any]] = []
    source_kind_counts: dict[str, int] = {}

    for match in re.finditer(r"<(shape|img|table|chart|whiteboard|embed)\b([^>]*)>", slide_xml):
        kind, attrs = match.group(1), match.group(2)
        source_kind_counts[kind] = source_kind_counts.get(kind, 0) + 1
        source_kind_index = source_kind_counts[kind]
        is_self_closing = attrs.rstrip().endswith("/")
        content = ""
        if kind in {"shape", "table"} and not is_self_closing:
            close_index = slide_xml.find(f"</{kind}>", match.end())
            if close_index != -1:
                content = slide_xml[match.end() : close_index]

        source_id = extract_attribute(attrs, "id") or None
        element_id = source_id or f"{kind}-{len(elements) + 1}"
        x = extract_numeric_attribute(attrs, "topLeftX")
        y = extract_numeric_attribute(attrs, "topLeftY")
        width = extract_numeric_attribute(attrs, "width")
        height = extract_numeric_attribute(attrs, "height")
        rotation = extract_numeric_attribute(attrs, "rotation") or 0
        alpha = extract_numeric_attribute(attrs, "alpha")
        table_layouts: dict[str, dict[str, Any] | None] = {}
        if kind == "table":
            width, table_layouts["width"] = resolve_table_dimension(
                content, width, extract_table_column_sizes, DEFAULT_TABLE_COLUMN_WIDTH
            )
            height, table_layouts["height"] = resolve_table_dimension(
                content, height, extract_table_row_sizes, DEFAULT_TABLE_ROW_HEIGHT
            )
        if all(value is not None for value in [x, y, width, height]):
            element = {
                "id": element_id,
                "_source_id": source_id,
                "kind": kind,
                "type": extract_attribute(attrs, "type") or kind,
                "x": x,
                "y": y,
                "width": width,
                "height": height,
                "rotation": rotation,
                "alpha": alpha if alpha is not None else 1,
                "order": len(elements),
                "_source_kind_index": source_kind_index,
            }
            if kind == "table":
                element.update(
                    {
                        "declared_width": extract_numeric_attribute(attrs, "width"),
                        "declared_height": extract_numeric_attribute(attrs, "height"),
                        "table_layouts": table_layouts,
                    }
                )
            if kind == "shape":
                content_attrs = extract_tag_attributes(content, "content")
                font_size = extract_numeric_attribute(content_attrs, "fontSize")
                if font_size is None:
                    font_size = extract_numeric_attribute(attrs, "fontSize")
                font_family = extract_attribute(content_attrs, "fontFamily") or extract_attribute(attrs, "fontFamily")
                text_color = extract_attribute(content_attrs, "color") or extract_attribute(attrs, "color")
                bold = (
                    extract_bool_attribute(content_attrs, "bold")
                    or extract_bool_attribute(attrs, "bold")
                    or detect_inline_style_presence(content, {"strong", "b"})
                    or detect_any_span_bool_attribute(content, "bold")
                )
                italic = (
                    extract_bool_attribute(content_attrs, "italic")
                    or extract_bool_attribute(attrs, "italic")
                    or detect_inline_style_presence(content, {"i", "em"})
                    or detect_any_span_bool_attribute(content, "italic")
                )
                element.update(
                    {
                        "textType": extract_attribute(content_attrs, "textType"),
                        "textAlign": extract_attribute(content_attrs, "textAlign"),
                        "verticalAlign": extract_attribute(content_attrs, "verticalAlign") or "middle",
                        "vert": extract_attribute(attrs, "vert") or "horz",
                        "autoFit": extract_attribute(content_attrs, "autoFit"),
                        "wrap": extract_attribute(content_attrs, "wrap"),
                        "lineSpacing": extract_attribute(content_attrs, "lineSpacing"),
                        "beforeLineSpacing": extract_attribute(content_attrs, "beforeLineSpacing"),
                        "afterLineSpacing": extract_attribute(content_attrs, "afterLineSpacing"),
                        "letterSpacing": extract_numeric_attribute(content_attrs, "letterSpacing"),
                        "paddingTop": extract_numeric_attribute(content_attrs, "paddingTop") or 0,
                        "paddingRight": extract_numeric_attribute(content_attrs, "paddingRight") or 0,
                        "paddingBottom": extract_numeric_attribute(content_attrs, "paddingBottom") or 0,
                        "paddingLeft": extract_numeric_attribute(content_attrs, "paddingLeft") or 0,
                        "fontSize": font_size if font_size is not None else 16,
                        "fontFamily": font_family or "",
                        "color": text_color,
                        "textAlpha": effective_text_alpha(alpha, text_color),
                        "bold": bold,
                        "italic": italic,
                        "text": strip_xml_paragraphs(content),
                        "paragraphs": extract_text_paragraphs(content, font_size if font_size is not None else 16),
                    }
                )
            elements.append(element)
    return elements


def intersects(left: dict[str, Any], right: dict[str, Any]) -> bool:
    return (
        left["x"] < right["x"] + right["width"]
        and left["x"] + left["width"] > right["x"]
        and left["y"] < right["y"] + right["height"]
        and left["y"] + left["height"] > right["y"]
    )


def is_text_element(element: dict[str, Any]) -> bool:
    return element["kind"] == "shape" and element["type"] == "text"


def is_whiteboard_element(element: dict[str, Any]) -> bool:
    return element["kind"] == "whiteboard"


def has_text_content(element: dict[str, Any]) -> bool:
    return bool(element.get("text"))


def is_vertical_text(element: dict[str, Any]) -> bool:
    return element.get("vert") in {"vert", "vert270", "word-art-vert", "word-art-vert-rtl", "ea-vert"}


def detect_image_text_occlusions(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
    issues: list[dict[str, Any]] = []
    text_elements = [
        element
        for element in elements
        if is_text_element(element) and has_text_content(element) and not is_ghost_text(element)
    ]
    image_elements = [element for element in elements if element["kind"] == "img" and element["alpha"] > 0]
    for text_element in text_elements:
        for image_element in image_elements:
            if image_element["order"] <= text_element["order"]:
                continue
            if is_vertical_text(text_element):
                if intersects(image_element, text_element):
                    issues.append({
                        "level": "info",
                        "code": "image_may_cover_vertical_text",
                        "elements": [element_ref(image_element), element_ref(text_element)],
                        "message": (
                            f"image {element_label(image_element)} may cover vertical text shape "
                            f"{element_label(text_element)}"
                        ),
                        "hint": "Inspect the rendered slide because vertical text layout is not statically modeled.",
                    })
                continue
            text_visual_bbox = estimate_text_visual_bbox(text_element)
            if text_visual_bbox is not None and intersects(image_element, text_visual_bbox):
                issues.append({
                    "level": "error",
                    "code": "image_covers_text",
                    "elements": [element_ref(image_element), element_ref(text_element)],
                    "message": (
                        f"image {element_label(image_element)} covers text shape "
                        f"{element_label(text_element)}"
                    ),
                    "hint": "Move the image before the text shape in XML order, or adjust the image and text shape coordinates or dimensions.",
                })
    return issues


def is_decorative_text(element: dict[str, Any]) -> bool:
    text = element.get("text") or ""
    return bool(text) and re.search(r"[A-Za-z0-9\u4e00-\u9fff]", text) is None


def normalize_text_for_overlap(text: str) -> str:
    return re.sub(r"\s+", "", text)


SERIF_FONT_PATTERNS = {
    "song", "songti", "simsun", "ming", "mincho",
    "georgia", "times", "caslon", "garamond", "sourcehan-serif",
    "source han serif", "思源宋体", "宋体", "明体",
}

SANS_EXPLICIT_MARKERS = {"sans", "sans-serif", "sans serif", "sourcehan-sans", "source han sans", "思源黑体", "黑体",
                         "helvetica", "arial", "inter", "roboto", "verdana", "tahoma", "calibri", "open sans"}


def classify_font_family(font_family: str | None) -> str:
    if not font_family:
        return "sans"
    family_lower = font_family.lower()
    for marker in SANS_EXPLICIT_MARKERS:
        if marker in family_lower:
            return "sans"
    serif_keywords = SERIF_FONT_PATTERNS | {"serif"}
    for pattern in serif_keywords:
        if pattern in family_lower:
            return "serif"
    return "sans"


_FONT_CATEGORY_MULTIPLIERS: dict[str, dict[str, float]] = {
    "sans": {"upper": 0.57, "lower": 0.51, "digit": 0.58, "punct": 0.50},
    "serif": {"upper": 0.57, "lower": 0.53, "digit": 0.58, "punct": 0.50},
}


def estimate_character_width(
    character: str,
    font_size: int | float,
    bold: bool = False,
    font_family: str | None = None,
) -> int | float:
    bold_multiplier = 1.05 if bold else 1.0
    if character.isspace():
        return font_size * 0.33 * bold_multiplier
    ea_width = unicodedata.east_asian_width(character)
    if ea_width in {"F", "W"}:
        return font_size * bold_multiplier
    category = classify_font_family(font_family)
    coeffs = _FONT_CATEGORY_MULTIPLIERS[category]
    if character.isupper():
        return font_size * coeffs["upper"] * bold_multiplier
    if character.islower():
        return font_size * coeffs["lower"] * bold_multiplier
    if character.isdigit():
        return font_size * coeffs["digit"] * bold_multiplier
    return font_size * coeffs["punct"] * bold_multiplier


def estimate_text_width(
    text: str,
    font_size: int | float,
    letter_spacing: int | float = 0,
    bold: bool = False,
    font_family: str | None = None,
) -> int | float:
    base = sum(estimate_character_width(character, font_size, bold, font_family) for character in text)
    return base + max(len(text) - 1, 0) * letter_spacing


def resolve_letter_spacing(element: dict[str, Any], paragraph: dict[str, Any] | None = None) -> int | float:
    if paragraph is not None:
        value = paragraph.get("letterSpacing")
        if isinstance(value, (int, float)):
            return value
    value = element.get("letterSpacing")
    return value if isinstance(value, (int, float)) else 0


def text_wrap_width_tolerance() -> int | float:
    return TEXT_WRAP_WIDTH_TOLERANCE_PX


def text_height_overflow_tolerance() -> int | float:
    return TEXT_HEIGHT_OVERFLOW_TOLERANCE_PX


def has_explicit_height_auto_fit(element: dict[str, Any]) -> bool:
    return element.get("autoFit") in {"normal-auto-fit", "shape-auto-fit"}


def is_short_metric_text(text: str) -> bool:
    compact = re.sub(r"\s+", "", text)
    if not compact or len(compact) > 16 or re.search(r"\d", compact) is None:
        return False
    if re.fullmatch(r"[+\-–—]?[0-9,.,]+[\u4e00-\u9fffA-Za-z]{1,4}", compact):
        return True
    if re.search(r"[,.,+\-–—/%%]", compact) is None:
        return False
    return re.fullmatch(r"[+\-–—]?[0-9A-Za-z,.,/%%\-–—\u4e00-\u9fff]+", compact) is not None


def is_single_line_visual_candidate(
    element: dict[str, Any],
    paragraph: dict[str, Any] | None,
    text: str,
    logical_width: int | float,
    effective_width: int | float,
) -> bool:
    if "\n" in text or logical_width <= effective_width:
        return False
    if is_short_metric_text(text):
        return logical_width <= effective_width * SINGLE_LINE_METRIC_WIDTH_RATIO

    text_align = (paragraph or {}).get("textAlign") or element.get("textAlign")
    compact_len = len(re.sub(r"\s+", "", text))
    if text_align == "center" and compact_len <= 32:
        return logical_width <= effective_width * CENTERED_SHORT_LABEL_WIDTH_RATIO

    font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
    if element.get("textType") in {"headline", "title"} and font_size <= 30 and compact_len <= 40:
        return logical_width <= effective_width * HEADLINE_NEAR_FIT_WIDTH_RATIO
    return False


def estimate_text_max_line_width(element: dict[str, Any]) -> int | float:
    font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
    bold = element.get("bold", False)
    font_family = element.get("fontFamily", "")
    letter_spacing = resolve_letter_spacing(element)
    paragraphs = [paragraph for paragraph in re.split(r"\n+", element["text"]) if paragraph]
    return max(
        [estimate_text_width(paragraph, font_size, letter_spacing, bold, font_family) for paragraph in paragraphs]
        or [1]
    )


def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool:
    left_text = normalize_text_for_overlap(left.get("text") or "")
    right_text = normalize_text_for_overlap(right.get("text") or "")
    if not left_text or not right_text:
        return False
    if left_text == right_text or left_text in right_text or right_text in left_text:
        return True
    return SequenceMatcher(None, left_text, right_text).ratio() >= 0.75


def estimate_text_line_count_for_text(
    element: dict[str, Any], text: str, paragraph: dict[str, Any] | None = None
) -> int:
    font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
    bold = element.get("bold", False)
    font_family = element.get("fontFamily", "")
    letter_spacing = resolve_letter_spacing(element, paragraph)
    available_width = max(element["width"] - element.get("paddingLeft", 0) - element.get("paddingRight", 0), 1)
    hard_lines = text.split("\n")
    if not text:
        return 0
    line_count = 0
    for hard_line in hard_lines:
        if element.get("wrap") in {"false", "0"}:
            line_count += 1
            continue
        logical_width = max(estimate_text_width(hard_line, font_size, letter_spacing, bold, font_family), 1)
        effective_width = available_width + text_wrap_width_tolerance()
        if is_single_line_visual_candidate(element, paragraph, hard_line, logical_width, effective_width):
            line_count += 1
            continue
        line_count += max(1, math.ceil(logical_width / effective_width))
    return line_count


def estimate_text_line_count(element: dict[str, Any]) -> int:
    return max(estimate_text_line_count_for_text(element, element["text"]), 1)


def estimate_text_line_height(element: dict[str, Any], line_spacing: str | None = None) -> int | float | None:
    font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
    if line_spacing is None:
        return font_size * DEFAULT_TEXT_LINE_SPACING_MULTIPLE
    match = re.fullmatch(r"(multiple|fixed):([0-9]+(?:\.[0-9]+)?)", line_spacing)
    if match is None:
        return None
    spacing_type, value = match.groups()
    return font_size * float(value) if spacing_type == "multiple" else float(value)


def adjust_dense_body_line_height(
    element: dict[str, Any],
    line_spacing: str | None,
    line_height: int | float,
    paragraph_count: int,
) -> int | float:
    font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
    if paragraph_count < 4 or font_size > 14 or not line_spacing:
        return line_height
    match = re.fullmatch(r"multiple:([0-9]+(?:\.[0-9]+)?)", line_spacing)
    if match is None:
        return line_height
    return min(line_height, font_size * min(float(match.group(1)), DENSE_BODY_LINE_SPACING_MAX_MULTIPLE))


def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
    issues: list[dict[str, Any]] = []
    for element in elements:
        if not is_text_element(element) or not has_text_content(element):
            continue
        if has_explicit_height_auto_fit(element):
            continue

        font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
        paragraphs = element.get("paragraphs") or [
            {
                "text": element["text"],
                "lineSpacing": None,
                "beforeLineSpacing": None,
                "afterLineSpacing": None,
            }
        ]
        line_count = 0
        estimated_height = 0.0
        line_heights: list[int | float] = []
        for paragraph in paragraphs:
            paragraph_line_count = estimate_text_line_count_for_text(element, paragraph["text"], paragraph)
            if paragraph_line_count == 0:
                continue
            resolved_line_spacing = paragraph["lineSpacing"] or element["lineSpacing"]
            line_height = estimate_text_line_height(element, resolved_line_spacing)
            before_spacing = estimate_text_line_height(
                element, paragraph["beforeLineSpacing"] or element["beforeLineSpacing"] or "fixed:0"
            )
            after_spacing = estimate_text_line_height(
                element, paragraph["afterLineSpacing"] or element["afterLineSpacing"] or "fixed:0"
            )
            if line_height is None or before_spacing is None or after_spacing is None:
                line_count = 0
                break
            line_height = adjust_dense_body_line_height(element, resolved_line_spacing, line_height, len(paragraphs))
            first_line_height = font_size if line_count == 0 else line_height
            line_count += paragraph_line_count
            line_heights.append(line_height)
            estimated_height += (
                before_spacing + first_line_height + max(paragraph_line_count - 1, 0) * line_height + after_spacing
            )
        if line_count == 0:
            continue
        available_height = max(element["height"] - element["paddingTop"] - element["paddingBottom"], 0)
        overflow = estimated_height - available_height
        if overflow <= text_height_overflow_tolerance():
            continue

        is_background = is_background_decorative_text(element, elements)
        if is_background:
            level = "info"
        else:
            level = "error" if overflow > 10 else "warning"
        message = (
            f"text shape {element_label(element)} may overflow its own content box "
            f'(estimated {estimated_height:g}px, available {available_height:g}px); '
            'consider setting content wrap="true" autoFit="normal-auto-fit"'
        )
        if is_background:
            message += " (likely background decoration: large font, low alpha, underneath other text)"
        issues.append(
            {
                "level": level,
                "code": "text_may_overflow_shape",
                "elements": [element_ref(element)],
                "line_count": line_count,
                "line_height": max(line_heights),
                "estimated_height": estimated_height,
                "available_height": available_height,
                "overflow": overflow,
                "message": message,
                "hint": (
                    "Increase shape.height, reduce the text, or set content wrap=\"true\" "
                    "autoFit=\"normal-auto-fit\". "
                    "This is an estimate based on font size, line spacing, and wrapped line count."
                ),
            }
        )
    return issues


def is_background_decorative_text(
    element: dict[str, Any], elements: list[dict[str, Any]]
) -> bool:
    if not is_ghost_text(element):
        return False
    for other in elements:
        if other is element:
            continue
        if not is_text_element(other) or not has_text_content(other):
            continue
        foreground_alpha = other.get("textAlpha", other.get("alpha", 1))
        if not isinstance(foreground_alpha, (int, float)) or foreground_alpha <= 0:
            continue
        if other["order"] <= element["order"]:
            continue
        if intersects(element, other):
            return True
    return False


def is_ghost_text(element: dict[str, Any]) -> bool:
    if not is_text_element(element) or not has_text_content(element):
        return False
    font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
    text_alpha = element.get("textAlpha", element.get("alpha", 1))
    if not isinstance(text_alpha, (int, float)):
        return False
    if font_size > GHOST_TEXT_MIN_FONT_SIZE and text_alpha < GHOST_TEXT_MAX_ALPHA:
        return True
    return font_size >= GHOST_TEXT_FAINT_MIN_FONT_SIZE and text_alpha < GHOST_TEXT_FAINT_MAX_ALPHA


def estimate_text_visual_bbox(element: dict[str, Any]) -> dict[str, int | float] | None:
    if not is_text_element(element) or not has_text_content(element) or is_decorative_text(element):
        return None

    padding_left = element.get("paddingLeft", 0)
    padding_right = element.get("paddingRight", 0)
    padding_top = element.get("paddingTop", 0)
    padding_bottom = element.get("paddingBottom", 0)
    content_width = max(element["width"] - padding_left - padding_right, 0)
    content_height = max(element["height"] - padding_top - padding_bottom, 0)
    font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
    line_count = estimate_text_line_count(element)
    estimated_width = max(1, estimate_text_max_line_width(element))
    visual_width = estimated_width if element.get("wrap") in {"false", "0"} else min(content_width, estimated_width)
    visual_height = min(content_height, max(1, line_count * font_size * 1.2))
    x = element["x"] + padding_left
    if element.get("textAlign") == "center":
        x += (content_width - visual_width) / 2
    elif element.get("textAlign") == "right":
        x += content_width - visual_width
    y = element["y"] + padding_top
    if element.get("verticalAlign") == "middle":
        y += (content_height - visual_height) / 2
    elif element.get("verticalAlign") == "bottom":
        y += content_height - visual_height
    return {
        "x": x,
        "y": y,
        "width": visual_width,
        "height": visual_height,
    }


def intersection_area(left: dict[str, Any], right: dict[str, Any]) -> int | float:
    width = min(left["x"] + left["width"], right["x"] + right["width"]) - max(left["x"], right["x"])
    height = min(left["y"] + left["height"], right["y"] + right["height"]) - max(left["y"], right["y"])
    if width <= 0 or height <= 0:
        return 0
    return width * height


def intersection_height(left: dict[str, Any], right: dict[str, Any]) -> int | float:
    height = min(left["y"] + left["height"], right["y"] + right["height"]) - max(left["y"], right["y"])
    return max(height, 0)


def intersection_width(left: dict[str, Any], right: dict[str, Any]) -> int | float:
    width = min(left["x"] + left["width"], right["x"] + right["width"]) - max(left["x"], right["x"])
    return max(width, 0)


def element_area(element: dict[str, Any]) -> int | float:
    return max(element["width"], 0) * max(element["height"], 0)


def contains(outer: dict[str, Any], inner: dict[str, Any], tolerance: int | float = 2) -> bool:
    return (
        inner["x"] >= outer["x"] - tolerance
        and inner["y"] >= outer["y"] - tolerance
        and inner["x"] + inner["width"] <= outer["x"] + outer["width"] + tolerance
        and inner["y"] + inner["height"] <= outer["y"] + outer["height"] + tolerance
    )


def is_bottom_layer_full_slide_whiteboard(
    whiteboard: dict[str, Any], other: dict[str, Any], slide_width: int | float, slide_height: int | float
) -> bool:
    return (
        whiteboard["order"] < other["order"]
        and whiteboard["x"] <= 2
        and whiteboard["y"] <= 2
        and whiteboard["width"] >= slide_width - 4
        and whiteboard["height"] >= slide_height - 4
    )


def is_background_container_for_whiteboard(container: dict[str, Any], whiteboard: dict[str, Any]) -> bool:
    if container["order"] > whiteboard["order"]:
        return False
    if is_text_element(container):
        return False
    return contains(container, whiteboard)


def is_template_text_stack(left: dict[str, Any], right: dict[str, Any]) -> bool:
    if not (is_text_element(left) and is_text_element(right)):
        return False
    if not (has_text_content(left) and has_text_content(right)):
        return True
    top, bottom = sorted([left, right], key=lambda element: element["y"])
    top_type = top.get("textType")
    bottom_type = bottom.get("textType")
    allowed_pairs = {
        ("title", "sub-headline"),
        ("title", None),
        ("headline", "headline"),
        ("headline", None),
    }
    if (top_type, bottom_type) not in allowed_pairs:
        return False
    same_column = abs(top["x"] - bottom["x"]) <= 4
    vertical_offset = bottom["y"] - top["y"]
    top_font_size = float(top.get("fontSize", 16))
    return same_column and vertical_offset >= top_font_size * 0.75


def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str, Any]) -> bool:
    if not (is_text_element(left) and is_text_element(right)):
        return False
    if not (has_text_content(left) and has_text_content(right)):
        return False
    if is_ghost_text(left) or is_ghost_text(right):
        return False
    if is_template_text_stack(left, right) or is_similar_text_overlay(left, right):
        return False

    source, target = sorted([left, right], key=lambda element: element["x"])
    if source["x"] == target["x"]:
        return False
    wrap_enabled = source.get("wrap") not in {"false", "0"}
    has_horizontal_gap = source["x"] + source["width"] <= target["x"]
    if wrap_enabled and has_horizontal_gap:
        return False
    if source.get("autoFit") == "normal-auto-fit":
        return False
    if source.get("textAlign") in {"center", "right"}:
        return False

    font_size = source["fontSize"] if isinstance(source["fontSize"], (int, float)) else 16
    padding_left = source.get("paddingLeft", 0)
    padding_right = source.get("paddingRight", 0)
    available_width = max(source["width"] - padding_left - padding_right, 1)
    visual_width = estimate_text_max_line_width(source)
    overflow_width = visual_width - available_width
    min_overflow = max(font_size * 1.5, available_width * 0.08)
    if overflow_width < min_overflow:
        return False

    intrusion_width = source["x"] + padding_left + visual_width - target["x"]
    min_intrusion = max(font_size * 1.5, target["width"] * 0.08)
    if intrusion_width < min_intrusion:
        return False

    vertical_overlap = intersection_height(source, target)
    min_vertical_overlap = min(source["height"], target["height"]) * 0.40
    return vertical_overlap >= min_vertical_overlap


def horizontal_text_overflow_measurement(left: dict[str, Any], right: dict[str, Any]) -> dict[str, int | float]:
    source, target = sorted([left, right], key=lambda element: element["x"])
    padding_left = source.get("paddingLeft", 0)
    visual_width = estimate_text_max_line_width(source)
    source_visual_bbox = {"x": source["x"] + padding_left, "y": source["y"], "width": visual_width, "height": source["height"]}
    width = intersection_width(source_visual_bbox, target)
    height = intersection_height(source_visual_bbox, target)
    return {
        "intersection_width": round(width, 3),
        "intersection_height": round(height, 3),
        "intersection_area": round(width * height, 3),
    }


def should_flag_overlap(left: dict[str, Any], right: dict[str, Any]) -> bool:
    if is_text_element(left) and not has_text_content(left):
        return False
    if is_text_element(right) and not has_text_content(right):
        return False
    if is_ghost_text(left) or is_ghost_text(right):
        return False
    if is_template_text_stack(left, right):
        return False
    if is_text_element(left) and is_text_element(right):
        if is_similar_text_overlay(left, right):
            return False
        left_visual = estimate_text_visual_bbox(left)
        right_visual = estimate_text_visual_bbox(right)
        if left_visual is None or right_visual is None:
            return False
        overlap_area = intersection_area(left_visual, right_visual)
        if overlap_area <= 0:
            return False
        smaller_area = min(
            left_visual["width"] * left_visual["height"],
            right_visual["width"] * right_visual["height"],
        )
        return smaller_area > 0 and overlap_area / smaller_area >= 0.30
    return False


def build_whiteboard_external_overlap_issue(
    whiteboard: dict[str, Any], overlap_details: list[dict[str, Any]]
) -> dict[str, Any]:
    element_refs = [detail["element"] for detail in overlap_details]
    return {
        "level": "warning",
        "code": "whiteboard_external_overlap",
        "elements": [element_ref(whiteboard), *element_refs],
        "message": (
            f"whiteboard {element_label(whiteboard)} overlaps {len(element_refs)} "
            "sibling elements across its boundary"
        ),
        "hint": (
            "Treat this as a static whiteboard container-bbox risk, not final visual proof. "
            "After moving or accepting the overlap, use screenshot QA or equivalent rendered visual inspection as "
            "the final authority because XML readback does not include whiteboard SVG/Mermaid internals."
        ),
        "overlaps": overlap_details,
    }


def should_report_whiteboard_overlap(
    whiteboard: dict[str, Any],
    other: dict[str, Any],
    slide_width: int | float,
    slide_height: int | float,
) -> dict[str, Any] | None:
    if other is whiteboard or not intersects(whiteboard, other):
        return None
    if is_ghost_text(other):
        return None
    if contains(whiteboard, other):
        return None
    if is_bottom_layer_full_slide_whiteboard(whiteboard, other, slide_width, slide_height):
        return None
    if is_background_container_for_whiteboard(other, whiteboard):
        return None

    overlap_width = intersection_width(whiteboard, other)
    overlap_height = intersection_height(whiteboard, other)
    if overlap_width < 8 or overlap_height < 8:
        return None

    other_area = element_area(other)
    if other_area <= 0:
        return None
    overlap_area = overlap_width * overlap_height
    overlap_ratio = overlap_area / other_area
    if overlap_ratio < 0.15:
        return None

    return {
        "element": element_ref(other),
        "kind": other["kind"],
        "type": other.get("type"),
        "overlap_width": overlap_width,
        "overlap_height": overlap_height,
        "target_overlap_ratio": round(overlap_ratio, 3),
    }


def prune_contained_text_overlap_details(
    overlap_details: list[dict[str, Any]], elements_by_ref: dict[str, dict[str, Any]]
) -> list[dict[str, Any]]:
    pruned: list[dict[str, Any]] = []
    for detail in overlap_details:
        element = elements_by_ref[detail["element"]]
        if is_text_element(element):
            has_reported_container = any(
                detail["element"] != other_detail["element"]
                and not is_text_element(elements_by_ref[other_detail["element"]])
                and contains(elements_by_ref[other_detail["element"]], element)
                for other_detail in overlap_details
            )
            if has_reported_container:
                continue
        pruned.append(detail)
    return pruned


def detect_whiteboard_external_overlaps(
    elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
) -> list[dict[str, Any]]:
    issues: list[dict[str, Any]] = []
    elements_by_ref = {element_ref(element): element for element in elements}
    for whiteboard in [element for element in elements if is_whiteboard_element(element)]:
        overlap_details = [
            detail
            for element in elements
            if (
                detail := should_report_whiteboard_overlap(
                    whiteboard,
                    element,
                    slide_width,
                    slide_height,
                )
            )
            is not None
        ]
        overlap_details = prune_contained_text_overlap_details(overlap_details, elements_by_ref)
        if overlap_details:
            issues.append(build_whiteboard_external_overlap_issue(whiteboard, overlap_details))
    return issues


def element_canvas_bbox(element: dict[str, Any]) -> dict[str, int | float]:
    bbox = {key: element[key] for key in ("x", "y", "width", "height")}
    if element["kind"] != "chart" and not (element["kind"] == "shape" and element["type"] == "text"):
        return bbox
    rotation = element["rotation"]
    if not isinstance(rotation, (int, float)) or not math.isfinite(rotation):
        rotation = 0
    rotation %= 360
    if math.isclose(rotation, 0, abs_tol=1e-9):
        return bbox
    radians = math.radians(rotation)
    sine = abs(math.sin(radians))
    cosine = abs(math.cos(radians))
    sine = 0 if math.isclose(sine, 0, abs_tol=1e-12) else sine
    cosine = 0 if math.isclose(cosine, 0, abs_tol=1e-12) else cosine
    rotated_width = element["width"] * cosine + element["height"] * sine
    rotated_height = element["width"] * sine + element["height"] * cosine
    return {
        "x": element["x"] - (rotated_width - element["width"]) / 2,
        "y": element["y"] - (rotated_height - element["height"]) / 2,
        "width": rotated_width,
        "height": rotated_height,
    }


def detect_elements_out_of_canvas(
    elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
) -> list[dict[str, Any]]:
    issues: list[dict[str, Any]] = []
    for element in (
        element
        for element in elements
        if element["kind"] in {"table", "chart"}
        or (element["kind"] == "shape" and element["type"] in {"rect", "text"})
    ):
        bbox = element_canvas_bbox(element)
        overflow = {
            "left": max(-bbox["x"], 0),
            "top": max(-bbox["y"], 0),
            "right": max(bbox["x"] + bbox["width"] - slide_width, 0),
            "bottom": max(bbox["y"] + bbox["height"] - slide_height, 0),
        }
        overflow_details = [
            f"{side} by {amount:g}px"
            for side, amount in overflow.items()
            if amount > CANVAS_OVERFLOW_TOLERANCE
        ]
        if not overflow_details:
            continue
        issues.append(
            {
                "level": "error",
                "code": f'{element["kind"]}_out_of_canvas',
                "elements": [element_ref(element)],
                "canvas": {"width": slide_width, "height": slide_height},
                "bbox": bbox,
                "overflow": overflow,
                "message": (
                    f'{element["kind"]} {element_label(element)} exceeds the {slide_width:g}x{slide_height:g} canvas '
                    f'({", ".join(overflow_details)})'
                ),
                "hint": (
                    "Move the table inside the canvas, reduce table.width/table.height, or split the table across "
                    "slides."
                    if element["kind"] == "table"
                    else f'Move the {element["kind"]} inside the canvas or reduce its width/height.'
                ),
            }
        )
    return issues


def extract_table_column_sizes(table_xml: str) -> list[int | float | None]:
    sizes: list[int | float | None] = []
    for match in re.finditer(r"<col\b([^>]*)/?>", table_xml):
        attrs = match.group(1)
        span = extract_numeric_attribute(attrs, "span") or 1
        span_count = int(span) if math.isfinite(span) and span > 0 and float(span).is_integer() else 1
        sizes.extend([extract_numeric_attribute(attrs, "width")] * span_count)
    return sizes


def extract_table_row_sizes(table_xml: str) -> list[int | float | None]:
    return [extract_numeric_attribute(match.group(1), "height") for match in re.finditer(r"<tr\b([^>]*)>", table_xml)]


def resolve_table_dimension(
    table_xml: str,
    declared_size: int | float | None,
    extract_sizes: Any,
    default_size: int | float,
) -> tuple[int | float | None, dict[str, Any] | None]:
    input_sizes = extract_sizes(table_xml)
    if not input_sizes:
        return declared_size, None
    layout = solve_weighted_min_layout(
        input_sizes, default_size, declared_size if is_filled_size(declared_size) else None
    )
    return layout["actual_size"], layout


def format_size(size: int | float) -> str:
    return f"{size:g}"


def detect_table_layout_size_mismatches(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
    issues: list[dict[str, Any]] = []
    dimensions = {
        "width": ("col", "column widths"),
        "height": ("tr", "row heights"),
    }
    for table in (element for element in elements if element["kind"] == "table"):
        for dimension, (child_tag, child_description) in dimensions.items():
            target_size = table[f"declared_{dimension}"]
            if not is_filled_size(target_size):
                continue
            layout = table["table_layouts"][dimension]
            if layout is None:
                continue
            actual_size = layout["actual_size"]
            if math.isclose(actual_size, target_size, rel_tol=1e-9, abs_tol=1e-9):
                continue
            issues.append(
                {
                    "level": "info",
                    "code": "table_resolved_size_mismatch",
                    "elements": [element_ref(table)],
                    "dimension": dimension,
                    "declared_size": target_size,
                    "resolved_size": actual_size,
                    "resolved_sizes": layout["final_sizes"],
                    "message": (
                        f'table {element_label(table)} declares {dimension}={format_size(target_size)}px, but its '
                        f"{child_description} resolve to {format_size(actual_size)}px"
                    ),
                    "hint": (
                        f"Set table.{dimension} to {format_size(actual_size)}px, or adjust <{child_tag}> sizes "
                        f"so their resolved total matches {format_size(target_size)}px."
                    ),
                }
            )
    return issues


def segment_intersects_rect(
    x1: float, y1: float, x2: float, y2: float, rect: dict[str, int | float]
) -> bool:
    """True when segment (x1,y1)-(x2,y2) enters the axis-aligned rect (Liang-Barsky clip)."""
    left = rect["x"]
    top = rect["y"]
    right = rect["x"] + rect["width"]
    bottom = rect["y"] + rect["height"]
    if right <= left or bottom <= top:
        return False
    dx = x2 - x1
    dy = y2 - y1
    if dx == 0 and dy == 0:
        return left <= x1 <= right and top <= y1 <= bottom
    t_enter, t_exit = 0.0, 1.0
    for delta, distance in ((-dx, x1 - left), (dx, right - x1), (-dy, y1 - top), (dy, bottom - y1)):
        if delta == 0:
            if distance < 0:
                return False
            continue
        t = distance / delta
        if delta < 0:
            t_enter = max(t_enter, t)
        else:
            t_exit = min(t_exit, t)
        if t_enter > t_exit:
            return False
    return True


def line_text_graze_margin(text_element: dict[str, Any]) -> float:
    font_size = text_element["fontSize"] if isinstance(text_element.get("fontSize"), (int, float)) else 16
    return max(font_size * LINE_TEXT_GRAZE_FONT_RATIO, LINE_TEXT_GRAZE_MIN_PX)


def erode_rect(rect: dict[str, int | float], margin: float) -> dict[str, int | float] | None:
    width = rect["width"] - 2 * margin
    height = rect["height"] - 2 * margin
    if width <= 0 or height <= 0:
        return None
    return {"x": rect["x"] + margin, "y": rect["y"] + margin, "width": width, "height": height}


def line_crosses_text(line: dict[str, Any], text_element: dict[str, Any]) -> bool:
    if not is_visually_rendered(line) or line.get("alpha", 1) < LINE_MIN_VISIBLE_ALPHA:
        return False
    if not is_text_element(text_element) or not has_text_content(text_element):
        return False
    if is_ghost_text(text_element) or is_decorative_text(text_element):
        return False
    glyph_bbox = estimate_text_visual_bbox(text_element)
    if glyph_bbox is None:
        return False
    # Erode the glyph box so a line skimming the letter edge or only clipping the padding-only text
    # frame is exempt; only a line that actually cuts through the letterforms is a crossing.
    target = erode_rect(glyph_bbox, line_text_graze_margin(text_element))
    if target is None:
        return False
    return segment_intersects_rect(
        line["startX"], line["startY"], line["endX"], line["endY"], target
    )


def detect_line_text_crossings(
    slide_xml: str, elements: list[dict[str, Any]], slide_number: int
) -> list[dict[str, Any]]:
    lines = extract_line_elements(slide_xml)
    if not lines:
        return []
    attach_source_xml_paths(lines, build_source_xml_paths(slide_xml, slide_number))
    text_elements = [element for element in elements if is_text_element(element)]
    issues: list[dict[str, Any]] = []
    for line in lines:
        for text_element in text_elements:
            if not line_crosses_text(line, text_element):
                continue
            issues.append(
                {
                    "level": "error",
                    "code": "bbox_overlap",
                    "elements": [element_ref(line), element_ref(text_element)],
                    "message": (
                        f"line {element_label(line)} crosses text {element_label(text_element)}"
                    ),
                    "hint": "Move the line off the text glyphs so it no longer cuts through the letterforms.",
                }
            )
    return issues


def lint_slide(
    slide_xml: str, slide_number: int, slide_width: int | float = 960, slide_height: int | float = 540
) -> dict[str, Any]:
    elements = extract_elements(slide_xml)
    attach_source_xml_paths(elements, build_source_xml_paths(slide_xml, slide_number))
    issues: list[dict[str, Any]] = [
        *detect_whiteboard_external_overlaps(elements, slide_width, slide_height),
        *detect_elements_out_of_canvas(elements, slide_width, slide_height),
        *detect_table_layout_size_mismatches(elements),
        *detect_text_may_overflow_shapes(elements),
        *detect_image_text_occlusions(elements),
        *detect_line_text_crossings(slide_xml, elements, slide_number),
    ]

    for index, left in enumerate(elements):
        for right in elements[index + 1 :]:
            horizontal_overflow = should_flag_horizontal_text_overflow(left, right)
            if not horizontal_overflow and (not intersects(left, right) or not should_flag_overlap(left, right)):
                continue
            issues.append(
                {
                    "level": "error",
                    "code": "bbox_overlap",
                    "elements": [element_ref(left), element_ref(right)],
                    "message": f"{element_label(left)} overlaps {element_label(right)}",
                    "hint": "Move or resize the elements so their visual bounds no longer intersect.",
                    **(
                        {"measurement": horizontal_text_overflow_measurement(left, right)}
                        if horizontal_overflow
                        else {}
                    ),
                }
            )

    return {
        "slide_number": slide_number,
        "element_count": len(elements),
        "elements": elements,
        "issues": issues,
    }



MIN_CONTAINER_WIDTH = 140
MIN_CONTAINER_HEIGHT = 160
MIN_SHORT_CARD_HEIGHT = 80
MIN_CONTAINER_AREA = 20_000
MIN_CONTENT_COVERAGE_RATIO = 0.15
MIN_SLIDE_CONTENT_COVERAGE_RATIO = 0.035
MIN_SLIDE_CONTENT_ELEMENT_COUNT = 4
SHORT_CARD_SIZE_TOLERANCE_RATIO = 0.10
MIN_SIMILAR_SHORT_CARD_COUNT = 2
LARGE_VISUAL_CHILD_RATIO = 0.35
LAYOUT_PANEL_SPAN_RATIO = 0.90
IMAGE_OVERLAY_MATCH_RATIO = 0.90
DENSITY_CONTAINMENT_TOLERANCE = 8


def clipped_bbox(element: dict[str, Any], container: dict[str, Any]) -> dict[str, int | float] | None:
    left = max(element["x"], container["x"])
    top = max(element["y"], container["y"])
    right = min(element["x"] + element["width"], container["x"] + container["width"])
    bottom = min(element["y"] + element["height"], container["y"] + container["height"])
    if right <= left or bottom <= top:
        return None
    return {"x": left, "y": top, "width": right - left, "height": bottom - top}


def rectangle_union_area(rectangles: list[dict[str, int | float]]) -> int | float:
    x_coordinates = sorted({coordinate for rect in rectangles for coordinate in (rect["x"], rect["x"] + rect["width"])})
    area = 0
    for left, right in zip(x_coordinates, x_coordinates[1:]):
        intervals = sorted(
            (rect["y"], rect["y"] + rect["height"])
            for rect in rectangles
            if rect["x"] < right and rect["x"] + rect["width"] > left
        )
        covered_height = 0
        interval_end: int | float | None = None
        for top, bottom in intervals:
            if interval_end is None:
                covered_height += bottom - top
                interval_end = bottom
            elif bottom > interval_end:
                covered_height += bottom - max(top, interval_end)
                interval_end = bottom
        area += (right - left) * covered_height
    return area


def has_similar_short_card_peer(element: dict[str, Any], elements: list[dict[str, Any]]) -> bool:
    return sum(
        other is not element
        and is_visually_rendered(other)
        and other["kind"] == "shape"
        and other["type"] == "rect"
        and other["width"] >= MIN_CONTAINER_WIDTH
        and other["height"] >= MIN_SHORT_CARD_HEIGHT
        and element_area(other) >= MIN_CONTAINER_AREA
        and abs(other["width"] - element["width"]) / max(other["width"], element["width"])
        <= SHORT_CARD_SIZE_TOLERANCE_RATIO
        and abs(other["height"] - element["height"]) / max(other["height"], element["height"])
        <= SHORT_CARD_SIZE_TOLERANCE_RATIO
        for other in elements
    ) >= MIN_SIMILAR_SHORT_CARD_COUNT


def is_layout_container(
    element: dict[str, Any],
    slide_width: int | float,
    slide_height: int | float,
    elements: list[dict[str, Any]] | None = None,
) -> bool:
    has_supported_height = element["height"] >= MIN_CONTAINER_HEIGHT or (
        elements is not None
        and element["height"] >= MIN_SHORT_CARD_HEIGHT
        and has_similar_short_card_peer(element, elements)
    )
    return (
        element["kind"] == "shape"
        and element["type"] == "rect"
        and is_visually_rendered(element)
        and element["width"] >= MIN_CONTAINER_WIDTH
        and has_supported_height
        and element_area(element) >= MIN_CONTAINER_AREA
        and not (
            element["x"] <= 2
            and element["y"] <= 2
            and element["width"] >= slide_width - 4
            and element["height"] >= slide_height - 4
        )
    )


def is_edge_spanning_layout_panel(
    element: dict[str, Any], slide_width: int | float, slide_height: int | float
) -> bool:
    touches_horizontal_edge = element["x"] <= 2 or element["x"] + element["width"] >= slide_width - 2
    touches_vertical_edge = element["y"] <= 2 or element["y"] + element["height"] >= slide_height - 2
    return (touches_horizontal_edge and element["height"] >= slide_height * LAYOUT_PANEL_SPAN_RATIO) or (
        touches_vertical_edge and element["width"] >= slide_width * LAYOUT_PANEL_SPAN_RATIO
    )


def has_matching_image_overlay(container: dict[str, Any], elements: list[dict[str, Any]]) -> bool:
    container_area = element_area(container)
    return any(
        element["kind"] == "img"
        and is_visually_rendered(element)
        and intersection_area(container, element) / max(1, container_area) >= IMAGE_OVERLAY_MATCH_RATIO
        for element in elements
    )


def is_nested_in_layout_panel(
    container: dict[str, Any], elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
) -> bool:
    return any(
        element is not container
        and element["kind"] == "shape"
        and element["type"] == "rect"
        and is_visually_rendered(element)
        and is_edge_spanning_layout_panel(element, slide_width, slide_height)
        and contains(element, container, tolerance=DENSITY_CONTAINMENT_TOLERANCE)
        for element in elements
    )


def extract_density_elements(slide_xml: str, slide_number: int = 1) -> list[dict[str, Any]]:
    elements = extract_elements(slide_xml)
    source_paths = build_source_xml_paths(slide_xml, slide_number)
    attach_source_xml_paths(elements, source_paths)
    shape_elements_by_index = {
        element["_source_kind_index"]: element
        for element in elements
        if element["kind"] == "shape"
    }
    root = ET.fromstring(slide_xml)
    shape_index = 0
    for node in root.iter():
        if xml_local_name(node.tag) != "shape":
            continue
        shape_index += 1
        element = shape_elements_by_index.get(shape_index)
        if element is None:
            continue
        content_node = next(
            (child for child in node if xml_local_name(child.tag) == "content"),
            None,
        )
        paragraphs = (
            [
                " ".join("".join(paragraph.itertext()).split())
                for paragraph in content_node.iter()
                if xml_local_name(paragraph.tag) == "p"
            ]
            if content_node is not None
            else []
        )
        raw_font_size = (
            content_node.attrib.get("fontSize") if content_node is not None else None
        ) or node.attrib.get("fontSize")
        try:
            base_font_size = float(raw_font_size or 16)
        except ValueError:
            base_font_size = 16.0
        element.update(
            {
                "textType": content_node.attrib.get("textType") if content_node is not None else None,
                "textAlign": content_node.attrib.get("textAlign") if content_node is not None else None,
                "autoFit": content_node.attrib.get("autoFit") if content_node is not None else None,
                "fontSize": base_font_size,
                "text": "\n".join(paragraph for paragraph in paragraphs if paragraph),
            }
        )
        if not has_text_content(element):
            continue
        declared_font_sizes = []
        for descendant in node.iter():
            raw_declared_font_size = descendant.attrib.get("fontSize")
            if raw_declared_font_size is None:
                continue
            try:
                declared_font_sizes.append(float(raw_declared_font_size))
            except ValueError:
                continue
        if declared_font_sizes:
            element["fontSize"] = max(declared_font_sizes)
    for source_kind_index, match in enumerate(
        re.finditer(r"<icon\b([^>]*)>", slide_xml), start=1
    ):
        attrs = match.group(1)
        source_id = extract_attribute(attrs, "id") or None
        x = extract_numeric_attribute(attrs, "topLeftX")
        y = extract_numeric_attribute(attrs, "topLeftY")
        width = extract_numeric_attribute(attrs, "width")
        height = extract_numeric_attribute(attrs, "height")
        if any(value is None for value in (x, y, width, height)):
            continue
        icon_alpha = extract_numeric_attribute(attrs, "alpha")
        elements.append(
            {
                "id": source_id or f"icon-{len(elements) + 1}",
                "_source_id": source_id,
                "kind": "icon",
                "type": "icon",
                "x": x,
                "y": y,
                "width": width,
                "height": height,
                "rotation": extract_numeric_attribute(attrs, "rotation") or 0,
                "alpha": icon_alpha if icon_alpha is not None else 1,
                "order": len(elements),
                "_source_kind_index": source_kind_index,
            }
        )
    for source_kind_index, match in enumerate(
        re.finditer(r"<polyline\b([^>]*)>", slide_xml), start=1
    ):
        attrs = match.group(1)
        x = extract_numeric_attribute(attrs, "topLeftX")
        y = extract_numeric_attribute(attrs, "topLeftY")
        width = extract_numeric_attribute(attrs, "width")
        height = extract_numeric_attribute(attrs, "height")
        if any(value is None for value in (x, y, width, height)):
            continue
        polyline_alpha = extract_numeric_attribute(attrs, "alpha")
        source_id = extract_attribute(attrs, "id") or None
        elements.append(
            {
                "id": source_id or f"polyline-{len(elements) + 1}",
                "_source_id": source_id,
                "kind": "polyline",
                "type": "polyline",
                "x": x,
                "y": y,
                "width": width,
                "height": height,
                "rotation": extract_numeric_attribute(attrs, "rotation") or 0,
                "alpha": polyline_alpha if polyline_alpha is not None else 1,
                "order": len(elements),
                "_source_kind_index": source_kind_index,
            }
        )
    for line_element in extract_line_elements(slide_xml):
        line_element["order"] = len(elements)
        elements.append(line_element)
    attach_source_xml_paths(elements, source_paths)
    for element in elements:
        element["_slide_number"] = slide_number
    return elements


def is_visually_rendered(element: dict[str, Any]) -> bool:
    return element.get("alpha", 1) > 0


def visual_bbox(element: dict[str, Any], container: dict[str, Any]) -> dict[str, int | float] | None:
    if not is_visually_rendered(element):
        return None
    if is_text_element(element):
        estimated = estimate_text_visual_bbox(element)
        return clipped_bbox(estimated, container) if estimated else None
    return clipped_bbox(element, container)


def own_text_visual_bbox(container: dict[str, Any]) -> dict[str, int | float] | None:
    if container["kind"] != "shape" or not has_text_content(container):
        return None
    text_proxy = {**container, "type": "text"}
    estimated = estimate_text_visual_bbox(text_proxy)
    return clipped_bbox(estimated, container) if estimated else None


def slide_content_visual_bbox(
    element: dict[str, Any], slide_bbox: dict[str, int | float]
) -> dict[str, int | float] | None:
    if not is_visually_rendered(element):
        return None
    if is_text_element(element):
        estimated = estimate_text_visual_bbox(element)
        return clipped_bbox(estimated, slide_bbox) if estimated else None
    if element["kind"] == "shape" and has_text_content(element):
        estimated = own_text_visual_bbox(element)
        return clipped_bbox(estimated, slide_bbox) if estimated else None
    if element["kind"] == "line":
        # a straight horizontal/vertical line has zero width or height in one axis; clipped_bbox
        # treats zero-area rects as invisible, so pad to its rendered stroke thickness instead.
        return clipped_bbox(line_stroke_bbox(element), slide_bbox)
    if element["kind"] in {"img", "chart", "table", "whiteboard", "embed", "icon", "polyline"}:
        return clipped_bbox(element, slide_bbox)
    return None


def line_stroke_bbox(element: dict[str, Any]) -> dict[str, Any]:
    return {**element, "width": max(element["width"], 1), "height": max(element["height"], 1)}


def is_slide_content_present(
    element: dict[str, Any], slide_bbox: dict[str, int | float]
) -> bool:
    # Deliberately permissive, unlike slide_content_visual_bbox: blank_slide is asking "is
    # *anything* rendered here", not the richer "counts toward meaningful content density" bar
    # that sparse_slide_content/sparse_container_content apply. A plain shape with no text (a
    # decorative rect/ellipse/etc.), <undefined>, or any future SXSD data element should all
    # count here — deny-list only what's actually invisible (alpha<=0 or zero on-canvas area)
    # instead of maintaining an allow-list that silently treats unlisted kinds as blank.
    if not is_visually_rendered(element):
        return False
    if (
        element["kind"] == "shape"
        and element["type"] == "rect"
        and not has_text_content(element)
        and element["x"] <= 2
        and element["y"] <= 2
        and element["width"] >= slide_bbox["width"] - 4
        and element["height"] >= slide_bbox["height"] - 4
    ):
        # A full-canvas plain rect is a background panel, not content -- same reasoning as
        # is_layout_container's existing background exclusion. A slide with nothing else on it
        # is still effectively blank.
        return False
    bbox = line_stroke_bbox(element) if element["kind"] == "line" else element
    return clipped_bbox(bbox, slide_bbox) is not None


def is_large_visual_child(element: dict[str, Any], container: dict[str, Any]) -> bool:
    if element["kind"] not in {"img", "chart", "table", "whiteboard", "embed"}:
        return False
    if not is_visually_rendered(element):
        return False
    return element_area(element) / element_area(container) >= LARGE_VISUAL_CHILD_RATIO


def detect_sparse_container_content(
    elements: list[dict[str, Any]], slide_number: int, slide_width: int | float, slide_height: int | float
) -> list[dict[str, Any]]:
    issues: list[dict[str, Any]] = []
    for container in (
        element for element in elements if is_layout_container(element, slide_width, slide_height, elements)
    ):
        if (
            is_edge_spanning_layout_panel(container, slide_width, slide_height)
            or is_nested_in_layout_panel(container, elements, slide_width, slide_height)
            or has_matching_image_overlay(container, elements)
        ):
            continue
        children = [
            element
            for element in elements
            if element is not container
            and contains(container, element, tolerance=DENSITY_CONTAINMENT_TOLERANCE)
        ]
        if any(is_large_visual_child(child, container) for child in children):
            continue
        own_text_bbox = own_text_visual_bbox(container)
        rectangles = ([own_text_bbox] if own_text_bbox else []) + [
            bbox for child in children if (bbox := visual_bbox(child, container)) is not None
        ]
        content_area = rectangle_union_area(rectangles) if rectangles else 0
        coverage_ratio = content_area / element_area(container)
        if coverage_ratio >= MIN_CONTENT_COVERAGE_RATIO:
            continue
        issues.append(
            {
                "level": "warning",
                "code": "sparse_container_content",
                "target": {
                    "slide_number": slide_number,
                    **(
                        {"container_id": source_element_id(container)}
                        if source_element_id(container) is not None
                        else {}
                    ),
                    "container_xml_path": element_ref(container),
                    "container_type": container["type"],
                    "bbox": {key: container[key] for key in ("x", "y", "width", "height")},
                },
                "rule": {
                    "name": "large_container_visible_content_coverage",
                    "threshold": MIN_CONTENT_COVERAGE_RATIO,
                    "comparison": "content_coverage_ratio < threshold",
                },
                "measurement": {
                    "container_area": element_area(container),
                    "visible_content_area": round(content_area, 3),
                    "content_coverage_ratio": round(coverage_ratio, 3),
                    "content_element_count": len(children) + (1 if own_text_bbox else 0),
                },
                "elements": [
                    element_ref(container),
                    *[element_ref(child) for child in children],
                ],
            }
        )
    return issues


def detect_sparse_slide_content(
    elements: list[dict[str, Any]], slide_number: int, slide_width: int | float, slide_height: int | float
) -> list[dict[str, Any]]:
    slide_bbox = {"x": 0, "y": 0, "width": slide_width, "height": slide_height}
    content = [
        (element, bbox)
        for element in elements
        if (bbox := slide_content_visual_bbox(element, slide_bbox)) is not None
    ]
    if len(content) < MIN_SLIDE_CONTENT_ELEMENT_COUNT:
        return []
    content_area = rectangle_union_area([bbox for _, bbox in content])
    slide_area = slide_width * slide_height
    coverage_ratio = content_area / slide_area
    if coverage_ratio >= MIN_SLIDE_CONTENT_COVERAGE_RATIO:
        return []
    return [
        {
            "level": "warning",
            "code": "sparse_slide_content",
            "target": {
                "slide_number": slide_number,
                "bbox": slide_bbox,
            },
            "rule": {
                "name": "slide_visible_content_coverage",
                "threshold": MIN_SLIDE_CONTENT_COVERAGE_RATIO,
                "comparison": "content_coverage_ratio < threshold",
            },
            "measurement": {
                "slide_area": slide_area,
                "visible_content_area": round(content_area, 3),
                "content_coverage_ratio": round(coverage_ratio, 3),
                "content_element_count": len(content),
            },
            "elements": [element_ref(element) for element, _ in content],
        }
    ]


def detect_blank_slide(
    elements: list[dict[str, Any]],
    slide_number: int,
    slide_width: int | float,
    slide_height: int | float,
) -> list[dict[str, Any]]:
    slide_bbox = {"x": 0, "y": 0, "width": slide_width, "height": slide_height}
    visible_elements = [
        element for element in elements if is_slide_content_present(element, slide_bbox)
    ]
    if visible_elements:
        return []
    return [
        {
            "level": "error",
            "code": "blank_slide",
            "schema_version": "2.0",
            "target": {"slide_number": slide_number},
            "rule": {
                "name": "slide_has_visible_content",
                "comparison": "visible_element_count == 0",
            },
            "measurement": {
                "visible_element_count": 0,
                "declared_element_count": len(elements),
            },
            "elements": [element_ref(element) for element in elements],
            "message": "slide has no visible content beyond empty layout shapes",
            "hint": "Add visible text, an image, a chart, a table, a whiteboard, or an icon before creating the slide.",
        }
    ]


def detect_duplicate_element_ids(
    elements: list[dict[str, Any]], *, cross_slide_only: bool = False
) -> list[dict[str, Any]]:
    elements_by_source_id: dict[str, list[dict[str, Any]]] = {}
    for element in elements:
        source_id = source_element_id(element)
        if source_id is not None:
            elements_by_source_id.setdefault(source_id, []).append(element)
    return [
        {
            "level": "error",
            "code": "duplicate_element_id",
            "elements": [element_ref(element) for element in duplicates],
            "measurement": {
                "element_id": source_id,
                "duplicate_count": len(duplicates),
            },
            "message": f'element id "{source_id}" is used by {len(duplicates)} elements',
            "hint": (
                "Do not invent replacement IDs. For newly authored elements, remove the id attribute. "
                "When updating read-back XML, keep the server ID on the original element only and remove it "
                "from copied or new elements."
            ),
        }
        for source_id, duplicates in elements_by_source_id.items()
        if len(duplicates) > 1
        and (
            not cross_slide_only
            or len({element.get("_slide_number") for element in duplicates}) > 1
        )
    ]


RULE_METADATA: dict[str, dict[str, Any]] = {
    "xml_not_well_formed": {
        "name": "xml_is_well_formed",
        "comparison": "xml_parse_error == false",
    },
    "sml_prefixed_tag": {
        "name": "sml_uses_default_namespace",
        "comparison": "prefixed_sml_tag_count == 0",
    },
    "sxsd_unsupported_tag": {
        "name": "tag_is_supported_by_slides_xml_schema",
        "comparison": "unsupported_tag_count == 0",
    },
    "sxsd_unsupported_attr": {
        "name": "attribute_is_supported_by_slides_xml_schema",
        "comparison": "unsupported_attribute_count == 0",
    },
    "icon_missing_fill_color": {
        "name": "icon_has_visible_fill_color",
        "comparison": "fill_color_present == true",
    },
    "icon_transparent_fill_color": {
        "name": "icon_has_visible_fill_color",
        "comparison": "fill_alpha > 0",
    },
    "iconpark_unsupported_icon_type": {
        "name": "iconpark_type_is_supported",
        "comparison": "icon_type in iconpark_index",
    },
    "bbox_overlap": {
        "name": "text_visual_bounds_do_not_overlap",
        "comparison": "intersection_area == 0",
    },
    "text_may_overflow_shape": {
        "name": "estimated_text_fits_declared_shape",
        "comparison": "estimated_height <= available_height",
    },
    "whiteboard_external_overlap": {
        "name": "whiteboard_does_not_cross_sibling_content",
        "comparison": "external_overlap_count == 0",
    },
    "image_covers_text": {
        "name": "image_does_not_cover_text",
        "comparison": "intersection_area == 0",
    },
    "image_may_cover_vertical_text": {
        "name": "image_vertical_text_occlusion_requires_review",
        "comparison": "intersection_area == 0",
    },
    "table_resolved_size_mismatch": {
        "name": "table_declared_size_matches_resolved_grid",
        "comparison": "declared_size == resolved_size",
    },
    "blank_slide": {
        "name": "slide_has_visible_content",
        "comparison": "visible_element_count > 0",
    },
    "duplicate_element_id": {
        "name": "element_ids_are_unique",
        "comparison": "duplicate_count == 0",
    },
}


def issue_rule(issue: dict[str, Any]) -> dict[str, Any]:
    if issue.get("rule"):
        return {**issue["rule"], "id": issue["code"]}
    if issue["code"].endswith("_out_of_canvas"):
        return {
            "id": issue["code"],
            "name": "element_stays_within_slide_canvas",
            "comparison": "max(left, top, right, bottom overflow) == 0",
        }
    return {
        "id": issue["code"],
        **RULE_METADATA.get(
            issue["code"],
            {"name": issue["code"], "comparison": "violation_count == 0"},
        ),
    }


def issue_measurement(
    issue: dict[str, Any], elements_by_ref: dict[str, dict[str, Any]]
) -> dict[str, Any]:
    if issue.get("measurement") is not None:
        return issue["measurement"]
    if issue["code"] == "bbox_overlap" and len(issue.get("elements", [])) == 2:
        left = elements_by_ref.get(issue["elements"][0])
        right = elements_by_ref.get(issue["elements"][1])
        if left and right:
            left_box = (estimate_text_visual_bbox(left) if is_text_element(left) else None) or left
            right_box = (estimate_text_visual_bbox(right) if is_text_element(right) else None) or right
            width = intersection_width(left_box, right_box)
            height = intersection_height(left_box, right_box)
            return {
                "intersection_width": round(width, 3),
                "intersection_height": round(height, 3),
                "intersection_area": round(width * height, 3),
            }
    if issue["code"].endswith("_out_of_canvas"):
        return {
            "canvas": issue.get("canvas"),
            "bbox": issue.get("bbox"),
            "overflow": issue.get("overflow"),
        }
    measurement_keys = (
        "line",
        "column",
        "tag",
        "attr",
        "iconType",
        "line_count",
        "line_height",
        "estimated_height",
        "available_height",
        "overflow",
        "dimension",
        "declared_size",
        "resolved_size",
        "resolved_sizes",
        "overlaps",
    )
    measured = {key: issue[key] for key in measurement_keys if key in issue}
    return measured or {"violation_count": 1}


def related_object(element: dict[str, Any]) -> dict[str, Any]:
    related = {
        "kind": element["kind"],
        "type": element["type"],
    }
    bbox_keys = ("x", "y", "width", "height")
    if all(key in element for key in bbox_keys):
        related["bbox"] = {key: element[key] for key in bbox_keys}
    if source_element_id(element) is not None:
        related["element_id"] = source_element_id(element)
    if element.get("xml_path"):
        related["xml_path"] = element["xml_path"]
    return related


def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
    elements: list[dict[str, Any]] = []
    for source_kind_index, match in enumerate(
        re.finditer(r"<line\b([^>]*?)(/?)>", slide_xml), start=1
    ):
        attrs = match.group(1)
        source_id = extract_attribute(attrs, "id") or None
        start_x = extract_numeric_attribute(attrs, "startX")
        start_y = extract_numeric_attribute(attrs, "startY")
        end_x = extract_numeric_attribute(attrs, "endX")
        end_y = extract_numeric_attribute(attrs, "endY")
        if any(value is None for value in (start_x, start_y, end_x, end_y)):
            continue
        line_alpha = extract_numeric_attribute(attrs, "alpha")
        base_alpha = line_alpha if line_alpha is not None else 1
        border_alpha = 1
        if match.group(2) != "/":
            close_index = slide_xml.find("</line>", match.end())
            body = slide_xml[match.end() : close_index] if close_index != -1 else ""
            border_attrs = extract_tag_attributes(body, "border")
            color_alpha = extract_color_alpha(extract_attribute(border_attrs, "color"))
            if isinstance(color_alpha, (int, float)):
                border_alpha = color_alpha
        elements.append(
            {
                "id": source_id or f"line-{len(elements) + 1}",
                "_source_id": source_id,
                "kind": "line",
                "type": "line",
                "x": min(start_x, end_x),
                "y": min(start_y, end_y),
                "width": abs(end_x - start_x),
                "height": abs(end_y - start_y),
                "startX": start_x,
                "startY": start_y,
                "endX": end_x,
                "endY": end_y,
                "rotation": 0,
                "alpha": base_alpha * border_alpha,
                "order": len(elements),
                "_source_kind_index": source_kind_index,
            }
        )
    return elements


def normalize_issue(
    issue: dict[str, Any],
    slide_number: int | None,
    elements_by_ref: dict[str, dict[str, Any]],
) -> dict[str, Any]:
    normalized = dict(issue)
    element_refs = list(dict.fromkeys(normalized.get("elements", [])))
    resolved_elements = [
        elements_by_ref[element_ref]
        for element_ref in element_refs
        if element_ref in elements_by_ref
    ]
    element_locators = [
        source_element_id(elements_by_ref[element_ref]) or element_ref
        if element_ref in elements_by_ref
        else element_ref
        for element_ref in element_refs
    ]
    element_ids = [
        source_id
        for element in resolved_elements
        if (source_id := source_element_id(element)) is not None
    ]
    normalized["schema_version"] = "2.0"
    normalized["elements"] = element_locators
    normalized["element_ids"] = element_ids
    normalized["target"] = {
        **({"slide_number": slide_number} if slide_number is not None else {}),
        **normalized.get("target", {}),
    }
    normalized["rule"] = issue_rule(normalized)
    normalized["measurement"] = issue_measurement(issue, elements_by_ref)
    normalized["related_objects"] = [related_object(element) for element in resolved_elements]
    if normalized["code"] == "sparse_container_content":
        ratio = normalized["measurement"]["content_coverage_ratio"]
        threshold = normalized["rule"]["threshold"]
        container_locator = (
            normalized["target"].get("container_id")
            or normalized["target"].get("container_xml_path")
            or "unknown"
        )
        normalized.setdefault(
            "message",
            f"large card {container_locator} content coverage {ratio:.1%} is below {threshold:.1%}",
        )
        normalized.setdefault(
            "hint",
            "Review the rendered screenshot; add or enlarge meaningful content if the whitespace is not intentional.",
        )
    elif normalized["code"] == "sparse_slide_content":
        ratio = normalized["measurement"]["content_coverage_ratio"]
        threshold = normalized["rule"]["threshold"]
        normalized.setdefault(
            "message",
            f"slide visible content coverage {ratio:.1%} is below {threshold:.1%}",
        )
        normalized.setdefault(
            "hint",
            "Review the rendered screenshot to decide whether the page is intentionally sparse.",
        )
    else:
        normalized.setdefault("message", normalized["code"].replace("_", " "))
    normalized.setdefault(
        "hint", "Inspect the reported elements and adjust them to satisfy the rule comparison."
    )
    if any(related.get("xml_path") for related in normalized["related_objects"]):
        hint = normalized["hint"]
        if not hint.startswith(XML_PATH_HINT_PREFIX):
            normalized["hint"] = f"{XML_PATH_HINT_PREFIX} {hint}"
    return normalized


def slide_status(errors: list[dict[str, Any]], warnings: list[dict[str, Any]]) -> str:
    if errors:
        return "blocked"
    if warnings:
        return "needs_screenshot_review"
    return "passed"


def is_slide_scoped_sxsd_issue(issue: dict[str, Any], root_name: str) -> bool:
    if issue.get("code") == "sxsd_unsupported_declaration":
        return False
    if root_name == "slide":
        return True
    path = issue.get("path")
    if not isinstance(path, str):
        return False
    if path.startswith("presentation/slide/"):
        return True
    return path == "presentation/slide" and (
        issue.get("attr") is not None or issue.get("code") == "sxsd_invalid_namespace"
    )


def build_result(
    source_path: str | None,
    slide_size: dict[str, int | float],
    top_level_issues: list[dict[str, Any]],
    slides: list[dict[str, Any]],
) -> dict[str, Any]:
    document_errors = [issue for issue in top_level_issues if issue["level"] == "error"]
    document_warnings = [issue for issue in top_level_issues if issue["level"] == "warning"]
    document_infos = [issue for issue in top_level_issues if issue["level"] == "info"]
    error_count = len(document_errors) + sum(len(slide["errors"]) for slide in slides)
    warning_count = len(document_warnings) + sum(len(slide["warnings"]) for slide in slides)
    info_count = len(document_infos) + sum(len(slide["infos"]) for slide in slides)
    all_errors = document_errors + [issue for slide in slides for issue in slide["errors"]]
    all_warnings = document_warnings + [issue for slide in slides for issue in slide["warnings"]]
    status = slide_status(all_errors, all_warnings)
    result: dict[str, Any] = {
        "schema_version": "2.0",
        "tool": "xml_lint",
        "file": source_path,
        "slide_size": slide_size,
        "summary": {
            "slide_count": len(slides),
            "error_count": error_count,
            "warning_count": warning_count,
            "info_count": info_count,
            "status": status,
            "release_ready": error_count == 0,
            "screenshot_review_required": warning_count > 0,
        },
        "document": {
            "errors": document_errors,
            "warnings": document_warnings,
            "infos": document_infos,
        },
        "slides": slides,
    }
    if top_level_issues:
        result["issues"] = top_level_issues
    return result


def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
    root, xml_error = parse_xml_root(xml)
    if xml_error:
        issue = normalize_issue(xml_error, None, {})
        return build_result(
            source_path,
            {"width": 960, "height": 540},
            [issue],
            [],
        )
    if root is None:
        raise AssertionError("parse_xml_root must return a root or error")

    namespace_issues = validate_sml_tag_prefixes(xml)
    root_name = xml_local_name(root.tag)
    sxsd_issues = validate_sxsd_document(xml, root)
    iconpark_issues = validate_iconpark_icon_types(root)
    top_level_issues = [
        normalize_issue(issue, None, {})
        for issue in [
            *namespace_issues,
            *[
                issue
                for issue in sxsd_issues
                if not is_slide_scoped_sxsd_issue(issue, root_name)
            ],
            *iconpark_issues,
        ]
    ]
    if any(issue["level"] == "error" for issue in top_level_issues):
        return build_result(
            source_path,
            {"width": 960, "height": 540},
            top_level_issues,
            [],
        )

    presentation = parse_presentation(root)
    slide_roots = presentation["slide_roots"]
    slides: list[dict[str, Any]] = []
    presentation_id_elements: list[dict[str, Any]] = []
    presentation_elements_by_ref: dict[str, dict[str, Any]] = {}
    for index, slide_xml in enumerate(presentation["slides"]):
        slide_number = index + 1
        slide_root = slide_roots[index]
        slide_sxsd_issues = [
            normalize_issue(issue, slide_number, {})
            for issue in validate_sxsd_document(slide_xml, slide_root)
        ]
        slide_sxsd_errors = [
            issue for issue in slide_sxsd_issues if issue["level"] == "error"
        ]
        if slide_sxsd_errors:
            slide_sxsd_warnings = [
                issue for issue in slide_sxsd_issues if issue["level"] == "warning"
            ]
            slides.append(
                {
                    "slide_number": slide_number,
                    "status": slide_status(slide_sxsd_errors, slide_sxsd_warnings),
                    "element_count": 0,
                    "errors": slide_sxsd_errors,
                    "warnings": slide_sxsd_warnings,
                    "infos": [],
                    "issues": slide_sxsd_issues,
                }
            )
            continue

        geometry = lint_slide(
            slide_xml,
            slide_number,
            presentation["width"],
            presentation["height"],
        )
        density_elements = extract_density_elements(slide_xml, slide_number)
        id_elements = extract_source_id_elements(slide_xml, slide_number)
        presentation_id_elements.extend(id_elements)
        extra_elements = [
            element for element in density_elements if element["kind"] in {"icon", "polyline", "line"}
        ]
        elements_by_ref = {
            element_ref(element): element for element in density_elements
        }
        visible_element_count = len(elements_by_ref)
        for element in id_elements:
            elements_by_ref.setdefault(element_ref(element), element)
        # geometry["elements"] are the exact objects should_flag_overlap/detect_elements_out_of_canvas
        # selected inside lint_slide; prefer them so measurement/related_objects stay consistent
        # with whatever actually triggered the issue, instead of density_elements' separate re-parse.
        elements_by_ref.update(
            {element_ref(element): element for element in geometry["elements"]}
        )
        presentation_elements_by_ref.update(
            {
                element_ref(element): elements_by_ref[element_ref(element)]
                for element in id_elements
            }
        )
        extra_overflow_issues = detect_elements_out_of_canvas(
            extra_elements,
            presentation["width"],
            presentation["height"],
        )
        raw_issues = [
            *geometry["issues"],
            *extra_overflow_issues,
            *detect_duplicate_element_ids(id_elements),
            *detect_blank_slide(
                density_elements,
                slide_number,
                presentation["width"],
                presentation["height"],
            ),
            *detect_sparse_container_content(
                density_elements,
                slide_number,
                presentation["width"],
                presentation["height"],
            ),
            *detect_sparse_slide_content(
                density_elements,
                slide_number,
                presentation["width"],
                presentation["height"],
            ),
        ]
        issues = [
            *slide_sxsd_issues,
            *[
                normalize_issue(issue, slide_number, elements_by_ref)
                for issue in raw_issues
            ],
        ]
        errors = [issue for issue in issues if issue["level"] == "error"]
        warnings = [issue for issue in issues if issue["level"] == "warning"]
        infos = [issue for issue in issues if issue["level"] == "info"]
        slides.append(
            {
                "slide_number": slide_number,
                "status": slide_status(errors, warnings),
                "element_count": visible_element_count,
                "errors": errors,
                "warnings": warnings,
                "infos": infos,
                "issues": issues,
            }
        )

    top_level_issues.extend(
        normalize_issue(issue, None, presentation_elements_by_ref)
        for issue in detect_duplicate_element_ids(
            presentation_id_elements, cross_slide_only=True
        )
    )

    return build_result(
        source_path,
        {"width": presentation["width"], "height": presentation["height"]},
        top_level_issues,
        slides,
    )


def print_usage() -> None:
    print("Usage:\n  python3 xml_lint.py --input <presentation.xml>", file=sys.stderr)


def run_cli(argv: list[str] | None = None) -> None:
    options = parse_args(argv or sys.argv[1:])
    if options.get("help") or options.get("--help"):
        print_usage()
        raise SystemExit(0)
    if not options.get("input"):
        print_usage()
        fail("--input is required")
    requested_path = options["input"]
    resolved_path = Path(requested_path).resolve()
    result = lint_xml(read_file(resolved_path), requested_path)
    print(json.dumps(result, ensure_ascii=False, indent=2))
    if result["summary"]["error_count"] > 0:
        raise SystemExit(1)


if __name__ == "__main__":
    try:
        run_cli()
    except XmlLayoutLintError as error:
        print(f"xml-lint error: {error}", file=sys.stderr)
        raise SystemExit(1) from error
scripts/xml_text_overlap_lint_test.py
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
"""Compatibility entry point for the renamed xml_lint_test script."""

import os
import sys
from pathlib import Path


if __name__ == "__main__":
    target = Path(__file__).with_name("xml_lint_test.py")
    os.execv(sys.executable, [sys.executable, str(target), *sys.argv[1:]])
scripts/xml_text_overlap_lint.py
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
"""Compatibility entry point for the renamed xml_lint module."""

import sys

from xml_lint import *  # noqa: F401,F403
from xml_lint import XmlLayoutLintError, run_cli


if __name__ == "__main__":
    try:
        run_cli()
    except XmlLayoutLintError as error:
        print(f"xml-text-overlap-lint error: {error}", file=sys.stderr)
        raise SystemExit(1) from error
SKILL.md
---
name: lark-slides
version: 1.0.0
description: "飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。不负责:云文档内容编辑(走 lark-doc)、云文档里的独立画板对象(走 lark-whiteboard)、上传或下载普通文件(走 lark-drive)。"
metadata:
  requires:
    bins: ["lark-cli"]
  cliHelp: "lark-cli slides --help"
---

# slides (v1)

> 本技能文档较长,务必使用 Read 工具阅读两次,必须阅读完整全文。

## 权威经验

**权威经验是全局硬约束和高频易错点,必须牢记并严格遵守。**

- 你有充足的时间完成这个 PPT,质量永远比速度重要。
- PPT 的尺寸是 960x540,必须严格确保主体内容在页面边界内。
- !!!禁止交付无图产物!!! 必须使用大量图片增强视觉效果!!! 禁止重复使用同一张图!!!
- 封面页的主视觉必须是 `<img>`(来自生图工具或搜图工具),不要使用 `<shape>` 或 `<icon>` 拼出封面视觉。
- 禁止用 `<shape>` 和 `<line>` 拟形具体物项,必须使用生图工具生成的 `<img>`。
- 禁止在 `headline` 或 `title` 下方放置用于分隔或装饰的 `rect` 或 `<line>`。
- 禁止在任何页面内部使用无意义的装饰线条或色块条带,页面任何一边都不要使用贴边窄条。
- 生图工具的指令参数必须以“不要出现任何文字和颜色色号”结尾,避免生成的图片上出现干扰文字。
- 禁止使用 emoji 图标,任何位置都不能出现。
- 字号必须显式设置 `<content>` 的 `fontSize` 属性,不要依赖 `textType` 的默认字号兜底,这些兜底值明显偏大。
- 大数字、字号大或字数多的 `<content>` 必须设置 `wrap="true" autoFit="normal-auto-fit"` 属性自动换行和缩排,避免文字溢出。
- 文字颜色必须用 `<content>` 的 `color` 属性而不是 `fontColor` 属性。
- 文字行间距必须设置 `<content>` 的 `lineSpacing="multiple:xx"` 或 `lineSpacing="fixed:xx"` 而不是 `lineSpacing="xx"`。
- 图片必须用 `<img>` 而不是 `<image>`。
- IconPark 图标必须填充颜色(设置 `<fill><fillColor color="rgba(R,G,B,A)"/></fill>`)并和背景有足够对比。
- 绘制图表时原生图表(柱状、条形、折线、面积、饼(环)、雷达、组合图)用 `<chart>`,其他(漏斗图、金字塔图、象限图、矩阵图等)用 `<shape>` + `<line>` 模拟。
- 隐藏 `<chart>` 的图例只能通过不写或删除 `<chartLegend>` 实现,`<chartLegend>` 不支持 `position="none"`。
- 表格优先用 `rect` 和 `text` 模拟,其他用 `<table>`,没有 `<shape type="table">`。
- 必须设置 `<table>` 的 `width` 和 `height` 固定表格大小,同时设置需要保留列宽或行高的 `<col>` 的 `width` 和 `<tr>` 的 `height`,其余自动分配。
- `<td>` 直接子元素只有 `<fill>`(背景)、`<content>`(文字)和边框配置(一般不用),不能嵌套 `<shape>`、`<img>`、`<icon>`。
- `<shape type="rect">` 只是形状不是容器,`<icon>`、`<img>`、`<shape type="text">` 和其他 `<shape>` 必须与它平级靠坐标叠放。
- 填充渐变颜色必须用 `<fill><fillColor color="linear-gradient(135deg, rgba(R,G,B,A) 0%, rgba(R,G,B,A) 100%)"/></fill>`。
- 编辑页面前必须阅读 [`workflow/slides-editing.md`](references/workflow/slides-editing.md)。
- 绘制图表前必须阅读 [`xml/slides_chart_demo.xml`](references/xml/slides_chart_demo.xml)。
- 当用户要求无损复述历史上下文时,必须无损复述以上权威经验、必读的技能文档(需要重新阅读)和易错元素语法(尤其是 `<table>` 和 `<chart>`)。

## 豆包设计原则

适用范围:

- 普通内容页的设计必须以豆包设计原则为最高准则,除非用户要求使用模板或直接提供设计方案。
- 不适用于 `title-cover`、`section-divider`、`conclusion`、`quote-highlight` 和 `big-number`。

核心要求:

- 必须采用信息密度极高的图文卡片布局,追求充实饱满、图文丰富、可逐行细读的版面,宁可密而满,不要空而疏。
- **!!!信息密度极高!!! 图多!!! 卡多!!! 字多!!!**

排版布局:

- 卡片布局:卡片按多行网格铺满页面,版面对称、均衡、不留白。网格数、图文比例按内容变化,避免每页雷同。使用更多卡片做细分承载,避免在单张卡片里堆砌大量文字(例如 8 张 50 字卡片优于 2 张 200 字卡片),多个要点必须拆分为多张子卡片。
- 卡片样式:方角卡片 + 半透明填充 + 无边框 + 卡片贴边窄条(可选);所有卡片必须使用相同的配色方案(少量需强调的卡片除外),禁止同页出现彩虹卡片(卡片颜色超过 3 种)。
- 卡片结构:视觉锚点(关键词、编号或 IconPark 图标)+ 标题 + 内容(包括文字、图片、图表、子卡片)。
- 文字卡片:多数页面必须满足 6-8 张文字卡片、200-400 文字数量,字数不足时必须扩写成长句或段落,文字卡片不要留白,必须充实饱满。文字卡片不是短标签,而是“标题 + 完整说明”,像浓缩的分析文稿。文字内容不得不用列表、分栏、关键词或短句时,必须保证层次清晰,更建议拆分为多张子卡片。
- 图片卡片:多数页面必须满足 1-3 张图片卡片,缺少图片时必须用生图工具补充配图,图片卡片与文字卡片组成网格,确保图文丰富。
- 图表卡片:数据信息不要在文字卡片中罗列,必须在图表卡片中可视化(包括表格、图表、时间线、流程图等),图表卡片与其他卡片组成网格,展现数据驱动。
- 间距要求:所有边距都要左右对称,页面和内部内容的边距至少 40px(内容不要贴边),卡片和内部文字的边距至少 5px(文字不要贴边),卡片之间保持 20-40px 的间距。
- 文字对齐:正文默认左对齐,只在封面、结尾或大号数字场景中使用居中;表格里的文字左对齐、数字右对齐、仅关键词或短句时居中对齐。

视觉风格:

- 美学:干净、明亮、清爽但信息饱满;靠卡片和对齐网格在高密度下维持秩序感;同排卡片文字数量应相近以保持观感整齐。
- 字体:全篇以无衬线体(思源黑体)为主,封面或关键强调可少量使用衬线体。
- 字号:标题 28-36pt、正文 12-14pt、注释 10-12pt,常规关键指标 16-32pt、核心指标用 36-52pt 数字,下面配 10-14pt 标签与简短解读,需要容纳更多文字时允许使用更小的字号。
- 图标:内嵌 IconPark 图标(可用关键词或编号替代)作为视觉锚点,让高密度文字也有图形节奏,而不是成片纯文字块。
- 配色:克制颜色数量,确保所有页面都只使用同样的 1 个背景色(偏好浅米白)、1 个主色、1 个强调色和 1 个辅助色;偏好莫兰迪配色,禁止彩虹配色(比如蓝配橙)。

## Quick Reference

**本表只定位「场景 → 用哪条命令、读哪份文档」。参数以「执行前必做」里对应的文档和 `lark-cli slides +<verb> --help` 为准,不要凭记忆或按别的命令类比补参数。**

| 用户需求 | 优先动作 | 关键文档 / 命令 |
|----------|----------|-----------------|
| 新建 PPT | 先规划 `slide_plan.json`,再按页数选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`cli/lark-slides-create.md`、`slides +create`、`slides +add-slide`、`cli/lark-slides-add-slide.md`(两步创建逐页添加) |
| 用户要求使用模板,或提供 PPTX 文件要求修改、美化 | 将模板导入为 Slides 再编辑 | `workflow/template-editing.md` |
| 编辑单个标题、文本块、图片或局部元素 | 块级替换/插入,**只动点名的 block,同页其他元素不受影响**;不改页序 | `slides +replace-slide`、`cli/lark-slides-replace-slide.md` |
| 一页改动很多(批量字体/配色)、要改页面背景、要删掉若干元素 | 整页覆盖,`slide_id` 和页序不变;带原 `id` 写回的元素保留 id,不带 `id` 的会作为新元素插入并拿到新 id;**代价是没写进 `--content` 的元素会被删除,所以改个别元素不要用它** | `slides +update-slide`、`lark-slides-update-slide.md` |
| 给已有 PPT 追加或插入页面 | 一次一页,`--slide` 支持 `@file` 绕开 shell 转义 | `slides +add-slide`、`cli/lark-slides-add-slide.md` |
| 删除页面 | 按 `slide_id` 单页删除,删前先回读确认 | `slides +delete-slide`、`cli/lark-slides-delete-slide.md` |
| 读取或分析已有 PPT | 解析 slides/wiki token,用 shortcut 回读全文 XML 或读取单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `slides +xml-get`、`xml_presentation.slide.get`、`cli/lark-slides-xml-presentations-get.md` |
| 查看或回滚历史版本 | 先用 `+history-list` 找 `history_version_id`,再 `+history-revert`,必要时 `+history-revert-status` 轮询 | [`cli/lark-slides-history.md`](references/cli/lark-slides-history.md) |
| 获取幻灯片页面截图 | 按页码用 `--slide-number`,按 ID 用 `--slide-id`;单张用 `--output`,批量或全量用 `--output-dir`,每批最多 10 页串行执行;截图目录复用同一任务的 deck/task 标识,后续读取返回的实际路径 | `slides +screenshot`、`cli/lark-slides-screenshot.md` |
| 下载图片 | `--output` 选填;传入时指定单个文件路径,未传时自动保存到默认目录 `.lark-slides/media`,并按响应文件名/类型生成路径;调用后读取返回的 `path`,不要猜测文件名;直连被拒时自动回退到源文件预览 | `slides +media-download --file-token <file_token>` |
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`、`cli/lark-slides-media-upload.md`,或 `+create --slides` 的 XML 里写 `<img src="@./path">` 占位符 |
| 绘制图表 | 原生图表(柱状、条形、折线、面积、饼(环)、雷达、组合图)用 `<chart>`,其他(漏斗图、金字塔图、象限图、矩阵图等)用 `<shape>` + `<line>` 模拟 | `xml/xml-schema-quick-ref.md`、`xml/slides_chart_demo.xml` |
| 绘制表格 | 优先用 `rect` 和 `text` 模拟,其他用 `<table>` | `xml/xml-schema-quick-ref.md` |
| 使用图标 | 禁止盲猜 iconType,必须先检索 IconPark,再写 `<icon iconType="...">`,图标必须填充颜色并和背景有足够对比,禁止使用 emoji 图标 | `iconpark_tool.py search → resolve`、`xml/iconpark.md` |
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `workflow/error-handling.md`、`workflow/validation-xml.md` |

**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),认证、权限和全局参数均以 lark-shared 为准。**

**CRITICAL — 查看或回滚历史版本前,MUST 先读取 [`cli/lark-slides-history.md`](references/cli/lark-slides-history.md)。回滚接口只接受 `history_version_id`,不要把 `revision_id` 直接传给 `+history-revert`。**

**CRITICAL — 生成任何 XML 之前,MUST 先用 Read 工具读取 [xml/xml-schema-quick-ref.md](references/xml/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**

**CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录,规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**

**CRITICAL — 新建演示文稿或大幅改写页面时,生成 XML 前 MUST 读取 [visual-planning.md](references/visual-planning.md),确保 `layout_type`、`visual_focus`、`text_density` 实际改变页面几何、主视觉和文本量。**

**CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**

**CRITICAL — 将完整 `<slide>` XML 提交给 `slides +create`、`slides +add-slide` 或 `slides +update-slide` 之前,MUST 先把待提交 XML 保存到本地文件并运行唯一版式准出入口 [`scripts/xml_lint.py`](scripts/xml_lint.py);`summary.error_count` 必须为 0 才能调用接口。**

**CRITICAL — 创建、大幅改写或整页写回后,MUST 按 [workflow/validation-xml.md](references/workflow/validation-xml.md) 做显式验证:回读全文 XML、核对页数和关键元素,并使用 [`scripts/xml_lint.py`](scripts/xml_lint.py) 统一检查 XML、越界、重叠、空白页和内容稀疏风险。**

**CRITICAL — 创建前自检或失败排障时,MUST 按 [workflow/error-handling.md](references/workflow/error-handling.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**

**编辑已有幻灯片页面**:单个标题、文本块、图片或局部元素优先用 [`+replace-slide`](references/cli/lark-slides-replace-slide.md)(块级替换/插入,不动页序);一页里改动很多(例如批量换字体)、要改背景、或要删掉若干元素时用 [`+update-slide`](references/cli/lark-slides-update-slide.md) 整页覆盖(`slide_id` 和页序不变,但没写进 `--content` 的元素会被删除);**多页大改就对每一页各跑一次 `+update-slide`**。选择 action 和完整读-改-写流程见 [`workflow/slides-editing.md`](references/workflow/slides-editing.md)。

**用户要求使用模板**:按 [workflow/template-editing.md](references/workflow/template-editing.md) 处理。

## 身份选择

飞书幻灯片通常是用户自己的内容资源。**默认应优先显式使用 `--as user`(用户身份)执行 slides 相关操作**,始终显式指定身份。

- **`--as user`(推荐)**:以当前登录用户身份创建、读取、管理演示文稿。执行前先完成用户授权:

```bash
lark-cli auth login --domain slides
```

- **`--as bot`**:仅在用户明确要求以应用身份操作,或需要让 bot 持有/创建资源时使用。使用 bot 身份时,要额外确认 bot 是否真的有目标演示文稿的访问权限。

**执行规则**:

1. 创建、读取、增删 slide、按用户给出的链接继续编辑已有 PPT,默认都先用 `--as user`。
2. 如果出现权限不足,先检查当前是否误用了 bot 身份;不要默认回退到 bot。
3. 只有在用户明确要求"用应用身份 / bot 身份操作",或当前工作流就是 bot 创建资源后再做协作授权时,才切换到 `--as bot`。

## 执行前必做

> **重要**:`references/xml/slides_xml_schema_definition.xml` 是此 skill 唯一正确的 XML 协议来源;其他 md 仅是对它和 CLI schema 的摘要。

高频只读:

- [xml/xml-schema-quick-ref.md](references/xml/xml-schema-quick-ref.md)
- [planning-layer.md](references/planning-layer.md)(新建 / 大幅改写)
- [visual-planning.md](references/visual-planning.md)(新建 / 大幅改写)
- [asset-planning.md](references/asset-planning.md)(新建 / 大幅改写)
- [workflow/validation-xml.md](references/workflow/validation-xml.md)(创建 / 大幅改写后)

调用相关命令前必须读取相关的文档以了解命令的使用方式:

- 创建:[`cli/lark-slides-create.md`](references/cli/lark-slides-create.md)、[`cli/lark-slides-add-slide.md`](references/cli/lark-slides-add-slide.md)(逐页添加 / 给已有 PPT 追加页面)
- 删除页面:[`cli/lark-slides-delete-slide.md`](references/cli/lark-slides-delete-slide.md)
- 阅读:[`cli/lark-slides-xml-presentations-get.md`](references/cli/lark-slides-xml-presentations-get.md)
- 编辑:[`workflow/slides-editing.md`](references/workflow/slides-editing.md)、[`cli/lark-slides-replace-slide.md`](references/cli/lark-slides-replace-slide.md)、[`lark-slides-update-slide.md`](references/cli/lark-slides-update-slide.md)
- 历史版本:[`cli/lark-slides-history.md`](references/cli/lark-slides-history.md)
- 截图:[`cli/lark-slides-screenshot.md`](references/cli/lark-slides-screenshot.md)
- 图片:[`cli/lark-slides-media-upload.md`](references/cli/lark-slides-media-upload.md)
- 图表:[`xml/slides_chart_demo.xml`](references/xml/slides_chart_demo.xml)
- 图标:[`xml/iconpark.md`](references/xml/iconpark.md)、[`scripts/iconpark_tool.py`](scripts/iconpark_tool.py)
- 排障:[`workflow/error-handling.md`](references/workflow/error-handling.md)
- 完整协议:[`xml/slides_xml_schema_definition.xml`](references/xml/slides_xml_schema_definition.xml)


## Workflow

### Design Ideas

不要生成无设计感的幻灯片。纯白背景 + 标题 + bullets 只能作为极简临时稿,不能作为正式交付。

开始写 XML 前,先在 `slide_plan.json` 里确定 deck 级视觉策略:

- **主题化配色**:配色必须服务本次主题、行业和受众,不要默认蓝色商务风。如果把同一套颜色换到另一个完全不同主题仍然成立,说明配色不够具体。
- **主次比例**:选择 1 个主色承担约 60-70% 视觉权重,1 个辅助色承担结构和分区,1 个强调色只用于关键数字、结论或行动点。不要让所有颜色权重相同。
- **背景一致性**:先确定全 deck 的背景策略,默认保持同一明暗基调和底色体系;无论深浅,都要保证内容和背景对比充足。
- **统一 motif**:选择一个可复用视觉母题贯穿全文,例如编号节点、卡片处理方式、半出血图片区域、标题、页脚。不要每页换一套装饰语言。

每页至少要有一个视觉元素:图片、图标、图表、表格、流程、对比结构或大号数字。文本框本身不算主视觉。

常见页面形态:

- **双栏结构**:左文右图或左图右文,视觉区域占 35-45% 宽度。
- **图标行**:图标在色块或圆形底中,右侧是短标题和一句解释。
- **网格**:适合能力、模块、风险、行动项,每格内容保持同等层级。
- **半出血视觉**:图片占据左/右半屏,文字覆盖或贴边排布。
- **大数字卡片**:核心指标用大数字,下面配标签与简短解读。
- **对比列**:before/after、方案 A/B、问题/解法用左右并列,标题和基线严格对齐。
- **时间线/流程图**:步骤用节点和箭头表达,流程方向必须一眼可见。

常见错误必须避免:

- 不要所有页面复用同一种标题 + 三 bullets 版式。
- 不要用低对比文字或低对比图标,例如浅灰字压在浅色背景上。
- 不要让装饰线穿过文字,或让页脚、来源、编号挤压主体内容。
- 不要把素材缺失表现为空白图片框;必须按 `fallback_if_missing` 生成替代图片。
- 不要在任何位置使用 emoji 图标。


### 生成流程

```text
Step 1: 需求分析 & 读取知识
  - 分析主题、受众、页数、风格;
  - 若用户要求使用模板,按 workflow/template-editing.md 处理
  - 读取 xml/xml-schema-quick-ref.md;新建 / 大幅改写时还要读取 planning-layer.md、visual-planning.md、asset-planning.md
  - 涉及图表读取 xml/slides_chart_demo.xml

Step 2: 生成大纲 → 写入 slide_plan.json
  - 生成结构化大纲
  - 新建 / 大幅改写必须先创建目录并写入 `slide_plan.json`
  - plan 字段、路径命名和 `asset_need` 结构按 planning-layer.md / asset-planning.md 执行

Step 3: 按 slide_plan.json 生成 XML → 创建
  - 逐页消费 plan:key_message 定主结论,layout_type 定几何,visual_focus 定主视觉,text_density 定文本量
  - 缺少真实素材时必须用 `fallback_if_missing` 生成替代图片,不要留空
  - 读 cli/lark-slides-create.md 定一步创建还是两步创建,并据此构造 `slides +create`;两步创建再读 cli/lark-slides-add-slide.md 用 `+add-slide` 逐页添加
  - 图片按 cli/lark-slides-media-upload.md 处理;复杂 XML、转义和 3350001 排查按 workflow/error-handling.md 执行

Step 4: 审查 & 交付
  - 创建完成后,必须用 `slides +xml-get --presentation <xml_presentation_id>` 读取全文 XML,并按 workflow/validation-xml.md 做显式验证记录,包括 XML 文本重叠检查
  - 失败或部分成功按 workflow/error-handling.md 处理;局部问题优先用 `+replace-slide` 修正
  - 没问题 → 交付:使用 NotifyHuman 工具交付 PPT 链接
```

> 渐变色必须使用 `rgba()` 格式并带百分比停靠点,如 `linear-gradient(135deg,rgba(15,23,42,1) 0%,rgba(56,97,140,1) 100%)`。使用 `rgb()` 或省略停靠点会导致服务端回退为白色。

### 大纲模板

生成大纲时使用以下格式:

```text
[PPT 标题] — [定位描述],面向 [目标受众]

页面结构(N 页):
1. 封面页:[标题文案]
2. [页面主题]:[要点1]、[要点2]、[要点3]
3. [页面主题]:[要点描述]
...
N. 结尾页:[结尾文案]

风格:[配色方案],[排版风格]
```

## 核心概念

### URL 格式与 Token

| URL 格式 | 示例 | Token 类型 | 处理方式 |
|----------|------|-----------|----------|
| `/slides/` | `https://example.larkoffice.com/slides/xxxxxxxxxxxxx` | `xml_presentation_id` | URL 路径中的 token 直接作为 `xml_presentation_id` 使用 |
| `/wiki/` | `https://xxx.feishu.cn/wiki/wikcn_EXAMPLE_NODE_TOKEN_123456` | `wiki_token` | ⚠️ **不能直接使用**,需要先查询获取真实的 `obj_token` |

> 带 `--presentation` 的 slides shortcut 都会自动解析以上两种 URL;直接调用原生 API 时仍需手动解析 wiki 链接。

### Wiki 链接特殊处理(关键!)

知识库链接(`/wiki/TOKEN`)不能直接当 `xml_presentation_id`。直接调用原生 API 前,先用 Wiki shortcut 查询节点,确认 `data.obj_type == "slides"`,再用 `data.obj_token` 作为真实 presentation ID。

```bash
lark-cli wiki +node-get --node-token 'https://xxx.feishu.cn/wiki/wikcn_EXAMPLE_NODE_TOKEN_123456' --as user --format json
```

节点解析必须与后续 Slides 操作使用相同身份;下游明确使用 `--as bot` 时,这里也改为 `--as bot`。

带 `--presentation` 的 slides shortcut 都会自动解析 `/wiki/` URL 并校验 `obj_type`;手动调用 `xml_presentations.*` / `xml_presentation.slide.*` 时才需要自己做这一步。

### 资源关系

```text
Wiki Space (知识空间)
└── Wiki Node (知识库节点, obj_type: slides)
    └── obj_token → xml_presentation_id

Slides (演示文稿)
├── xml_presentation_id (演示文稿唯一标识)
├── revision_id (版本号)
└── Slide (幻灯片页面)
    └── slide_id (页面唯一标识)
```

## Shortcuts 与 API

Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`)。有 Shortcut 的操作优先使用。

| Shortcut | 说明 |
|----------|------|
| [`+create`](references/cli/lark-slides-create.md) | 创建 PPT,可选一步添加页面 |
| [`+add-slide`](references/cli/lark-slides-add-slide.md) | 向已有演示文稿追加或插入**一页**(`--before-slide-id` 控制位置),XML 支持 `@file` / stdin,`<img src="@./path">` 占位符自动上传 |
| [`+delete-slide`](references/cli/lark-slides-delete-slide.md) | 按 `slide_id` 删除**一页** |
| [`+xml-get`](references/cli/lark-slides-xml-presentations-get.md) | 读取全文 XML,用 `--presentation` 指定演示文稿的 `xml_presentation_id`,用 `--output` 把 XML 存到本地文件(必须是 CWD 内的相对路径,如 `.lark-slides/plan/<deck>/readback.xml`) |
| [`+screenshot`](references/cli/lark-slides-screenshot.md) | 把幻灯片页面截图保存为本地图片;用 `--slide-number` 指定页码(从 1 开始,多页重复传入)或用 `--slide-id` 指定页面;单张用 `--output .lark-slides/screenshots/<deck-or-task-id>/page-01`,批量用 `--output-dir .lark-slides/screenshots/<deck-or-task-id>`(一次最多 10 页);后续必须读取返回的 `output` / `screenshots[].path` |
| [`+media-upload`](references/cli/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
| `+media-download` | 根据 Slides 图片 `file_token` 下载本地图片;`--output` 选填,未传时使用 `--output-dir` 默认值 `.lark-slides/media` 并自动生成文件名;调用后使用返回的 `path`,不要猜测实际路径;直连下载无权限时自动回退到源文件预览 |
| [`+replace-slide`](references/cli/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
| [`+update-slide`](references/cli/lark-slides-update-slide.md) | 把一整页 XML 交给已有页面,页面变成 `--content` 描述的样子;能一次改样式/插入/删除/备注/背景,`slide_id` 和页序不变。**没写进 `--content` 的元素会被删除** |

没有 Shortcut 覆盖时使用原生 API。高频资源:`slides +xml-get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。

```bash
lark-cli schema slides.<resource>.<method>   # 调用 API 前必须先查看参数结构
lark-cli slides <resource> <method> [flags] # 调用 API
```

> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。

## 核心规则

1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
2. **创建流程**:新建演示文稿用 `slides +create`,一步创建还是两步创建按 [`cli/lark-slides-create.md`](references/cli/lark-slides-create.md) 判断
3. **`<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`**:文本和图形必须放在 `<data>` 内
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内;不要混淆 XML 元素 `<content>` 和 `--parts` 的 JSON 字段:编写 `--parts` 时,`block_replace` 装载 XML 使用标准字段 `replacement`,`block_insert` 使用 `insertion`
5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id`
6. **删除谨慎**:删除不可逆,删前先回读确认 `slide_id`
7. **编辑已有页面优先原链接更新**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;一页改动很多或要改背景用 `+update-slide` 整页覆盖(保 `slide_id` 和页序),多页整页重建就对每页各跑一次 `+update-slide`,不要用 `slides +create` 新建整份 PPT;追加/插入单页用 `+add-slide`、删除单页用 `+delete-slide`,只有这些 shortcut 未覆盖的参数才手动调 `slide.create` / `slide.delete`
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传,或在 `+create --slides` 的 XML 里写 `<img src="@./path">` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。

> **注意**:如果 md 内容与 `xml/slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。