fix(lark-slides): detect double escaped entities

This commit is contained in:
zhanghuanxu
2026-06-24 18:05:14 +08:00
parent 461b4a7e80
commit 3f993ea772
7 changed files with 119 additions and 7 deletions

View File

@@ -36,7 +36,7 @@ metadata:
**CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**
**CRITICAL — 创建或大幅改写后MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)。**
**CRITICAL — 创建或大幅改写后MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py),不得交付 `double_escaped_entity` 问题**
**CRITICAL — 创建前自检或失败排障时MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**

View File

@@ -46,7 +46,7 @@ REV=$(lark-cli slides xml_presentation.slide get --as user \
# 写时传该版本号,服务端以此为 base
lark-cli slides +replace-slide --as user \
--presentation "$PID" --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\"/>"}]'
--parts '[{"action":"block_replace","block_id":"bUn","replacement":"<shape type=\"rect\" topLeftX=\"100\" topLeftY=\"100\" width=\"200\" height=\"100\"><content/></shape>"}]'
```
注意:传不存在的版本号(超过当前 revision会返回 3350002 not found不确定时用 `-1` 即可。

View File

@@ -103,7 +103,7 @@ lark-cli slides xml_presentation.slide create --as user --params '{
"xml_presentation_id": "slides_example_presentation_id"
}' --data '{
"slide": {
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"520\" height=\"120\"><content textType=\"title\"><p>数据展示</p></content></shape><shape type=\"rect\" topLeftX=\"700\" topLeftY=\"100\" width=\"200\" height=\"150\"><fill><fillColor color=\"rgb(100, 149, 237)\"/></fill></shape></data></slide>"
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"520\" height=\"120\"><content textType=\"title\"><p>数据展示</p></content></shape><shape type=\"rect\" topLeftX=\"700\" topLeftY=\"100\" width=\"200\" height=\"150\"><fill><fillColor color=\"rgb(100, 149, 237)\"/></fill><content/></shape></data></slide>"
}
}'
```

View File

@@ -7,6 +7,7 @@
在真正创建或替换前,至少检查:
- 特殊字符已转义:正文和标题里的 `&``<``>` 不能裸写;属性值里的裸 `&` 也必须写成 `&amp;`
- 普通可见符号直接写 Unicode不要输出 HTML/XML entity 后再转义:`«姓名»``●``✓` 是正确文本;`&amp;#171;姓名&amp;#187;``&amp;#9679;``&amp;nbsp;` 会在页面中泄漏成字面量。
- 属性引号安全XML 属性、shell 引号、JSON 字符串包装之间没有互相打断。
- 结构合法:`<slide>` 下只放 `<style>``<data>``<note>`,文本都在 `<content>` 内。
- 图片路径正确:`<img src="@...">` 只在 `+create --slides` 的支持链路中使用;直接调用 `xml_presentation.slide.create` 必须先拿到 `file_token`
@@ -17,9 +18,9 @@
1. 记录 `xml_presentation_id`,不要假设失败代表什么都没创建。
2.`xml_presentations.get` 回读,确认是否已有部分页面写入。
3. 检查失败页是否含未转义字符:`Q&A -> Q&amp;A`,文本 `<` / `>` 写成 `&lt;` / `&gt;`,属性 URL `a=1&b=2 -> a=1&amp;b=2`
3. 检查失败页是否含未转义字符:`Q&A -> Q&amp;A`,文本 `<` / `>` 写成 `&lt;` / `&gt;`,属性 URL `a=1&b=2 -> a=1&amp;b=2`;同时检查是否有 `double_escaped_entity`,如 `&amp;#9679;``&amp;nbsp;``&amp;lt;`
4. 检查标签闭合、属性引号、`<content>` 结构,以及 `<slide>` 直接子元素。
5. 页面空白、溢出、重叠或越界时,按 [validation-checklist.md](validation-checklist.md) 运行 XML 文本重叠检查,并人工核对越界、截断、图文压盖等视觉风险;工具当前只会报告 `xml_not_well_formed` / `bbox_overlap`
5. 页面空白、溢出、重叠、乱码或越界时,按 [validation-checklist.md](validation-checklist.md) 运行 XML 文本重叠检查,并人工核对越界、截断、图文压盖等视觉风险;工具会报告 XML 语法、二次转义实体、文本重叠和部分异常换行风险
6. 如果使用 `--slides '[...]'`,怀疑 shell 截断时直接切到两步创建:先 `slides +create`,再用 `xml_presentation.slide.create` 逐页添加。
7. 局部问题用 `+replace-slide` 块级修正;整页结构要改时再用 `slide.delete` 旧页 + `slide.create` 新页。
@@ -52,7 +53,7 @@
| 400 无法删除唯一幻灯片 | 演示文稿至少保留一页 | 先创建新页,再删除旧页 |
| 1061002 媒体上传 params error | slides 媒体上传参数不符合约定 | 用 `slides +media-upload`,不要手拼原生 `medias/upload_all`slides 唯一可用 `parent_type``slide_file` |
| 1061004 forbidden | 当前身份对演示文稿无编辑权限 | 确认 user/bot 对目标 PPT 有编辑权限bot 常见于 PPT 非该 bot 创建 |
| 3350001 | XML 非 well-formed、XML 结构不符合服务端要求,或 replace 片段问题 | 优先检查未转义字符replace 场景再看 `block_id``<content/>` |
| 3350001 | XML 非 well-formed、XML 结构不符合服务端要求,或 replace 片段问题 | 优先检查未转义字符和二次转义实体replace 场景再看 `block_id``<content/>` |
| 3350002 | `revision_id` 大于当前版本 | 用 `-1` 取当前版本,或重新读 `xml_presentations.get` 取最新 `revision_id` |
| validation: unsafe file path | `--file` 给了绝对路径或上层路径 | `--file` 必须是 CWD 内相对路径;先 `cd` 到素材目录再执行 |

