ppt-master 多语言文本导出冒烟测试:BCP-47 语言标签到 DrawingML rtl 契约的全链路验证
【免费下载链接】ppt-masterAI turns documents or topics into real, native PowerPoint decks—with native shapes, transitions and animations,>项目地址: https://gitcode.com/GitHub_Trending/ppt/ppt-master
本文围绕 multilingual-text-smoke.md 讲解 ppt-master 的多语言文本维护冒烟测试:一条可复制的 heredoc 命令,在临时目录中串联 Confirm UI 语言归一化、spec_lock.md执行锁、SVG→DrawingML 文本导出、原生表格/图表、演讲者备注与docProps/core.xml元数据,验证从 BCP-47 语言标签到 PPTX 包内lang/rtl属性的完整契约。读完并亲手跑通后,你能理解该项目"执行锁是唯一导出语言源"的设计,以及段落级rtl="1"、run 级a:rtl的双层 RTL 契约,从而在改动语言相关代码时知道如何自检。
冒烟测试的定位与运行方式
这是一份手动冒烟(manual smoke),不是自动化测试套件——文档开篇即说明:它只使用临时文件(tempfile.TemporaryDirectory),不会向仓库新增任何测试资产。适用时机是修改了以下任一模块之后:
- Confirm UI 的语言处理(Stage 1 推荐与确认流程);
- DrawingML 文本导出(SVG 文本 → 原生
a:rPr); - 原生表格 / 原生图表的构建;
- 演讲者备注(notes slide);
- 文档元数据(
docProps/core.xml)。
从仓库根目录直接执行(注意PYTHONPATH同时包含scripts与confirm_ui两个目录,脚本内会 import 二者):
PYTHONPATH="skills/ppt-master/scripts:skills/ppt-master/scripts/confirm_ui" python3 - <<'PY' import json import zipfile from pathlib import Path from tempfile import TemporaryDirectory from xml.etree import ElementTree as ET from pptx import Presentation from confirm_ui.server import create_app from language_tags import ( LanguageTagError, language_uses_rtl, normalize_language_tag, ) from svg_to_pptx.drawingml.context import ConvertContext from svg_to_pptx.drawingml.converter import convert_svg_to_slide_shapes from svg_to_pptx.native_objects import _build_native_chart from svg_to_pptx.native_objects.table import _build_native_table from svg_to_pptx.pptx_package.builder import create_pptx_with_native_svg from svg_to_pptx.pptx_package.cli import _declared_primary_language from svg_to_pptx.pptx_package.notes import create_notes_slide_xml def reject_language(value): try: normalize_language_tag(value) except LanguageTagError: return raise AssertionError(f"invalid language accepted: {value}") canonical = { "ES_mx": "es-MX", "RU_ru": "ru-RU", "AR_sa": "ar-SA", "HE_il": "he-IL", "HI_in": "hi-IN", "TH_th": "th-TH", "KO_kr": "ko-KR", "fil_ph": "fil-PH", "zh_hans": "zh-Hans", "de-CH-1901": "de-CH-1901", "en-u-nu-latn": "en-u-nu-latn", } for raw, expected in canonical.items(): assert normalize_language_tag(raw) == expected for raw in ("und", "zh", "en--US", "Arabic", "x-private"): reject_language(raw) assert language_uses_rtl("ar-Arab-SA") assert not language_uses_rtl("ar-Latn-SA") assert language_uses_rtl("en-Arab-US") samples = { "en-US": "Summary 2026", "zh-Hans": "年度总结 2026", "ja-JP": "年間まとめ 2026", "ko-KR": "연간 요약 2026", "es-ES": "Resumen 2026", "ru-RU": "Итоги 2026", "ar-SA": "ملخص 2026", "he-IL": "סיכום 2026", "hi-IN": "सारांश 2026", "th-TH": "สรุป 2026", } rtl_languages = {"ar-SA", "he-IL"} with TemporaryDirectory(prefix="ppt-master-multilingual-smoke-") as tmp: root = Path(tmp) # Confirm UI canonicalizes Stage 1 and persists the same project language. project = root / "confirm-project" confirm = project / "confirm_ui" confirm.mkdir(parents=True) recommendation = { "stage": "stage1", "lang": "en", "primary_language": "AR_sa", "audience": {"value": "Team"}, "communication_intent": {"value": "Explain"}, "audience_outcome": {"value": "Understand"}, "core_message": {"value": "Result"}, "delivery_context": {"value": "Meeting"}, "artifact_afterlife": {"value": ""}, "content_divergence": {"value": ""}, "recommend": {"canvas": "ppt169"}, } (confirm / "recommendations.stage1.json").write_text( json.dumps(recommendation), encoding="utf-8", ) (confirm / "template_options.json").write_text( json.dumps( { "schema_version": 1, "phase": "template", "default_mode": "free_design", "explicit_workspace_roots": [], } ), encoding="utf-8", ) app = create_app(str(project), idle_timeout=0) app.testing = True client = app.test_client() response = client.get("/api/recommendations") assert response.status_code == 200 assert response.get_json()["primary_language"] == "ar-SA" response = client.post( "/api/confirm", json={ "stage": "stage1", "template_selection": { "mode": "free_design", "selection_keys": [], }, "primary_language": "ar-SA", "canvas": "ppt169", "audience": "Team", "communication_intent": "Explain", "audience_outcome": "Understand", "core_message": "Result", "delivery_context": "Meeting", "artifact_afterlife": "", "content_divergence": "", }, ) assert response.status_code == 200 result = json.loads((confirm / "result.json").read_text(encoding="utf-8")) assert result["primary_language"] == "ar-SA" # The execution lock is the only export-time project-language source. lock_project = root / "lock-project" lock_project.mkdir() lock_template = """# Execution Lock ## communication {language}- audience: team - objective: explain - core_message: result """ lock = lock_project / "spec_lock.md" lock.write_text( lock_template.format(language="- primary_language: ES_mx\n"), encoding="utf-8", ) assert _declared_primary_language(lock_project) == "es-MX" lock.write_text(lock_template.format(language=""), encoding="utf-8") assert _declared_primary_language(lock_project) is None for index, (language, sample) in enumerate(samples.items(), 1): stem = f"slide-{index}" svg = root / f"{stem}.svg" svg.write_text( '<svg xmlns="http://www.w3.org/2000/svg" ' 'viewBox="0 0 1280 720">' '<rect x="0" y="0" width="1280" height="720" fill="#FFFFFF"/>' f'<text x="100" y="140" font-size="42" ' f'font-family="Arial" fill="#111111">{sample}</text>' "</svg>", encoding="utf-8", ) slide_xml, *_ = convert_svg_to_slide_shapes( svg, index, verbose=False, primary_language=language, ) assert f'lang="{language}"' in slide_xml assert ("rtl=\"1\"" in slide_xml) == (language in rtl_languages) assert ("<a:rtl val=\"1\"/>" in slide_xml) == ( language in rtl_languages ) ascii_svg = root / f"{stem}-ascii.svg" ascii_svg.write_text( '<svg xmlns="http://www.w3.org/2000/svg" ' 'viewBox="0 0 1280 720">' '<text x="100" y="140" font-size="42">2026 AI</text>' "</svg>", encoding="utf-8", ) ascii_xml, *_ = convert_svg_to_slide_shapes( ascii_svg, index + 20, verbose=False, primary_language=language, ) assert f'lang="{language}"' in ascii_xml assert 'rtl="1"' not in ascii_xml assert "<a:rtl val=\"1\"/>" not in ascii_xml context = ConvertContext(primary_language=language) marker = ET.Element("g") table = _build_native_table( marker, context, { "schema": "ppt-master.semantic-table.v2", "x": 10, "y": 10, "width": 600, "height": 180, "columns": [sample, "2026 AI"], "rows": [["A", "B"]], "style": {"font_family": "Arial"}, }, ) assert f'lang="{language}"' in table.xml assert all(slot in table.xml for slot in ("<a:latin ", "<a:ea ", "<a:cs ")) chart_context = ConvertContext(primary_language=language) _build_native_chart( marker, chart_context, { "x": 10, "y": 220, "width": 600, "height": 300, "type": "column", "title": sample, "categories": ["A", "B"], "series": [{"name": sample, "values": [1, 2]}], "style": {"font_family": "Arial"}, "show_legend": True, }, ) chart_xml = next( value.decode("utf-8") for part, value in chart_context.package_files.items() if part.startswith("ppt/charts/chart") and not part.endswith(".rels") ) assert f'<c:lang val="{language}"/>' in chart_xml assert f'lang="{language}"' in chart_xml notes_xml = create_notes_slide_xml( 1, sample + "\n2026 AI", language, ) assert f'lang="{language}"' in notes_xml assert ("rtl=\"1\"" in notes_xml) == (language in rtl_languages) output = root / f"{index}.pptx" assert create_pptx_with_native_svg( [svg], output, canvas_format="ppt169", verbose=False, transition=None, notes={stem: sample + "\n2026 AI"}, pptx_structure="flat", structure_name="multilingual-smoke", primary_language=language, ) with zipfile.ZipFile(output) as archive: assert archive.testzip() is None packaged_slide = archive.read( "ppt/slides/slide1.xml" ).decode("utf-8") packaged_notes = archive.read( "ppt/notesSlides/notesSlide1.xml" ).decode("utf-8") core = archive.read("docProps/core.xml").decode("utf-8") assert f'lang="{language}"' in packaged_slide assert f'lang="{language}"' in packaged_notes assert f"<dc:language>{language}</dc:language>" in core assert len(Presentation(str(output)).slides) == 1 # A legacy lock with no language field keeps the previous per-run path. legacy_svg = root / "legacy.svg" legacy_svg.write_text( '<svg xmlns="http://www.w3.org/2000/svg" ' 'viewBox="0 0 1280 720">' '<text x="100" y="140" font-size="42">한국어 2026</text>' "</svg>", encoding="utf-8", ) legacy_output = root / "legacy.pptx" assert create_pptx_with_native_svg( [legacy_svg], legacy_output, canvas_format="ppt169", verbose=False, transition=None, pptx_structure="flat", structure_name="legacy-smoke", ) with zipfile.ZipFile(legacy_output) as archive: assert archive.testzip() is None legacy_slide = archive.read( "ppt/slides/slide1.xml" ).decode("utf-8") assert 'lang="ko-KR"' in legacy_slide print("Multilingual text smoke: passed") PY预期输出只有一行:
Multilingual text smoke: passed任何一条assert失败即代表某条语言契约被破坏,应停止合入对应改动。
第一环:BCP-47 语言标签的归一化与拒收规则
冒烟脚本开头直接对 language_tags.py 的normalize_language_tag做正/反用例断言,覆盖了两类输入:
归一化正向用例——ES_mx → es-MX、AR_sa → ar-SA、zh_hans → zh-Hans、de-CH-1901与en-u-nu-latn等带变体/扩展子标签的完整形态。对照源码可以确认其规则(language_tags.py#L159-L265):
- 语言子标签统一小写(
_LANGUAGE_RE为 2–8 位字母); - 下划线会被替换为连字符(
ES_mx→ES-mx→es-MX); - 脚本(script)子标签转 Title Case(
hans→Hans),区域(region)子标签转大写(mx→MX); - 变体(variant,5–8 位数字字母或
数字+3位)与单字母扩展(如u-nu-latn中的-u-扩展)需按序合法且不可重复; - 内置一组历史自然语言别名(如
"Chinese"、"中文"、"日本語"、"한국어")会被映射为规范标签,见 _LANGUAGE_ALIASES; - 特例:
zh必须带脚本或区域(zh-Hans、zh-CN等),裸zh会抛错。
拒收反向用例——und(未定语言,不可用作 deck 主语言)、裸zh、空子标签的en--US、纯自然语言名Arabic(不在别名表内,落入_NATURAL_LANGUAGE_NAMES黑名单被拒)、无子标签的私有域x-private。
RTL 判定同样在冒烟中显式验证了三行:
assert language_uses_rtl("ar-Arab-SA") # 显式 Arab 脚本 → RTL assert not language_uses_rtl("ar-Latn-SA") # 显式 Latin 脚本 → 非 RTL assert language_uses_rtl("en-Arab-US") # 英语内容但声明 Arab 脚本 → RTL对应实现见 language_uses_rtl:优先看显式 script 子标签是否属于_RTL_SCRIPTS(Arab、Hebr、Thaa等 9 个),没有脚本子标签时才回落到_RTL_BASE_LANGUAGES(ar、he、fa、ur等 11 个基础语言)。这个"脚本优先于语言"的顺序正是ar-Latn-SA判为非 RTL 的原因。
第二环:Confirm UI 的语言归一化与持久化
冒烟脚本在临时项目confirm-project/confirm_ui/下写入两份 JSON,然后以 Flask test client 驱动 confirm_ui/server.py:
recommendations.stage1.json中故意声明"primary_language": "AR_sa"(非规范写法),同时"lang": "en"——这是刻意设置的对照:lang只控制 Confirm UI 界面语言,primary_language才是 deck 内容语言;template_options.json声明default_mode: free_design,满足 Stage 1 确认的模板选择前置条件。
随后两条断言链验证的是同一个契约:
response = client.get("/api/recommendations") assert response.get_json()["primary_language"] == "ar-SA" # 读取时被归一化 # POST /api/confirm 之后 result = json.loads((confirm / "result.json").read_text(encoding="utf-8")) assert result["primary_language"] == "ar-SA" # 确认时持久化同一标签源码层面,Stage 1 推荐缺失或不合法primary_language会直接报出错误信息 "Stage 1 recommendations must declare a valid primary_language BCP-47 tag; lang controls only the Confirm UI language",归一化入口是 _canonicalize_primary_language。这一步保证了"用户看到的确认语言"与"最终写入导出链的语言"始终是同一个规范标签。
第三环:spec_lock.md是导出期唯一的项目语言源
文档注释写得很明确:"The execution lock is the only export-time project-language source." 冒烟脚本构造了两个执行锁场景,调用 _declared_primary_language:
lock.write_text(lock_template.format(language="- primary_language: ES_mx\n")) assert _declared_primary_language(lock_project) == "es-MX" # 锁内标签也被归一化 lock.write_text(lock_template.format(language="")) assert _declared_primary_language(lock_project) is None # 无该字段 → None实现上它通过update_spec.parse_lock解析spec_lock.md的communication.primary_language字段:值非空则归一化后返回;字段缺失返回None;值非法则升级为带上下文的LanguageTagError(提示spec_lock.md communication.primary_language is invalid: ...)。执行期行为在 cli.py 导出主流程 可见:
- 锁内声明了语言 → 直接作为
primary_language传入构建管线; - 锁内没有该字段 → 打印警告 "spec_lock.md has no communication.primary_language; using legacy per-run language detection.",走遗留的逐行脚本启发式(见后文 legacy 段)。
执行锁模板可参考 spec_lock.md 脚手架。
第四环:SVG → DrawingML 文本导出的 lang 与 rtl 断言
核心循环对 10 种语言(en-US、zh-Hans、ja-JP、ko-KR、es-ES、ru-RU、ar-SA、he-IL、hi-IN、th-TH)各生成一张 1280×720 的 SVG,调用convert_svg_to_slide_shapes后断言:
assert f'lang="{language}"' in slide_xml # run 级 lang assert ("rtl=\"1\"" in slide_xml) == (language in rtl_languages) # 段落级 rtl assert ("<a:rtl val=\"1\"/>" in slide_xml) == (language in rtl_languages) # run 级 a:rtl三层断言对应源码中的具体产出点:
a:rPr lang="..."由detect_text_lang(text, ctx.primary_language)解析——优先使用项目语言契约,而不是逐段猜语言,见 elements.py run 属性构建;- 段落级
a:pPr rtl="1"与 run 级<a:rtl val="1"/>分别由text_uses_rtl与text_has_rtl_characters决定(utils.py):前者按"首个强方向字符"判定段落方向,无强方向字符时回落到language_uses_rtl(primary_language);后者只检查文本中是否含unicodedata.bidirectional为R/AL的强 RTL 字符; - 字体三槽
<a:latin/>、<a:ea/>、<a:cs/>始终成对出现在 run 属性中,保证 CJK(ea 槽)与复杂文种(cs 槽,覆盖阿拉伯、希伯来、天城文等)有独立字体解析路径。
ASCII 对照组是这套契约的关键细节:同一语言下换一张只含2026 AI的 SVG,断言lang="{language}"仍然成立(语言跟着项目走),但rtl="1"与<a:rtl val="1"/>均不得出现。也就是说 rtl 属性由"文本实际内容 + 项目语言"共同决定,而不是简单地对 RTL 语言项目全局开启——这与 ConvertContext.primary_language 的注释一致:None时保留"legacy per-run script heuristic"。
第五环:原生表格、原生图表与备注的语言注入
冒烟脚本接着对每种语言构建语义表、柱状图与备注页,验证语言标签渗透到 PPTX 的各个部件:
原生表格(_build_native_table):
table = _build_native_table(marker, context, { "schema": "ppt-master.semantic-table.v2", ... "columns": [sample, "2026 AI"], "rows": [["A", "B"]], "style": {"font_family": "Arial"}, }) assert f'lang="{language}"' in table.xml assert all(slot in table.xml for slot in ("<a:latin ", "<a:ea ", "<a:cs "))表格单元格文本的语言解析在 table.py:resolved_language = language or detect_text_lang(text, default_language),即显式传入的ctx.primary_language优先、逐格检测结果兜底。
原生图表(_build_native_chart→chart_context.package_files中的ppt/charts/chartN.xml)断言两个事实:
assert f'<c:lang val="{language}"/>' in chart_xml # 图表部件级语言声明 assert f'lang="{language}"' in chart_xml # 图表文本 run 级语言前者对应 chart_xml.py 中图表语言声明:chart_language = primary_language or "en-US",写入图表部件自身的<c:lang>元素,供 PowerPoint 图表编辑器按该语言做校对与字体回退;后者来自图表标题、轴标签等文本节点的detect_text_lang。
备注页(create_notes_slide_xml)逐段应用方向判定:非空段落用text_uses_rtl(para, primary_language)决定是否加a:pPr rtl="1",再用text_has_rtl_characters(para)决定 run 属性内是否追加<a:rtl val="1"/>;空段落则按项目语言决定是否整段 RTL。未传语言时默认回落en-US。
第六环:打包后的 .pptx 包内校验
对每种语言,冒烟脚本通过create_pptx_with_native_svg产出真实pptx(参数包括canvas_format="ppt169"、pptx_structure="flat"、notes={stem: ...}与primary_language=language),然后打开 zip 做四重检查:
with zipfile.ZipFile(output) as archive: assert archive.testzip() is None # 包完整性 ... assert f'lang="{language}"' in packaged_slide # ppt/slides/slide1.xml assert f'lang="{language}"' in packaged_notes # ppt/notesSlides/notesSlide1.xml assert f"<dc:language>{language}</dc:language>" in core # docProps/core.xml assert len(Presentation(str(output)).slides) == 1 # python-pptx 可读回<dc:language>的写入点在 builder.py 的 core 属性生成,同时该构建函数会把primary_language透传给 notes master(builder.py 中create_notes_master_xml(primary_language)),保证备忘记录版式占位符的语言属性与内容语言一致。最后用python-pptx的Presentation读回幻灯片数,验证包不仅 XML 合法、且能被标准库打开。
遗留路径:无语言字段的旧锁
脚本末尾验证向后兼容:一张不含communication.primary_language的"legacy 锁"场景(直接不传primary_language调用构建入口),SVG 文本为한국어 2026,最终slide1.xml中应出现:
assert 'lang="ko-KR"' in legacy_slide这验证了遗留的逐行脚本启发式(per-run script heuristic)仍然工作——韩文(Hangul)文本在没有项目语言契约时被检出为ko-KR。与导出主流程的警告提示(cli.py#L2400-L2406)呼应:旧项目、无锁快速生成仍可出片,只是语言精度降级为逐行检测。
RTL 方向契约:为什么不用rtlCol
文档最后一段定义了必须遵守的 RTL 契约,这也是整套冒烟断言的语义基础:
段落方向用
a:pPr rtl="1";run 级<a:rtl val="1"/>仅在该 run 含强 RTL 字符时输出;不要使用rtlCol——它控制的是文本框内段落条目的列顺序,不是段落文字方向。
对照源码即可逐条对上:
- 段落级:
text_uses_rtl决定a:pPr的rtl属性(notes 生成器 notes.py#L94-L102 与 DrawingML 转换器均如此); - run 级:仅
text_has_rtl_characters为真时追加<a:rtl val="1"/>(elements.py#L2916-L2920),所以英文项目里混入的阿拉伯语短语会在各自 run 上获得方向提示,而纯拉丁文本 run 保持 LTR; rtlCol全仓库未用于此目的——冒烟断言中若误加rtlCol,"ASCII 对照组不出现 rtl" 的检查不会覆盖它,但文档把它列为明确的反模式,避免用列序开关冒充段落方向开关。
何时运行与如何解读失败
触发时机按文档约定:改动 Confirm UI 语言处理、DrawingML 文本导出、原生表格/图表、notes 或文档元数据之后,从仓库根目录运行该命令。成功标志是 stdout 最后一行Multilingual text smoke: passed且无AssertionError。常见失败定位路径:
| 失败断言 | 优先排查文件 |
|---|---|
| 归一化正/反用例 | language_tags.py |
/api/recommendations、result.json的primary_language | confirm_ui/server.py |
_declared_primary_language取值 | pptx_package/cli.py、update_spec.py 的parse_lock |
幻灯片 XML 的lang/rtl | drawingml/elements.py、drawingml/utils.py |
表格lang/三字体槽 | native_objects/table.py |
<c:lang>或图表文本语言 | native_objects/chart_xml.py |
notes 的lang/rtl | pptx_package/notes.py |
包内slide1.xml、dc:language | pptx_package/builder.py |
小结
这条冒烟测试把 ppt-master 的多语言导出契约压缩成了一次可执行的端到端检查:BCP-47 归一化(language_tags.py)→ Confirm UI 持久化(confirm_ui/server.py)→ 执行锁声明(spec_lock.md+_declared_primary_language)→ DrawingML 文本/原生表格/原生图表/备注的语言与方向属性 → 打包后 zip 内逐部件复核。理解这条链后,改动任一环节时都能从失败的断言快速反推出契约被破坏的具体位置。
【免费下载链接】ppt-masterAI turns documents or topics into real, native PowerPoint decks—with native shapes, transitions and animations,>项目地址: https://gitcode.com/GitHub_Trending/ppt/ppt-master
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考