View File

@@ -34,6 +34,7 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
通过标准:
- `summary.error_count == 0`。任何 error 都必须先修复再交付。
- `double_escaped_entity` warning 必须先修复再交付;它通常表示 HTML/XML 实体被二次转义,页面会显示 `&#...;` / `&nbsp;` / `&lt;` 这类字面量。
- 对异常换行、文本框高度不足等 wrap quality warning默认也应修复后再交付仅当它是普通正文的自然换行且用户明确允许时才可在验证记录中说明豁免原因。
- 当前工具检查 XML well-formed、文本元素之间的明显重叠以及部分规则化异常换行它不检查越界、图文压盖、表格/图表压盖或底部拥挤。
- 该工具不能替代页数核对、关键内容核对或真实视觉验收。
@@ -43,6 +44,7 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
| code | 含义 | 处理方式 |
|------|------|----------|
| `xml_not_well_formed` | XML 语法错误或文本未转义 | 修复标签闭合、属性引号、`&` / `<` / `>` 转义 |
| `double_escaped_entity` | 文本中含二次转义实体,如 `&amp;#9679;``&amp;nbsp;``&amp;lt;` | 改成目标 Unicode 文本,如 `●`、空格、`<`;只对 XML 保留字符做一层必要转义 |
| `bbox_overlap` | 文本元素的估算绘制区域明显重叠 | 拉开文本坐标、缩小文本框/字号,或改成明确的分栏/分组结构 |
| `text_word_split` / `text_phrase_split` | 中文词语或高频短语被异常拆行 | 增宽文本框、降低字号、改写短语或调整换行点,避免把词语/短语拆开 |
| `text_orphan_line` | 最后一行只有极短中文尾巴 | 增宽文本框、缩小字号或重排文本,让尾行形成可读短句 |

View File

@@ -9,6 +9,7 @@ import re
import sys
import xml.etree.ElementTree as ET
from difflib import SequenceMatcher
from html import unescape
from pathlib import Path
from typing import Any
@@ -19,6 +20,9 @@ class XmlTextOverlapLintError(Exception):
TITLE_LIKE_TEXT_TYPES = {"title", "headline", "sub-headline", "card_title", "callout"}
CENTER_ALLOWED_TEXT_TYPES = {"title", "quote", "hero"}
DOUBLE_ESCAPED_ENTITY_PATTERN = re.compile(
r"&#(?:[0-9]+|x[0-9A-Fa-f]+);|&(?:lt|gt|quot|apos|nbsp);"
)
def fail(message: str) -> None:
@@ -91,6 +95,52 @@ def xml_local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1] if tag.startswith("{") else tag
def preview_double_escaped_entity(text: str) -> str:
return unescape(text).replace("\xa0", " ")
def lint_double_escaped_entities(slide_xml: str) -> list[dict[str, Any]]:
try:
root = ET.fromstring(slide_xml)
except ET.ParseError:
return []
issues: list[dict[str, Any]] = []
seen: set[tuple[str, str]] = set()
for node in root.iter():
if xml_local_name(node.tag) != "content":
continue
for text in node.itertext():
if not text:
continue
for match in DOUBLE_ESCAPED_ENTITY_PATTERN.finditer(text):
entity = match.group(0)
context = collapse_space(text)
key = (entity, context)
if key in seen:
continue
seen.add(key)
is_numeric = entity.startswith("&#")
raw_entity = entity.replace("&", "&amp;", 1)
issues.append(
{
"level": "warning",
"code": "double_escaped_entity",
"message": f"Text contains a likely double-escaped XML/HTML entity: {raw_entity}",
"entity": raw_entity,
"context": context,
"preview": preview_double_escaped_entity(text),
"confidence": "high" if is_numeric else "medium",
"hint": (
"Use the intended literal Unicode text in slide XML, and only XML-escape reserved "
"characters once. For example, write «姓名», ●, or ✓ directly instead of "
"&amp;#171;姓名&amp;#187;, &amp;#9679;, or &amp;#10003;."
),
}
)
return issues
def extract_content_lines(content_xml: str) -> list[str]:
try:
root = ET.fromstring(f"<root>{content_xml}</root>")
@@ -554,7 +604,7 @@ def lint_wrap_quality(element: dict[str, Any]) -> list[dict[str, Any]]:
def lint_slide(slide_xml: str, slide_number: int) -> dict[str, Any]:
elements = extract_elements(slide_xml)
issues: list[dict[str, Any]] = []
issues: list[dict[str, Any]] = lint_double_escaped_entities(slide_xml)
for element in elements:
issues.extend(lint_wrap_quality(element))

View File

@@ -96,6 +96,65 @@ class XmlTextOverlapLintTest(unittest.TestCase):
self.assertEqual(result["summary"]["error_count"], 0)
self.assertNotIn("issues", result)
def test_lint_xml_reports_double_escaped_numeric_entities(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape type="text" topLeftX="80" topLeftY="80" width="420" height="90">
<content textType="body"><p>&amp;#171;姓名&amp;#187;</p><p>&amp;#9679; 占位符</p></content>
</shape>
</data>
</slide>
"""
)
issues = result["slides"][0]["issues"]
self.assertEqual(result["summary"]["warning_count"], 3)
self.assertTrue(all(issue["code"] == "double_escaped_entity" for issue in issues))
self.assertEqual(issues[0]["entity"], "&amp;#171;")
self.assertEqual(issues[0]["preview"], "«姓名»")
self.assertEqual(issues[0]["confidence"], "high")
self.assertEqual(issues[2]["entity"], "&amp;#9679;")
self.assertEqual(issues[2]["preview"], "● 占位符")
def test_lint_xml_reports_double_escaped_named_entities(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape type="text" topLeftX="80" topLeftY="80" width="420" height="90">
<content textType="body"><p>&amp;lt;字段&amp;gt;</p><p>A&amp;nbsp;B</p></content>
</shape>
</data>
</slide>
"""
)
issues = result["slides"][0]["issues"]
self.assertEqual(result["summary"]["warning_count"], 3)
self.assertEqual([issue["entity"] for issue in issues], ["&amp;lt;", "&amp;gt;", "&amp;nbsp;"])
self.assertEqual(issues[0]["preview"], "<字段>")
self.assertEqual(issues[2]["preview"], "A B")
self.assertEqual(issues[0]["confidence"], "medium")
def test_lint_xml_does_not_report_regular_ampersands_urls_or_space_entities(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape type="text" topLeftX="80" topLeftY="80" width="640" height="120">
<content textType="body">
<p>Q&amp;A</p>
<p><a href="https://example.com/?a=1&amp;b=2">link</a></p>
<p>A&#32;B&#9;C</p>
</content>
</shape>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 0)
def test_lint_xml_accepts_chinese_full_width_punctuation(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""