Sync third-party and MCP marketplace plugins

Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources.
Confidence: high
Scope-risk: narrow
Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history.
Tested: Marketplace validation passed.
This commit is contained in:
KeyInfo Bot committed 2026-09-23 00:12:44 +08:00
1 parent e4ce1effaf
commit ebe8c7d892
171 files changed
+9762 -495

No files matched your search

+12
View File
@@ -160,6 +160,18 @@
},
"category": "Productivity"
},
{
"name": "artemis-automation",
"source": {
"source": "local",
"path": "./plugins/codex/plugins/artemis-automation"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Developer Tools"
},
{
"name": "caveman",
"source": {
+2
View File
@@ -33,6 +33,8 @@ Codex 插件页的大标题按 marketplace 来源分组,不是按插件 `categ
`embedded-app-pc-debug`、`firmware-app-development` 和 `firmware-lowlevel-development` 分别覆盖 Windows 电脑端设备调试、固件应用层开发及 BSP/PSP 等底层开发流程。使用前应确认项目工具链、目标设备和访问权限;构建、设备写入、刷机、升级、上传和发布等操作必须按插件门禁取得明确确认。
`artemis-automation` 提供 Android 真机或模拟器的自然语言测试、批量用例、失败诊断、历史检索和报告交付流程。市场只分发 Skill;使用前需单独部署并配置兼容的 ARTEMIS MCP、Android 设备和模型网关。
部分插件依赖特定运行环境。`$updating-gitea-repositories` 面向 Gitea HTTPS、GCM 和 PAT 工作流;`$etunel-role-collaboration` 仅在 Etunel 提供角色、成员、Hook 和任务上下文时使用。安装前应查看插件详情并确认运行前置条件。
`etunel-role-collaboration` 的插件版本对应上游正式 release 的版本号。更新插件不会自动修改 Etunel 的 Hook 配置或迁移在途项目;实际项目仍需按已确认的流程基线使用。
+57
View File
@@ -713,6 +713,7 @@
{
"id": "updating-gitea-repositories",
"repo": "https://git.playones.com/huzhifa/skill-git-push.git",
"repoProvider": "gitea",
"ref": "main",
"adapter": "codex-plugin",
"pluginName": "updating-gitea-repositories",
@@ -862,6 +863,62 @@
"hardware-development": "用于以 KiCad 为主的电子硬件开发,包括 Cadence/OrCAD 原理图转换、封装与网表核对、PCB 功能布局、布线验收、DRC 和 Gerber 制造交付。适用于已有电路的实施与审查,不用于纯软件或说明性框图。"
}
},
{
"id": "artemis-automation",
"repo": "https://git.playones.com/chenmingxuan/Artemis-Eapil.git",
"repoProvider": "gitea",
"ref": "main",
"adapter": "claude-skill",
"pluginName": "artemis-automation",
"category": "Developer Tools",
"skillPath": "skills/artemis-automation",
"sparseCheckout": [
"skills/artemis-automation",
"LICENSE"
],
"brandColor": "#2563EB",
"manifestOverrides": {
"version": "0.1.0",
"description": "使用 ARTEMIS MCP 完成 Android 自动化测试、批量执行、失败诊断、历史检索和报告交付。",
"author": {
"name": "chenmingxuan",
"url": "https://git.playones.com/chenmingxuan"
},
"homepage": "https://git.playones.com/chenmingxuan/Artemis-Eapil/src/branch/main/skills/artemis-automation",
"repository": "https://git.playones.com/chenmingxuan/Artemis-Eapil.git",
"license": "Apache-2.0",
"keywords": [
"artemis",
"android",
"automation",
"testing",
"mcp"
],
"interface": {
"displayName": "ARTEMIS Android 自动化",
"shortDescription": "通过 ARTEMIS MCP 执行 Android 测试并交付报告。",
"longDescription": "通过 ARTEMIS MCP 在 Android 真机或模拟器上执行自然语言测试、批量用例、失败诊断、历史检索及 Word/Excel 报告交付。插件只分发 Skill;使用前必须单独部署并配置与该版本兼容的 ARTEMIS MCP、Android 设备和所需模型网关。",
"developerName": "chenmingxuan",
"category": "Developer Tools",
"capabilities": [
"Interactive",
"Read",
"Write"
],
"defaultPrompt": [
"使用 ARTEMIS Android 自动化检查设备并执行这个测试用例。",
"使用 ARTEMIS Android 自动化批量执行这些用例并导出报告。"
],
"websiteURL": "https://git.playones.com/chenmingxuan/Artemis-Eapil",
"privacyPolicyURL": "https://git.playones.com/chenmingxuan/Artemis-Eapil",
"termsOfServiceURL": "https://git.playones.com/chenmingxuan/Artemis-Eapil/src/branch/main/LICENSE",
"brandColor": "#2563EB"
}
},
"includeRootFiles": [
"LICENSE"
]
},
{
"id": "embedded-app-pc-debug",
"repo": "https://git.playones.com/zoukai/embedded-app-pc-debug.git",
+27 -18
View File
@@ -15,8 +15,8 @@
"repo": "https://github.com/obra/superpowers.git",
"ref": "main",
"adapter": "codex-plugin",
"commit": "b36e0829c6d0140e93cfef2ca599b1b07d4a7797",
"syncedAt": "2026-08-13T08:38:10Z"
"commit": "5bf4e78011075bcfc0dc295f0724994cd123ee71",
"syncedAt": "2026-09-22T16:08:55Z"
},
{
"id": "superpowers-zh",
@@ -33,8 +33,8 @@
"repo": "https://github.com/Yeachan-Heo/oh-my-codex.git",
"ref": "main",
"adapter": "codex-plugin",
"commit": "cb955b0d5becbef76d2c1f0096b6e1f238e1e7f7",
"syncedAt": "2026-09-11T16:00:01Z"
"commit": "cdc24a71408ebd6bd0362f52170f0d7998f77007",
"syncedAt": "2026-09-22T16:08:55Z"
},
{
"id": "ui-ux-pro-max",
@@ -42,8 +42,8 @@
"repo": "https://github.com/nextlevelbuilder/ui-ux-pro-max-skill.git",
"ref": "main",
"adapter": "claude-skill",
"commit": "15de38fb70bc80ae9276fa7703b48ae861a672e6",
"syncedAt": "2026-09-15T09:51:52Z"
"commit": "dcc40ff5133ef78276117db0cc34e7b83cc8aeba",
"syncedAt": "2026-09-22T16:08:55Z"
},
{
"id": "caveman",
@@ -51,8 +51,8 @@
"repo": "https://github.com/JuliusBrussee/caveman.git",
"ref": "main",
"adapter": "codex-plugin",
"commit": "c2906c626be1d703b04e203d9f804a263e87f4ef",
"syncedAt": "2026-09-17T16:00:00Z"
"commit": "2fd153c67988e980fb0b2455c90832159a6a5a25",
"syncedAt": "2026-09-22T16:08:55Z"
},
{
"id": "taste-skill",
@@ -60,8 +60,8 @@
"repo": "https://github.com/Leonxlnx/taste-skill.git",
"ref": "main",
"adapter": "skill-collection",
"commit": "e79ca9ec7e071eb3a3b623c4fb752e853fc3ed58",
"syncedAt": "2026-09-16T15:59:59Z"
"commit": "5217fb45be2c0b302f29c9cd31cbd3237501c684",
"syncedAt": "2026-09-22T16:08:55Z"
},
{
"id": "shadcn",
@@ -69,8 +69,8 @@
"repo": "https://github.com/shadcn-ui/ui.git",
"ref": "main",
"adapter": "claude-skill",
"commit": "a87a63b2ca25143d26c8bd0903e4e9bc77b3f824",
"syncedAt": "2026-09-17T16:00:00Z"
"commit": "98a1fe67b439324ddc857f47fbdce056600a4329",
"syncedAt": "2026-09-22T16:08:55Z"
},
{
"id": "frontend-slides",
@@ -105,8 +105,8 @@
"repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main",
"adapter": "claude-skill",
"commit": "9c29d7d80d31d6aa658ae0caed9f7f1356b0d72a",
"syncedAt": "2026-09-17T16:00:00Z"
"commit": "fb478a93cbb8d359dea33b75687a1bfdb6f504cd",
"syncedAt": "2026-09-22T16:08:55Z"
},
{
"id": "grill-me",
@@ -114,8 +114,8 @@
"repo": "https://github.com/mattpocock/skills.git",
"ref": "main",
"adapter": "skill-collection",
"commit": "74ca5fe077456a0b3b2f5310cf9430999fd0b5fd",
"syncedAt": "2026-09-17T16:00:00Z"
"commit": "c55ee46073ed923f86ce59a5eb3b6d895095d1b7",
"syncedAt": "2026-09-22T16:08:55Z"
},
{
"id": "updating-gitea-repositories",
@@ -144,6 +144,15 @@
"commit": "fcbde2e9cfa14f3fd2e154d7bef6bd264da61fcd",
"syncedAt": "2026-09-17T16:00:00Z"
},
{
"id": "artemis-automation",
"pluginName": "artemis-automation",
"repo": "https://git.playones.com/chenmingxuan/Artemis-Eapil.git",
"ref": "main",
"adapter": "claude-skill",
"commit": "8e757d462615fa211cafcb85ab56a4ad6cd73a28",
"syncedAt": "2026-09-22T16:08:55Z"
},
{
"id": "embedded-app-pc-debug",
"pluginName": "embedded-app-pc-debug",
@@ -177,8 +186,8 @@
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
"adapter": "skill-collection",
"commit": "7d42c9dcdd11b01716eb5a8eb473f493ea5b23c6",
"syncedAt": "2026-09-17T16:00:00Z"
"commit": "3204d86c4494dec07f8927378084e10f92a58a37",
"syncedAt": "2026-09-22T16:08:55Z"
},
{
"id": "shuorenhua",
@@ -160,6 +160,18 @@
},
"category": "Productivity"
},
{
"name": "artemis-automation",
"source": {
"source": "local",
"path": "./plugins/artemis-automation"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Developer Tools"
},
{
"name": "caveman",
"source": {
@@ -0,0 +1,41 @@
{
"name": "artemis-automation",
"version": "0.1.0",
"description": "使用 ARTEMIS MCP 完成 Android 自动化测试、批量执行、失败诊断、历史检索和报告交付。",
"author": {
"name": "chenmingxuan",
"url": "https://git.playones.com/chenmingxuan"
},
"homepage": "https://git.playones.com/chenmingxuan/Artemis-Eapil/src/branch/main/skills/artemis-automation",
"repository": "https://git.playones.com/chenmingxuan/Artemis-Eapil.git",
"license": "Apache-2.0",
"keywords": [
"artemis",
"android",
"automation",
"testing",
"mcp"
],
"skills": "./skills/",
"interface": {
"displayName": "ARTEMIS Android 自动化",
"shortDescription": "通过 ARTEMIS MCP 执行 Android 测试并交付报告。",
"longDescription": "通过 ARTEMIS MCP 在 Android 真机或模拟器上执行自然语言测试、批量用例、失败诊断、历史检索及 Word/Excel 报告交付。插件只分发 Skill;使用前必须单独部署并配置与该版本兼容的 ARTEMIS MCP、Android 设备和所需模型网关。",
"developerName": "chenmingxuan",
"category": "Developer Tools",
"capabilities": [
"Interactive",
"Read",
"Write"
],
"defaultPrompt": [
"使用 ARTEMIS Android 自动化检查设备并执行这个测试用例。",
"使用 ARTEMIS Android 自动化批量执行这些用例并导出报告。"
],
"websiteURL": "https://git.playones.com/chenmingxuan/Artemis-Eapil",
"privacyPolicyURL": "https://git.playones.com/chenmingxuan/Artemis-Eapil",
"termsOfServiceURL": "https://git.playones.com/chenmingxuan/Artemis-Eapil/src/branch/main/LICENSE",
"brandColor": "#2563EB",
"screenshots": []
}
}
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Google LLC.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -0,0 +1,9 @@
{
"sourceId": "artemis-automation",
"repo": "https://git.playones.com/chenmingxuan/Artemis-Eapil.git",
"ref": "main",
"commit": "8e757d462615fa211cafcb85ab56a4ad6cd73a28",
"adapter": "claude-skill",
"sourcePath": "skills/artemis-automation",
"syncedAt": "2026-09-22T16:08:55Z"
}
@@ -0,0 +1,37 @@
---
name: artemis-automation
description: 使用 ARTEMIS MCP 在 Android 真机或模拟器上完成自然语言测试、批量用例执行、失败诊断、历史检索与 Word/Excel 报告交付。适用于用户希望 AI 操作或测试 Android 应用、导入测试用例或分析 ARTEMIS 执行结果。
---
# ARTEMIS 自动化
使用项目提供的 MCP 工具操作 Android。报告由 ARTEMIS 自己生成,不要求宿主 AI 具备 Codex 私有工具、Word 或 Excel 插件。该 Skill 不提供桌面/浏览器自动化,也不提供历史动作脚本回放。
## 从需求到执行
1. 明确目标、预期结果、目标应用和必要前置条件。用户已授权执行且信息足够时直接开展,不逐步索要批准;缺少会改变业务操作的条件时再询问。用例起点按实际需求安排,不默认清数据、重装、重启应用或退出登录。
2. 用 `mobile_workspace` 查看设备和队列。沿用用户已指定的设备;多台可用设备且没有选择时先询问,不擅自挑选实体手机。任务参数显式传入 `device_serial`。同设备串行,不同设备可并行;本 Skill 不要求创建其他 AI 子代理。
3. 首次使用、工具出错、任务拒绝或设备异常,先 `mobile_diagnose`。按 `next_steps` 的依赖顺序处理;`attempt_fix=true` 可尝试工具自带修复。只有凭据相关故障才使用 `verify_credentials=true`,观察异常才使用 `probe_device=true`。不要盲目重发任务。MCP 不可用时在项目目录运行 `uv run artemis doctor` 并检查宿主 MCP 日志。
4. 选择执行模式:Flash 适合路径明确的界面操作;Pro 适合动态分支、持续计划、ADB/日志诊断或需要 Checker 明确校验的任务。**`mobile_run_task.model` 的 `flash`/`pro` 是执行模式,不是 API 模型名称**。步数长不自动等于需要 Pro;视频能力只有显式启用后才可用。
5. 单任务用 `mobile_run_task`;批量任务用 `mobile_batch`,读 [批量任务与交付](references/batches.md)。AI 可直接提交结构化用例,不必先创建 Excel。用户提供 Excel 时先 `mobile_task_file(action="preview", path=绝对路径)`,修正所有行错误后提交。
6. 单任务返回 `trace_id` 后,用 `mobile_manage_task(action="status", trace_id=...)` 跟踪;批次用 `mobile_batch(action="get", batch_id=...)`。尊重工具建议的等待周期,等待期间可以整理已得证据,避免连续高频轮询。停滞或失败先诊断;不把同一业务任务自动重新提交来解决模型错误。
## 结果、证据与交付
- 执行状态与业务判定分开:`completed` 只说明执行结束,不等于测试通过。检查工具返回的验证摘要、预期/实际结果,以及 `mobile_inspect_trace` 的步骤/截图。证据不足标记“未验证”;前置条件不满足说明阻塞原因,不能编造通过结论。
- 用 `mobile_export_report(session_id=...)` 获取单任务报告;批次传 `batch_id` 获取汇总和每项任务报告的 ZIP。未指定格式时使用保存的 Word/Excel 偏好。执行中的导出只是当前快照。
- 工具返回 MCP 主机上的绝对文件路径和下载 URL。向用户交付真实生成的文件链接,并说明完成、失败、未验证、跳过数量及重要限制。不要只说“报告已生成”而不交付文件。
- 用 `mobile_search_history` 按名称、目标、编号或标签搜索全部历史;日期先按用户时区转换为 Unix 秒,`start` 含边界、`end` 不含边界。按返回的 `total/page/size` 翻页,不能把第一页当作全部历史。
- `mobile_inspect_trace` 是只读步骤证据查看;其中历史上下文的 replay 术语不表示重新执行设备动作。
## 配置和恢复边界
- `mobile_config` 读取非敏感设置。只有用户明确要求保存或修改默认配置时,才传 `changes` 与 `persist_defaults=true`;这些设置影响后续任务,不能为一次临时需求擅自改变以后所有任务。
- 模型名称支持预设 `gpt-5.6-luna`、`mimo-v2.6-flash` 和自定义名称,取决于用户配置的 Responses 网关。默认思考强度 `high`、单次请求 300 秒、失败后最多重试 5 次、固定间隔 10 秒;默认关闭视频分析与录屏。不根据模型名称猜测网关能力。
- 强度选 `default` 表示不发送该参数,和 `none`(显式要求关闭)不同。接口拒绝强度参数时明确呈现失败,先询问用户是否改用 `default`,不能静默降级或换模型。
- API Key 只通过网页或 `.env` 配置。不要请求用户把 key 发到聊天中,不读取/回显已有 key。保存后新任务生效;直接编辑环境文件后需重启相应服务,运行中的任务保留自己的配置。
- 当前视频开关仅提供保存的全局设置,没有单任务覆盖或独立视频文件分析接口。用户只要求临时分析且默认关闭时,明确说明这个限制;不能用“先开全局、执行后再关”影响同期任务。只有用户改为要求保存启用后,才修改配置并提交新任务。
- 请求重试是模型调用恢复,不是测试用例重跑。批次默认失败继续、不自动重跑;服务重启后保留状态并暂停,用户明确要求后才继续或复制失败项。
- 批次暂停只停止后续派发;要停止当前任务使用 `mobile_manage_task(action="stop", trace_id=...)`。停止可能保留已经产生的业务效果,不声称自动回滚。
需要编写外部可执行测试代码时,先通过 ARTEMIS 实际探索目标应用,确认页面、定位器和转换。优先动态定位,框架支持时才以已验证坐标兜底;在最终脚本中明确等待条件,不将 AI 思考耗时当作确定性等待。
@@ -0,0 +1,4 @@
interface:
display_name: "ARTEMIS Android 自动化"
short_description: "通过 MCP 完成 Android 单次与批量测试、失败诊断和报告交付"
default_prompt: "使用 $artemis-automation 检查设备,执行我指定的 Android 测试用例并交付有证据的报告。"
@@ -0,0 +1,56 @@
# 批量任务与报告交付
## 直接提交用例
用户要求执行明确的测试集合时,将每个独立目标转成一个 case。`parameters` 是简单值字典,`tags` 是字符串,不能填列表;目标中的 `{{参数名}}` 会替换成参数值。前置条件用自然语言描述真实起点。
先生成唯一 `request_id`(例如 UUID),保存在当前任务记录中,然后调用:
```json
{
"action": "create",
"request_id": "52c3e95d-7e31-4e41-b42d-27aa3774fb2d",
"batch": {
"name": "系统设置冒烟测试",
"device_serial": "用户已选择的设备编号",
"profile": "pro",
"formats": ["docx", "xlsx"],
"stop_on_failure": false,
"timeout_seconds": 1800,
"cases": [
{
"name": "设置首页可达",
"goal": "打开系统设置,确认设置首页可见,不修改任何设置。",
"expected": "能看到设置首页及至少一个设置分类,记录实际文字与截图证据。",
"app": "com.android.settings",
"preconditions": "设备已解锁。",
"parameters": {},
"tags": "冒烟,只读"
}
]
}
}
```
`create` 只保存已校验的草稿。检查返回批次编号和范围,用户已经授权执行时接着 `mobile_batch(action="start", batch_id=...)`;用户只要求准备方案时留在草稿,不启动。保存 `batch_id`,用 `get` 跟踪各项状态。
创建请求超时可能已经落库。先 `lookup(request_id=...)`;需要重发时保留**相同 request_id 和相同内容**。服务会返回原批次,内容不同会拒绝。不要通过换编号“修复超时”,否则会形成重复批次。
## Excel 导入
`mobile_task_file(action="template")` 返回空白模板的本地路径。标准列为任务名称、任务目标、预期结果、目标应用、前置条件、输入参数(JSON)、标签。每行独立,不支持公式、宏、外部链接。
`preview` 返回 `valid/tasks/errors`,不会执行。所有错误处理完后,将 `tasks` 中每项的预览字段 `row` 去掉,作为 `batch.cases` 提交。不要跳过错误行却声称全部导入成功。
## 状态与异常
- 批次 `draft` 等待开始;`running` 调度中;`paused` 等待处理;`completed` 表示所有项进入终态,不代表全部通过。
- 单项 `pending/queued/running/finalizing` 尚未结束,`completed/failed/cancelled/interrupted/skipped` 是终态。`finalizing` 在生成报告,不能提前宣称报告已完成。
- `pause` 保留当前执行。设备断开、模型持续失败或任务达到批次时限会暂停后续派发;用诊断确认恢复后再响应用户的继续指令。
- `resume` 只继续未开始项;`retry_failed` 复制失败、取消和中断项为新草稿,不修改原结果。必须来自用户的明确重跑请求。该动作不提供创建去重,响应不确定时先 `list` 查找新批次,不自动重复调用。
## 报告
`mobile_export_report(batch_id=...)` 下载批次 ZIP,包含汇总和各项报告,格式由创建批次时的设置决定。单任务传 `session_id`(单任务 MCP 的 `trace_id`)以及可选 `formats=["docx"]`。默认格式通过 `mobile_config(section="reports")` 查看。
结果文件在 MCP 主机的 `artifacts/mcp_exports/`。不要把 Office 文件内容当作文本塞入上下文;先检查工具返回的结构化结论,需要进一步审阅时再用宿主具备的文档工具打开文件。失败和未验证项也必须交付,保持原始证据。
@@ -2,8 +2,8 @@
"sourceId": "caveman",
"repo": "https://github.com/JuliusBrussee/caveman.git",
"ref": "main",
"commit": "c2906c626be1d703b04e203d9f804a263e87f4ef",
"commit": "2fd153c67988e980fb0b2455c90832159a6a5a25",
"adapter": "codex-plugin",
"sourcePath": "plugins/caveman",
"syncedAt": "2026-09-17T16:00:00Z"
"syncedAt": "2026-09-22T16:08:55Z"
}
@@ -2,8 +2,8 @@
"sourceId": "grill-me",
"repo": "https://github.com/mattpocock/skills.git",
"ref": "main",
"commit": "74ca5fe077456a0b3b2f5310cf9430999fd0b5fd",
"commit": "c55ee46073ed923f86ce59a5eb3b6d895095d1b7",
"adapter": "skill-collection",
"sourcePath": "skills/productivity",
"syncedAt": "2026-09-17T16:00:00Z"
"syncedAt": "2026-09-22T16:08:55Z"
}
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作",
"version": "20260605",
"keySource": "none",
"syncedAt": "2026-09-17T16:02:37Z"
"syncedAt": "2026-09-22T16:12:43Z"
}
@@ -2,8 +2,8 @@
"sourceId": "next-skills",
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
"commit": "7d42c9dcdd11b01716eb5a8eb473f493ea5b23c6",
"commit": "3204d86c4494dec07f8927378084e10f92a58a37",
"adapter": "skill-collection",
"sourcePath": "skills",
"syncedAt": "2026-09-17T16:00:00Z"
"syncedAt": "2026-09-22T16:08:55Z"
}
@@ -31,7 +31,7 @@ Enable Cache Components on an app and walk it to a passing build. This skill seq
- **Offline docs.** Guide links have offline copies under `node_modules/next/dist/docs/` (bundled since Next.js 16.2), with the directory layout numbered for ordering (e.g. `node_modules/next/dist/docs/01-app/02-guides/migrating-to-cache-components.md`). If you can't predict the numbered prefix, `find node_modules/next/dist/docs -name '<slug>.md'` resolves it. The `/docs/messages/*` error pages are not bundled.
- **Older versions without bundled docs.** Suggest `npx @next/codemod@latest agents-md` to the user before starting: it downloads a version-matched copy to `.next-docs/` and writes an index into `AGENTS.md` / `CLAUDE.md`. It touches files in their repo, so ask first and run it only if they want it.
- **Older versions without bundled docs.** Suggest `npx @next/codemod@latest agents-md` to the user before starting: it downloads a version-matched copy to `.next-docs/` and writes an index into `AGENTS.md`. It touches files in their repo, so ask first and run it only if they want it.
## the shape of the work
@@ -180,3 +180,17 @@ Close the session with the same session and restore context:
`agent-browser --session "$SESSION" --restore close`. `close` saves
that session's cookies and storage so the next loop's `--restore` open
keeps the user logged in. Leave `next dev` up for the next loop.
## Report Next.js friction
Only participate in agent feedback when managed Next.js feedback instructions
are already loaded for the project. Their presence means the feature is
enabled; their absence means it is disabled.
When enabled, add qualifying feedback candidates found during verification to
the feedback candidate queue for the current user request, then continue
verification. Do not run the feedback command or open review forms during the
loop or at this Skill's teardown.
The managed instructions own the single reporting pass immediately before the
final response. If they are absent, do not queue or report feedback.
@@ -36,7 +36,7 @@ Talk to the user in terms of what they'll see — PRs, features, and how the app
- **Offline docs.** Guide links have offline copies under `node_modules/next/dist/docs/` (bundled since Next.js 16.2), with the directory layout numbered for ordering (e.g. `node_modules/next/dist/docs/01-app/02-guides/adopting-partial-prefetching.md`). If you can't predict the numbered prefix, `find node_modules/next/dist/docs -name '<slug>.md'` resolves it. The `/docs/messages/*` error pages are not bundled.
- **Older versions without bundled docs.** Suggest `npx @next/codemod@latest agents-md` to the user before starting: it downloads a version-matched copy to `.next-docs/` and writes an index into `AGENTS.md` / `CLAUDE.md`. It touches files in their repo, so ask first and run it only if they want it.
- **Older versions without bundled docs.** Suggest `npx @next/codemod@latest agents-md` to the user before starting: it downloads a version-matched copy to `.next-docs/` and writes an index into `AGENTS.md`. It touches files in their repo, so ask first and run it only if they want it.
## background
@@ -1,6 +1,6 @@
{
"name": "oh-my-codex",
"version": "0.21.5",
"version": "0.21.6",
"description": "oh-my-codex 是 Codex CLI 的多 Agent 编排、结构化工作流、插件级 hooks、MCP 和 HUD 扩展插件。",
"author": {
"name": "Yeachan Heo",
@@ -2,8 +2,8 @@
"sourceId": "oh-my-codex",
"repo": "https://github.com/Yeachan-Heo/oh-my-codex.git",
"ref": "main",
"commit": "cb955b0d5becbef76d2c1f0096b6e1f238e1e7f7",
"commit": "cdc24a71408ebd6bd0362f52170f0d7998f77007",
"adapter": "codex-plugin",
"sourcePath": "plugins/oh-my-codex",
"syncedAt": "2026-09-11T16:00:01Z"
"syncedAt": "2026-09-22T16:08:55Z"
}
+1 -1
View File
@@ -28,7 +28,7 @@ This project is kept free and open source with the support of <a href="https://w
Thanks to [Kimi](https://www.kimi.com/code/?aff=ppt-master) for sponsoring this project! [Kimi K3](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart) is the world's first open 3T-class model, featuring native vision and a 1-million-token context window. With PPT Master, K3 can understand source materials such as PDFs, DOCX files, and web pages, identify key points, structure the narrative, and generate a natively editable PPTX that you can continue refining in PowerPoint.
**Try [Kimi Code](https://www.kimi.com/code/?aff=ppt-master), or access the API through the Kimi Open Platform ([中文站](https://platform.kimi.com?aff=ppt-master) | [Global](https://platform.kimi.ai?aff=ppt-master)).**
**Try a [Kimi Code plan](https://www.kimi.com/code/?aff=ppt-master) ([中文站](https://www.kimi.com/code/?aff=ppt-master) | [Global](https://www.kimi.ai/code/?aff=ppt-master)), or access the API through the Kimi Open Platform ([中文站](https://platform.kimi.com?aff=ppt-master) | [Global](https://platform.kimi.ai?aff=ppt-master)).**
<hr>
@@ -2,8 +2,8 @@
"sourceId": "ppt-master",
"repo": "https://github.com/hugohe3/ppt-master.git",
"ref": "main",
"commit": "9c29d7d80d31d6aa658ae0caed9f7f1356b0d72a",
"commit": "fb478a93cbb8d359dea33b75687a1bfdb6f504cd",
"adapter": "claude-skill",
"sourcePath": "skills/ppt-master",
"syncedAt": "2026-09-17T16:00:00Z"
"syncedAt": "2026-09-22T16:08:55Z"
}
@@ -2,7 +2,7 @@
name: ppt-master
description: "多格式源文档到高质量 SVG 页面再导出 PPTX 的多阶段演示文稿生成工作流。"
metadata:
version: "6.5.0"
version: "6.6.0"
copyright: "Copyright (c) 2025-2026 Hugo He"
license: "MIT"
official_repository: "https://github.com/hugohe3/ppt-master"
@@ -18,7 +18,7 @@ For the best results, use **[Kimi K3](https://www.kimi.com/code/?aff=ppt-master)
Thanks to [Kimi](https://www.kimi.com/code/?aff=ppt-master) for sponsoring PPT Master. [Kimi K3](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart) is the world's first open 3T-class model, featuring native vision and a 1-million-token context window. With PPT Master, K3 can understand PDFs, DOCX files, web pages, and other source material, structure the narrative, and generate a natively editable PPTX.
**Try [Kimi Code](https://www.kimi.com/code/?aff=ppt-master), or access the API through the Kimi Open Platform ([中文站](https://platform.kimi.com?aff=ppt-master) | [Global](https://platform.kimi.ai?aff=ppt-master)).**
**Try a [Kimi Code plan](https://www.kimi.com/code/?aff=ppt-master) ([中文站](https://www.kimi.com/code/?aff=ppt-master) | [Global](https://www.kimi.ai/code/?aff=ppt-master)), or access the API through the Kimi Open Platform ([中文站](https://platform.kimi.com?aff=ppt-master) | [Global](https://platform.kimi.ai?aff=ppt-master)).**
## Model Access Partners
@@ -18,7 +18,7 @@ PPT Master 始终免费开源。以下赞助方共同支持项目的持续维护
感谢 [Kimi](https://www.kimi.com/code/?aff=ppt-master) 赞助 PPT Master。[Kimi K3](https://platform.kimi.com/docs/guide/kimi-k3-quickstart) 是全球首个开源 3T 级模型,拥有原生视觉能力与 100 万 Token 上下文。搭配 PPT Master,K3 可以理解 PDF、DOCX、网页等原始资料,梳理演示逻辑并生成原生可编辑的 PPTX。
**立即体验 [Kimi Code](https://www.kimi.com/code/?aff=ppt-master),或通过 Kimi 开放平台([中文站](https://platform.kimi.com?aff=ppt-master)|[Global](https://platform.kimi.ai?aff=ppt-master))使用 API。**
**立即体验 [Kimi Code](https://www.kimi.com/code/?aff=ppt-master)([中文站](https://www.kimi.com/code/?aff=ppt-master)|[Global](https://www.kimi.ai/code/?aff=ppt-master)),或通过 Kimi 开放平台([中文站](https://platform.kimi.com?aff=ppt-master)|[Global](https://platform.kimi.ai?aff=ppt-master))使用 API。**
## 模型接入合作伙伴
@@ -14,7 +14,7 @@ Motion is several separate capabilities, not one dial; two of them are decided w
| A continuous action — slide-in, flip, camera push-in, progressive reveal, camera pan | **Morph: author the action as two static pages**, then select Morph and add explicit pairs when identity must be deterministic; there is no keyframe timeline anywhere — the difference between two editable slides *is* the animation | **Page authoring (Step 6), then motion post-processing** — §2.1, §3.1 |
| A static full-bleed page that should stop looking frozen | Slow `path_*` motion on a visually subordinate image or atmospheric layer | Post-processing; §4.1 |
| Carousel, counting numerals, parallax depth, click-to-reveal flip card | Four recipes assembled from the mechanisms above; carousel and odometer need paired pages | §4.2 |
| Unattended looping playback | `--auto-advance <seconds>`, optionally with `-t none` | Export; §3 |
| Unattended looping playback | `--auto-advance <seconds>`, optionally with `-t none`; add `--kiosk` when the show must loop and ignore clicks (signage, a touch guide navigated by its own buttons) | Export; §3 |
| A transition or object animation needs an audible cue | Optional `transition.sound` or object `sound`, selected only after the visual solution is complete and synced from the global library; a narrated MP4 uses either the verified native-export mix or explicit slideshow capture, never both | Post-motion; §2.2 |
| Nothing should move | `-t none` and per-element `none` | Export; §1 |
@@ -163,7 +163,8 @@ python3 skills/ppt-master/scripts/svg_to_pptx.py <project> -t none --auto-advanc
|---|---|
| `-t/--transition` | Default `fade`; `none` keeps an explicit auto-advance |
| `--transition-duration` | Default `0.4` |
| `--auto-advance` | Seconds; click still advances |
| `--auto-advance` | Seconds, longer than the page's animation total; click still advances |
| `--kiosk` | Loop until Escape and ignore click/keyboard advance; only timings and hyperlinks move between slides |
**Hard rule — no silent downgrade**: an unknown effect, unsupported option, or invalid duration fails export and is never replaced by `fade`. Carrier XML, MCE fallbacks, and read-back: [`pptx-transitions.md`](../scripts/docs/pptx-transitions.md).
@@ -15,7 +15,7 @@
| `banner` | Landscape Banner | `1920x1080` | `0 0 1920 1080` | 16:9 | Web banners, digital screens |
| `a4` | A4 Print | `1240x1754` | `0 0 1240 1754` | 1:sqrt(2) | Print posters, flyers |
Custom canvases likewise use `0 0 W H` with positive integer pixels; a fractional positive canvas is accepted only as compatible input for an imported custom PowerPoint slide size. All pages and internal Layout prototypes in one export share the same numeric canvas within PowerPoint's supported slide range (914,400–51,206,400 EMU per side, about 96–5,376 SVG px). `ppt169` is exactly `1280x720`; same-ratio canvases such as `banner` are different coordinate systems.
Custom canvases likewise use `0 0 W H` with positive integer pixels; a fractional canvas is accepted only as compatible input for an imported custom PowerPoint slide size. All pages and internal Layout prototypes in one export share one numeric canvas within PowerPoint's slide range (914,400–51,206,400 EMU per side, about 96–5,376 SVG px). `ppt169` is exactly `1280x720`; `banner` shares the ratio, not the coordinate system.
```xml
<svg viewBox="0 0 1280 720"> <!-- PPT 16:9 -->
@@ -24,7 +24,7 @@ Custom canvases likewise use `0 0 W H` with positive integer pixels; a fractiona
## Platform Keep-clear
Canvas dimensions imply no title band, content topology, or recurring chrome; reserve space only for a real output obstruction. For `story`, keep meaning-bearing text, identity, and calls to action within `y=120..1740` by default because mobile story controls occupy the top and bottom; images, backgrounds, and texture may stay full bleed. An exact target-platform overlay guide or installed template overrides this advisory band. Story starting geometry: 80 px margins, 920 px measure, title band ≈ 90 px; regions stack, side-by-side only for near-square halves, and the band below 1740 carries bleed, texture, or a cropped element, not dead space.
Canvas dimensions imply no title band, topology, or recurring chrome; reserve space only for a real output obstruction. For `story`, keep meaning-bearing text, identity, and calls to action within `y=120..1740` by default because mobile story controls occupy the top and bottom; images and texture may bleed. An exact platform overlay guide or installed template overrides this band. Story starting geometry: 80 px margins, 920 px measure, title band ≈ 90 px; regions stack, side by side only for near-square halves, and the band below 1740 carries bleed, texture, or a cropped element, not dead space.
## Typography Scale Start
@@ -52,6 +52,6 @@ low = round(span * 0.025); start = round(span * 0.029); high = round(span * 0.03
| `story` | 1920 | 48–63 | 56 |
| `a4` | 1754 | 44–58 | 51 |
`story` and `banner` share one span but not one measure: the same body size sits on a 1920 px line in `banner` and a 1080 px line in `story` (about half the characters per line), so portrait pages often settle at the low end of the band.
`story` and `banner` share one span but not one measure: the same body sits on a 1920 px line in `banner` and a 1080 px line in `story`, so portrait pages settle low. Executor's layout values and icon sizes are 1280-wide: scale by canvas width ÷ 1280, and stack regions instead of splitting once a column holds under about 30 body characters.
**Default — starting anchor, not a floor (may override when confirmed identity, source fidelity, or target viewing conditions require it)**: start from the table or formula, then resolve the complete role ramp and page density from the content and delivery context. The band only surfaces unusual values; falling outside it is not a validation failure. Apply the viewing-distance baseline in [`shared-standards-core.md`](./shared-standards-core.md) instead of silently shrinking a recurring role to make content fit.
**Default — starting anchor, not a floor (confirmed identity, source fidelity, or viewing conditions may override)**: start from the table or formula, then resolve the role ramp and page density from content and delivery context; the band only flags unusual values and is not a validation gate. Apply the viewing-distance baseline in [`shared-standards-core.md`](./shared-standards-core.md) instead of silently shrinking a recurring role to make content fit.
@@ -8,7 +8,7 @@ What the Strategist needs to run the two-stage confirmation: the surface decisio
| Most recent explicit surface instruction | Branch |
|---|---|
| The user explicitly delegates confirmation | Make the combined Stage-1 communication/template decision, install it, then present one complete final summary. Do not launch the page or fabricate UI receipts. |
| The user explicitly delegates confirmation | Make the combined Stage-1 communication/template decision, install it, then present one complete final summary. Do not launch the page or fabricate UI receipts; delegation skips the page, not the craft — the three solution intents are still authored and the chosen one recorded with its reason. |
| The user asks for or agrees to personally confirm in chat, or declines the page | Use chat for both stages; Stage 1 includes the template/free-design choice. Do not launch the page, run `--wait-only`, or require UI-authored results. |
| No explicit instruction | Use the page. |
@@ -22,7 +22,7 @@ Construct in this order: (1) resolve data domain, categories, units, baseline, s
**Perceptual reading**: choose the least ambiguous presentation of the same data. Preserve source or semantic order when it carries meaning; otherwise sort for the page's comparison task; a right-to-left deck sets `axes.category.reverse` so categories read the way the page does. Prefer direct series labels when legible; keep legends, grid lines, and ticks only when they materially reduce lookup effort. Comparable panels share domain, scale, and category order unless a disclosed difference is the message. Magnitude bars and columns start from zero; interval marks keep their authoritative domain; any non-zero baseline or axis break is explicit and never exaggerates. A dual-axis chart is valid only when both series share the exact time/category domain and units and identities stay unambiguous; otherwise separate the views.
**Reference — chart color and annotation conventions**: a monochromatic depth scheme reads cleaner than rainbow — the primary series in the full theme color, a comparison series in the same hue at about 60% alpha, baselines as a gray dashed line, the accent color only on the key data points; positive / warning / negative trends use the deck's green / yellow / red polarity roles. Place direct data labels at bar ends or beside points instead of a legend where they stay legible; annotate genuine inflection points in words ("policy change", "product launch"); mark an industry average or target as a gray dashed comparison baseline; keep units and precision consistent within one chart; add a muted source note where attribution or provenance matters.
**Reference — chart color and annotation conventions**: a monochromatic depth scheme reads cleaner than rainbow — the primary series in the full theme color, a comparison series in the same hue at about 60% alpha, baselines as a gray dashed line (solid and lighter on a native-marked chart, whose series carry no dash), the accent color only on the key data points; positive / warning / negative trends use the deck's green / yellow / red polarity roles. Place direct data labels at bar ends or beside points instead of a legend where they stay legible; annotate genuine inflection points in words ("policy change", "product launch"); mark an industry average or target as a gray dashed comparison baseline; keep units and precision consistent within one chart; add a muted source note where attribution or provenance matters.
**Per-object completeness**: preserve every authoritative series, category, point, label, unit, qualifier, source, and scale cue. For a `<object-key>=yes` chart, the JSON mirrors the drawn fallback item by item in the same edit — legend labels verbatim, point-level exception colors as `point_colors`, the axis scale, `plot_area` from the plot-area marker, visible data labels or summary figures as `data_labels` or companion text, actual label/axis/grid colors (omit `text_color` and siblings rather than guess). When the source cannot determine a required scale or derived value, return the ambiguity upstream in Default or resolve it from explicit source facts in Quick; never fabricate at draw time.
@@ -24,7 +24,7 @@ Conditional Executor authority for image status handling, placement, crop behavi
**Hard rule — visible-layer timing**: every crop, lens, scrim, comparison, evidence, or annotation layer an adopted motion plan needs already exists in the final SVG; the motion stage may regroup ordinary Slide-local content but never invents or modifies visible content. When no legal unit can serve a non-binding suggestion, simplify to available units, a page transition, or `none`; an unrepresentable explicit requirement follows failure recovery.
**Hard rule — narrow visual-inspection scope**: start from §VIII `Reference` plus dimensions; inspect an asset (or its review copy) once, and only while its focal-safe crop, overlay contrast, quiet region, or planned subject relationship stays ambiguous — on a photo-led page that may be each of several assets, still once each and never the folder; a `Generated` asset only when its intent and dimensions cannot resolve the ambiguity, never routinely. Inspection never reopens selection, changes identity or must-use, infers provenance, substitutes, or invents focus; uncertain `adaptive` focus uses `meet`, and conflicting binding constraints return upstream.
**Hard rule — narrow visual-inspection scope**: start from §VIII `Reference` plus dimensions; inspect an asset (or its review copy) once, and only while its focal-safe crop, overlay contrast, quiet region, or planned subject relationship stays ambiguous — on a photo-led page that may be each of several assets, still once each and never the folder; a `Generated` asset only when its intent and dimensions cannot resolve the ambiguity, never routinely; when native text will sit on it, a scrim or plate first decouples contrast from the picture, and only a design that forbids one earns a single look at the reserved region. Inspection never reopens selection, changes identity or must-use, infers provenance, substitutes, or invents focus; uncertain `adaptive` focus uses `meet`, and conflicting binding constraints return upstream.
## 1. Image Composition
@@ -8,7 +8,7 @@ Conditional late-stage authority for generating or validating the complete speak
## 1. Complete Speaker-notes Document
Write the whole deck to `notes/total.md` in one batch: `# <number>_<page_title>` per page, `---` between pages; only the heading is stripped before TTS. `notes_to_audio.py` reads the body verbatim, so write prose only — no list/bullet markup, stage markers, key-point labels, duration lines, or other metadata. Keep one language unless the confirmed contract is bilingual — then every page keeps the same segment order (first language, blank line, second language); spell out digits or symbols when literal TTS would sound wrong (Chinese "百分之六十八" rather than "68%").
Write the whole deck to `notes/total.md` in one batch: `# <number>_<page_title>` per page (`# Slide <number>` only for a script written before the roster exists; the splitter takes both), `---` between pages; only the heading is stripped before TTS. `notes_to_audio.py` reads the body verbatim, so write prose only — no list/bullet markup, stage markers, key-point labels, duration lines, or other metadata. Keep one language unless the confirmed contract is bilingual — then every page keeps the same segment order (first language, blank line, second language; narration reads both and doubles the page's time, so notes headed for audio keep one language unless both are meant to be heard); spell out digits or symbols when literal TTS would sound wrong (Chinese "百分之六十八" rather than "68%").
**Pre-SVG narration branch**: when `notes/total.md` already exists from a final/literal script or Quick direct narrated video, validate it instead of regenerating. Retain every word and segment of a final/literal script; agent-authored Quick narration may be repaired only for final-SVG inconsistency and before audio. A `# Slide <number>` heading remains valid until Generate Step 7.1 resolves the roster.
@@ -20,7 +20,7 @@ Conditional Executor authority for `template_reuse_scope: mirror|layout` with `p
**Hard rule**: the complete Slide prototype SVG is authoritative and already resolves its Master + Layout; standalone Master/Layout definition SVGs are invalid; never author from a roster, manifest, sidecar, filename, or summary (manifest/text-slot files are tool metadata whose absence neither invalidates a legacy workspace nor permits text-topology changes). Resolve each page's prototype directly from its `page_layouts` row — `mirror` → §1.1 (the workspace must support `replication_mode: mirror`); `layout` → resolve `P<NN>: <basename>`, retain the structure system, apply the re-skin/reflow rules below; a missing row stops (adaptive mode still needs one selected input prototype, and there is no filename/page-type fallback). A mapping change stops and returns to Strategist to update the plan, read back and validate, then load the new prototype.
**Default — re-skin `layout` (may override when the application plan keeps template visuals and the lock reflects them)**: inherit geometry, label/legend placement, and series encoding; repaint gradients, shadows, fills, and strokes from the current style/lock. `mirror` preserves visuals under §1.1.
**Default — re-skin `layout` (may override when the application plan keeps template visuals and the lock reflects them)**: inherit geometry, label/legend placement, and series encoding; repaint gradients, shadows, fills, and strokes from the current style/lock — Master/Layout atoms included, whose contract is their ids, order, geometry, and `data-pptx-*` metadata, never their paint. `mirror` preserves visuals under §1.1.
**Hard rule — font size is skin, not geometry (non-mirror)**: a template's hardcoded `font-size` values are never inherited. Build a per-page text inventory from §IX and the current notes; map every structural role and reusable slot (`title`, `subtitle` / `lead`, `body`, `annotation`, `footnote` / `page_number`, reusable hero or emphasis slots) to a declared `spec_lock.typography` role — a missing role returns upstream, never a numerically close one; choose the anchor or one value within `±2` px before placing text (only a Slide-local, non-slot Hero/Display element may use `executor-base.md`'s sparse exception; reusable slots never do); lay out from those sizes — line height, wrapped lines, child `y` / `dy`, card padding and height, column gaps, image/chart area — and reflow containers and local geometry with the bounded type rather than starting from a template size. Do not repaginate, split, or drop content; a value still outside the band returns upstream under `executor-base.md` §2.1.
@@ -28,7 +28,7 @@ With a `table/<key>` primary reference, [`executor-visualization.md`](./executor
**Per-cell completeness**: never drop a row, column, summary, footnote, unit, or qualifier to imitate a lighter catalog preview; reflow text, widen the column, rebalance neighbors, or increase row height within the page's information contract and [`executor-base.md`](./executor-base.md) typography bounds.
**Spans and chrome**: merge only rectangular regions whose repeated boundaries would obscure an intended shared heading or group, with no competing visible content in covered areas; native merge fields belong exclusively to [`native-data-interface.md`](./native-data-interface.md). Distinguish header/body/summary with the lightest sufficient weight, fill, rule, and whitespace; decorative card treatment must not break row or column continuity. **Reference — table defaults (treatment follows the locked style)**: a header set apart by weight and a rule, or by a fill with reversed text where the style carries fills; zebra rows at `fill-opacity` 0.05, numbers right-aligned and text left-aligned with consistent units and precision, one highlighted row in the accent at `fill-opacity` 0.1, and horizontal rules only — avoid a full grid of lines.
**Spans and chrome**: merge only rectangular regions whose repeated boundaries would obscure an intended shared heading or group, with no competing visible content in covered areas; native merge fields belong exclusively to [`native-data-interface.md`](./native-data-interface.md). Distinguish header/body/summary with the lightest sufficient weight, fill, rule, and whitespace; decorative card treatment must not break row or column continuity. **Reference — table defaults (treatment follows the locked style)**: a header set apart by weight and a rule, or by a fill with reversed text where the style carries fills; zebra rows at `fill-opacity` 0.05, numbers right-aligned and text left-aligned with consistent units and precision, one highlighted row in the accent at `fill-opacity` 0.1, and horizontal rules only — avoid a full grid of lines (a native-marked table projects them as per-cell `borders`; a uniform payload border means a full grid).
---
@@ -117,7 +117,7 @@ A/B describe a hero or a local single-subject region; C/D are hero-page composit
A sheet generates compatible transparent **illustration**, **illustrated-icon**, or **decorative lettering** elements sharing rendering, deck-color treatment, and finish; subjects, silhouettes, weights, and jobs may differ, and SVG composes after slicing. Lettering is stable Layer 1 artwork, never page copy turned into an image.
**Default — batch compatible elements; split when separate generation improves the result**: group illustrated-icon cues normally; group lettering by compatible letterform character and treatment (not font name); split for style, geometry, detail, quality, or semantic precision. A single element may use a keyed `1x1` sheet. Full-canvas or opaque images take the normal §4.1 path.
**Default — batch compatible elements; split when separate generation improves the result**: group illustrated-icon cues normally; group lettering by compatible letterform character and treatment (not font name); split for style, geometry, detail, quality, or semantic precision. A single element may use a keyed `1x1` sheet. Full-canvas or opaque images take the normal §4.1 path; several opaque photos for a multi-cell layout may share one sheet under the same contract, sliced by `--grid` with `--inset` and no key color, `--trim`, or `--alpha`.
**Hard rule — a sheet is a generation source, not a slide asset**: never referenced from SVG; out of `spec_lock.md images` in Default, generation-only in Quick's context and manifest; only sliced element rows are placed.
@@ -157,8 +157,8 @@ python3 scripts/slice_images.py <project>/images/illus_sheet.png --grid 2x3 \
--names team,product,customer,growth,risk,vision --trim --alpha \
--bg "${SHEET_KEY_HEX}" --strict-alpha
# Generated sheets arrive through JPEG, so the ground is never exactly the pure
# key: the first run usually fails with a measured border line — rerun with the
# --bg / --tolerance it names (the measured ground, which skips despill).
# key: when every finding is measured key noise the tool retries once with the
# tolerance it measured; content touching a cell edge still fails and stays yours.
```
`--names` count equals `rows*cols`; `--strict-alpha` writes nothing on an incomplete cut. Three quality constraints:
@@ -15,7 +15,7 @@ Every provider-sourced image lands in one of two tiers; everything else is rejec
| Tier | Licenses | On-slide attribution |
|---|---|---|
| `no-attribution` | CC0, Public Domain, Pexels License, Pixabay Content License | None |
| `attribution-required` | CC BY, CC BY-SA | Inline credit `<text>` on the slide |
| `attribution-required` | CC BY, CC BY-SA, publisher terms that require a source credit | Inline credit `<text>` on the slide (the CC token only for a CC licence) |
| `manual` | Directly selected URL or adopted-page image, license unverified | None — rights and any credit are the user's responsibility |
**Forbidden — auto-rejected**: CC BY-NC, CC BY-NC-SA, CC BY-ND, CC BY-NC-ND, All Rights Reserved, unknown or missing license.
@@ -124,7 +124,7 @@ Each successful download appends or replaces one entry keyed on `filename` (writ
## 7. On-Slide Attribution Contract
For `license_tier: attribution-required`, every slide using the asset carries a visible, readable credit bound unambiguously to it, preserving author, source/provider, and CC BY / CC BY-SA facts from `attribution_text`. Position, size, color, per-image versus combined credits, labels, and contrast treatment belong to the page — a compact credit near the image edge or footnote area for one image, per-image credits or one labeled combined line for several, a quiet region (a scrim or gradient only when contrast fails) on a hero. Compress without dropping required facts: `team.jpg — "Untitled" via Openverse — license: CC0 (...)` → `via Openverse / CC0`; `team.jpg — "Sunset" by Jane Doe via Wikimedia Commons — license: CC BY-SA 4.0 (...)` → `© Jane Doe / Wikimedia / CC BY-SA 4.0`.
For `license_tier: attribution-required`, every slide using the asset carries a visible, readable credit bound unambiguously to it, preserving author, source/provider, and — for a CC licence — the CC BY / CC BY-SA fact from `attribution_text`. Position, size, color, per-image versus combined credits, labels, and contrast treatment belong to the page — a compact credit near the image edge or footnote area for one image, per-image credits or one labeled combined line for several, a quiet region (a scrim or gradient only when contrast fails) on a hero. Compress without dropping required facts: `team.jpg — "Untitled" via Openverse — license: CC0 (...)` → `via Openverse / CC0`; `team.jpg — "Sunset" by Jane Doe via Wikimedia Commons — license: CC BY-SA 4.0 (...)` → `© Jane Doe / Wikimedia / CC BY-SA 4.0`.
---
@@ -66,7 +66,7 @@ The hash is a synchronization receipt, not proof of semantic equivalence; never
### Table schema — `ppt-master.semantic-table.v2`
Every payload carries that exact `schema`; `columns` holds the optional header row and `rows` the body rows; `column_widths` / `row_heights` are relative weights. A cell is a string or an object with `text` (or `paragraphs` for rich text), `fill`, `color`, `align` (`l` / `ctr` / `r`), `valign` (`top` / `middle` / `bottom`), `bold`, `font_size`, `padding`, and per-side `borders` keyed `left` / `right` / `top` / `bottom` / `diagonal_down` / `diagonal_up` (full words, unlike `align`; each side is `{"style": "none"}` or `{"style": "solid", "color", "width"}`; a border without `style` is rejected); exact repetition may be factored into `defaults.cell` / `defaults.paragraph` / `defaults.run` (cell fields such as `align`, `valign`, `padding`, `font_size` go under `defaults.cell`, never directly under `defaults`) and named `cell_styles`. Merged cells use positive `row_span` / `col_span` on the anchor with every covered cell as `{"merge_continuation": true}`. The complete field grammar: [`native-data.md`](../scripts/docs/native-data.md).
Every payload carries that exact `schema`; `columns` holds the optional header row and `rows` the body rows; `column_widths` / `row_heights` are relative weights. A cell is a string or an object with `text` (or `paragraphs` for rich text), `fill`, `color`, `align` (`l` / `ctr` / `r`), `valign` (`top` / `middle` / `bottom`), `bold`, `font_size`, `padding`, and per-side `borders` keyed `left` / `right` / `top` / `bottom` / `diagonal_down` / `diagonal_up` (full words, unlike `align`; each side is `{"style": "none"}` or `{"style": "solid", "color", "width"}`; a border without `style` is rejected); exact repetition may be factored into `defaults.cell` / `defaults.paragraph` / `defaults.run` (cell fields such as `align`, `valign`, `padding`, `font_size`, `borders` go under `defaults.cell`, never directly under `defaults`) and named `cell_styles`. Merged cells use positive `row_span` / `col_span` on the anchor with every covered cell as `{"merge_continuation": true}`. The complete field grammar: [`native-data.md`](../scripts/docs/native-data.md).
**Hard rule — the table payload is complete**: every row, summary line, value, and cell style that must survive `--native-charts-and-tables` is in `columns` / `rows`, because fallback text is discarded on that route. A payload holding only `font_size` and a uniform border is not complete when the fallback draws a header band, row or column fills, first-column emphasis, non-uniform row heights, or sparse rules. Numeric or currency columns use cell objects with `align: "r"` (`text-anchor="end"` does not carry).
@@ -8,7 +8,7 @@ Authoring contract for PowerPoint-native click hyperlinks on complete objects an
| Layer | Ownership |
|---|---|
| Default Strategist | Record the linked text/object intent and exact target in the §IX page block; never invent or normalize an unknown destination |
| Default Strategist | Record each linked text/object and its exact target on the §IX page block's `Hyperlinks` line — same-deck jumps as `#slide-N`, external destinations as the full URI; never invent or normalize an unknown destination |
| Default Executor | Choose the whole-object or inline carrier and author the canonical SVG anchor |
| Active Quick context | Both responsibilities directly |
| SVG-to-PPTX exporter | Validate the target, create the native relationship, and attach the click action |
@@ -30,7 +30,7 @@ Authoring contract for PowerPoint-native click hyperlinks on complete objects an
**Hard rule — inline run**: visible text sits in one or more `<tspan>` children inside the anchor; the anchor and its descendants own no `x`, `y`, `dx`, or `dy` — line positioning belongs to the enclosing line `<tspan>` — in a multi-line paragraph the first line is no exception: its anchor also sits inside a line `<tspan>`, never in direct `<text>` content. A linked inline formula is one leaf formula `<tspan>` inside the anchor and keeps its native math contract.
**Hard rule — whole-object hit area**: wrap at least one visible SVG element; no direct text or bare `<tspan>` in a shape anchor. A multi-object anchor links each exported leaf object; include an explicit background shape when gaps inside a button or card must also be clickable. Ordinary animation may target an outer top-level `<g>`, but a hyperlink-bearing group cannot also be an interactive `trigger_shape` — one click has one owner.
**Hard rule — whole-object hit area**: wrap at least one visible SVG element; no direct text or bare `<tspan>` in a shape anchor. A multi-object anchor links each exported leaf object; include an explicit background shape when gaps inside a button or card must also be clickable. A bare anchor around one top-level `<g id>` is transparent: that group remains the page's top-level unit and animation anchor. Ordinary animation may target an outer top-level `<g>`, but a hyperlink-bearing group cannot also be an interactive `trigger_shape` — one click has one owner.
**Forbidden**: nested `<a>`; an anchor inside `defs`, metadata, geometry-detail, or a native-replacement subtree (a complete block formula or Chart/Table marker may be wrapped as one whole object, but its preview may not contain another anchor); authored `data-pptx-shape-hyperlink`, which PPTX import writes only when one source shape has both a whole-shape click and descendant run links, and which checker/export accept only on that logical group with at least one real inline `<a>` descendant.
@@ -48,4 +48,4 @@ Inline links become `a:rPr/a:hlinkClick`, whole-object links `p:cNvPr/a:hlinkCli
**Forbidden — unsupported action settings**: mouse-over links, custom shows, first/last/next/previous navigation, program or macro execution, OLE or file actions, and arbitrary `ppaction://` or relationship injection. An `actionButton*` preset stays visual geometry until wrapped in a supported anchor.
**Validation**: the final SVG checker validates carrier structure, target syntax, and slide range; export validates relationship type/mode and final roster membership. Unsupported PPTX click actions produce an import diagnostic; strict import fails rather than fabricating an SVG link.
**Validation**: the final SVG checker validates carrier structure, target syntax, and slide range, and compares each page's anchors with its §IX `Hyperlinks` line (an in-range jump to the wrong page passes the range check and is caught only here); export validates relationship type/mode and final roster membership. Unsupported PPTX click actions produce an import diagnostic; strict import fails rather than fabricating an SVG link.
@@ -57,13 +57,13 @@ With notes disabled the last column is unavailable: every required meaning stays
**Material divergence**: how closely the deck follows the source versus how freely it reshapes it — a spectrum from *stay close* (track structure and wording, tune for clarity) through *balanced* (re-architect into a narrative under the chosen mode, keeping all substance) to *free* (regroup, reframe, expand, connect, invent structure and transitions). Default reads it from the user's own words in the `content_divergence` field; Quick reads it from the request; blank is a balanced default. **Hard rule — facts stay sourced however free the user asks**: divergence develops what is in the source and never licenses outside facts, figures, or claims — that is [`topic-research`](../workflows/stages/topic-research.md)'s job; `mode` and divergence are orthogonal.
**Fact provenance contract**: when `sources/*.facts.json` exists, read it before outlining and cite its stable `fact_id` values as `Fact IDs: F001, ...` on every §IX page that uses an external quantitative or factual claim; invented demo KPIs, ratios, targets, and roadmap numbers carry `Data class: scenario` and never a `fact_id`. One page may hold both classes as long as each number's class is unambiguous.
**Fact provenance contract**: when `sources/*.facts.json` exists, read it before outlining and cite its stable `fact_id` values as `Fact IDs: F001, ...` on every §IX page that uses an external quantitative or factual claim; invented demo KPIs, ratios, targets, and roadmap numbers carry `Data class: scenario` and never a `fact_id`. One page may hold both classes as long as each number's class is unambiguous. A value legible only inside a source page's image is a source fact, and the page names the image as its origin.
**Per-block expression**: the semantic relationship chooses the form — prose for cause, argument, interpretation, and narrative continuity; bullets or numbers only for genuinely parallel, ordered, or enumerable items, never because copy is long or a template exposes a list slot. In `presentation`, distill one assertion and move explanation into enabled notes (or keep it on the page when notes are off). Source texture is a secondary cue. Default writes §IX at the confirmed `design_spec_depth`: at `complete` depth write usable phrasing into §IX; at `brief` depth one bullet per block in the phrasing that fits, leaving page copy to authoring — neither is a skeleton: every claim, fact, relationship, and qualifier is present, and written wording is preferred wording unless literal preservation applies (Executor adapts under [`executor-base.md`](./executor-base.md) §2.1). §IX is the page brief at the confirmed depth; Executor retains it with the lock until context invalidation. Quick writes the same brief in context at whichever depth authoring needs.
**Mandatory — information model, not source object type**: qualitative `order` / `link` / `parent` / `membership` / `contrast` / `overlap` is written on the page's §IX `Relationships` line (its units and their source-stated relationship, or `none`; no catalog key, grammar atom, coordinate, shape, or named model — Executor decides at runtime whether geometry carries it); values, dates, or durations that determine geometry are a Chart; row header × column header facts are a Table, each compared against the complete loaded vocabulary.
**Mathematical and hyperlink content**: Record every source-backed equation under `Mathematical content` in the applicable §IX block as a LaTeX body without `$…$`, `$$…$$`, `\(…\)`, or `\[…\]` delimiters — never classified as inline or block, never invented for decoration, and never a policy, manifest, PNG, §VIII row, or lock entry; Executor owns the text-versus-native decision and returns here only for a content-level correction, including when the documented Microsoft 365 input profile cannot preserve the planned content. Record every explicit or source-backed link as the linked text/object plus its exact absolute URI or 1-based same-deck slide target — never guessed, never carrier-selected, never a manifest or lock entry; Executor authors it under [`native-hyperlinks.md`](./native-hyperlinks.md).
**Mathematical and hyperlink content**: Record every source-backed equation under `Mathematical content` in the applicable §IX block as a LaTeX body without `$…$`, `$$…$$`, `\(…\)`, or `\[…\]` delimiters — never classified as inline or block, never invented for decoration, and never a policy, manifest, PNG, §VIII row, or lock entry; Executor owns the text-versus-native decision and returns here only for a content-level correction, including when the documented Microsoft 365 input profile cannot preserve the planned content. Record every explicit or source-backed link on the block's `Hyperlinks` line as the linked text/object plus its exact absolute URI or `#slide-N` same-deck target — never guessed, never carrier-selected, never a manifest or lock entry; Executor authors it under [`native-hyperlinks.md`](./native-hyperlinks.md).
---
@@ -74,7 +74,7 @@ With notes disabled the last column is unavailable: every required meaning stays
**Mandatory — whole-roster rhythm check**: while composing §IX, compare neighbors and section arcs — chapter entries visibly reset; same-density, same-resource, or same-relationship runs are intentional sub-arcs; a repeated motif carries a continuity job; any visible-state sequence keeps a recognizable map while its next change is legible; each section follows a mode-fitting progression (including framework → explanation/evidence → judgment/action when it serves); the final arc resolves the objective before a genuine ending lowers load. Same section, equal density, one style, and precedent establish no sub-arc. Repair roster, `Composition`, and `page_rhythm` in place; preserve intentional continuity, legitimately all-`dense` material, and 1:1 order; add no filler — a `breathing` page marks a real pause and must stand alone. No field, lock row, artifact, or second pass.
**Cover impact is mandatory**: give `P01` one concrete hook from the source's strongest claim, metaphor, number, moment, or conflict plus one optional composition Reference in ordinary words (a distilled display phrase may carry the cover while the complete title stays a native subtitle; with no suitable image, a native-SVG hook). The hook binds; the composition is a Reference. `P01` stays `anchor`, defaulting away from generic content-page templates unless content, user, or template makes a card grid, agenda, or equal-weight columns the clearest cover. Beautify preservation is exempt.
**Closing impact (only when the deck closes)**: for a genuine conclusion, CTA, or final takeaway, name the binding takeaway plus a recommended composition; never an information-empty "Thank you", contact-only slide, or cover reprise (an explicit contact/event CTA may serve), and never an invented closing page. Preservation is exempt.
**Closing impact (only when the deck closes)**: for a genuine conclusion, CTA, or final takeaway, name the binding takeaway plus a recommended composition; never an information-empty "Thank you", contact-only slide, or cover reprise (an explicit contact/event CTA may serve), and never an invented closing page. Preservation is exempt. A social carousel (each card shared alone) gives every card its own title and source and reads the page number as sequence, not footer.
**Reference — a starting sketch, never a constraint**: a §IX `Composition` line names the macro relationship the page's content suggests (one focal claim, equal comparison, dominant evidence + takeaway, parallel sequence, core + surrounding forces, wide visual + explanation) in ordinary words; Executor owns the structure and geometry that realize it (its layout-structure vocabulary lives in [`executor-base.md`](./executor-base.md) Page Expression Core) and adjusts or replaces the line freely after reading the page. Never write element-level sizes or coordinates into §IX.
@@ -165,7 +165,7 @@ python3 skills/ppt-master/scripts/visualization_recall.py validate \
**Reference — PPT-safe faces (recall, not a whitelist; one concrete named face per script — a stack carries at most one Latin face and one CJK face and never a fallback list of alternatives, because export takes the first named face of each script)**: CJK sans `Microsoft YaHei` / `SimHei`, CJK serif `SimSun` / `FangSong` / `KaiTi` (their macOS counterparts `PingFang SC` / `Heiti SC` / `Songti SC` are preview aliases, never the named face; a CJK deck that also sets Latin lines pairs both in one stack — `SimSun, Cambria` — so the theme's latin and ea slots differ), Traditional Chinese sans `Microsoft JhengHei`, serif `PMingLiU` / `DFKai-SB`, Japanese sans `Yu Gothic` / `Meiryo`, serif `Yu Mincho`, Korean sans `Malgun Gothic`, serif `Batang` (text on another locale's CJK face takes that locale's glyph forms), Arabic `Segoe UI` / `Tahoma` / `Times New Roman` (one face carries both the Latin and Arabic text), Hebrew `Segoe UI` / `Arial` / `David` (one face carries both the Latin and Hebrew text), Cyrillic and Greek ride the same Latin faces (`Arial` / `Times New Roman` / `Segoe UI` / `Calibri` / `Georgia`), Devanagari and other Indic scripts `Nirmala UI` / `Mangal`, Thai `Leelawadee UI` / `Tahoma`, Latin sans `Arial` / `Calibri` / `Segoe UI` / `Verdana` / `Trebuchet MS`, Latin serif `Times New Roman` / `Georgia` / `Cambria` / `Palatino` / `Garamond` (`Georgia` sets old-style figures whose digits sit at different heights — a hero-number or data role needs lining figures: `Times New Roman`, `Cambria`, or any listed sans or mono), mono `Consolas` / `Courier New`, math `Cambria Math` (formula preview runs only), display `Impact` / `Arial Black`. Let the locked style's character pick the axis and lead the title — `Microsoft YaHei` / `Arial` are the neutral members, never the automatic lead; a neutral sans title where the style asks for character is the failure to avoid. Non-pre-installed directions — retro/pixel Press Start 2P / VT323, rounded Nunito / Quicksand / OPPO Sans (safe substitute `Trebuchet MS` / `Verdana`), modern web Inter / HarmonyOS Sans / Source Han, calligraphic 隶书 / 华文行楷 / 华文新魏 (safe substitute `KaiTi` / `FangSong`, titles only), brand faces — need target installation or stay Design Spec references.
**Role extension after confirmation**: while composing the roster, add a lowercase snake_case role with an exact stack only for a recurring role that materially needs a different family (`annotation`, `footer`, `footnote`, `data`, `emphasis`, `quote`, `code`), coherent with the confirmed heading/body system and locked style; one-off garnish stays omitted and Default names any added role in one compact `Role rationale` line in §IV.
**Role extension after confirmation**: while composing the roster, add a lowercase snake_case role with an exact stack only for a recurring role that materially needs a different family (`annotation`, `footer`, `footnote`, `data`, `emphasis`, `quote`, `code`), coherent with the confirmed heading/body system and locked style; one-off garnish stays omitted and Default names any added role in one compact `Role rationale` line in §IV. A `display` role falls back to `body_family` unless `display_family` is declared — declare it when hero numerals should share the title family.
**Size anchors — px only**: every layer carries bare px; PowerPoint pt (`px × 0.75`) is an export result. Take the initial body anchor and sanity band from [`canvas-formats.md`](canvas-formats.md) § Typography Scale Start, never rederived here.
@@ -64,6 +64,10 @@ Every new SVG project declares one deterministic route:
**Slot bounds**: derive `data-pptx-bounds` from the intended design zone, column, panel inset, safe area, or picture frame — never from text length, glyph width, line count, or a tight content box. Repeat the same slot ids/types/effective indices/default bounds/binding modes on every slide using that Layout; Slide content and local carrier geometry may differ from the default frame. A template-owned chart/table carrier may declare `data-pptx-native-authority="json"`; its metadata, marker identity, bounds, and slot binding are structural facts while its preview children are derived.
**Slot capacity**: a slot's bounds are a capacity, not a whitespace quota. When a page's content leaves a slot mostly empty, set its type at the top of the role's band, bring in the source's supporting detail, or map the page to a prototype with smaller slots; where Layout atoms occupy part of a slot, judge by the area left to write in.
**Morph endpoints**: a slot becomes a Layout placeholder at export and cannot pair; template prototypes supply only fixed layers and slots, so a Morph pair is a Slide-local group authored beside them, decided before P01.
**Text carriers**: a multiline placeholder stays one native text frame, so leave strict-line text Slide-local when separate frames are required. Leave a carrier empty or whitespace-only when the placeholder must be visually blank — export materializes an invisible run; never insert a dummy dash, sub-1pt text, or an opacity-hidden glyph. A materialized mirror carrier may keep the source `data-pptx-frame`; do not add it to an authored `standard` / `fidelity` carrier merely to duplicate its bounds. `object` is the generic content slot; `media` binds an authored image/crop and does not synthesize video or audio from a decorative group.
## 3. Legacy Template Input Boundary
@@ -219,9 +219,9 @@ An authored native preset fragment (§1.5) is already an atomic `<g id>` and cou
<!-- Shadow only if the card floats over a colored panel; on flat white, omit it. -->
<rect x="60" y="115" width="565" height="260" rx="20" fill="#FFFFFF" filter="url(#shadow)"/>
<use data-icon="chunk-filled/bolt" x="108" y="163" width="44" height="44" fill="#0071E3"/>
<g id="card-benefits-metric">
<text x="105" y="270" font-size="56" font-weight="bold" fill="#0071E3">10×</text>
<text x="250" y="270" font-size="30" font-weight="bold" fill="#1D1D1F">Faster</text>
<g id="card-benefits-metric" font-weight="bold"> <!-- shared presentation lives on the group -->
<text x="105" y="270" font-size="56" fill="#0071E3">10×</text>
<text x="250" y="270" font-size="30" fill="#1D1D1F">Faster</text>
</g>
<text x="105" y="310" font-size="18" fill="#6E6E73">Reduce production time from days to hours.</text>
</g>
@@ -246,6 +246,7 @@ Serial post-processing and export belong to [`generate-pptx.md`](../workflows/ge
| Viewing-distance legibility | Resolve delivery context and viewing distance before fixing density and type scale. Preserve necessary text at a readable scale by applying only actions the active route's content and page invariants permit: restructure, shorten, split, or reflow. If none is permitted, surface the unresolved fit instead of silently miniaturizing it. Do not turn this into one universal font-size floor: captions and metadata may be smaller when their role and context remain legible. |
| Contrast and semantic encoding | Within the active profile's fidelity boundary, keep meaning-bearing text distinguishable from its actual background. For newly authored distinctions, combine luminance, weight, scale, shape, position, or explicit labeling; color may reinforce meaning but never carry a required distinction alone. When fidelity requires preserving source-only color encoding, reproduce it rather than inventing a cue. Reserve lower-contrast treatment for genuinely secondary metadata that remains legible. |
| Natural wrapping | Break at semantic phrase or punctuation boundaries where possible. An unspaced script (Thai, Lao, Khmer, Myanmar) breaks only at word boundaries the author chooses — never by inserting spaces or zero-width characters — and keeps native table cells single-line; it and any script with stacked marks (Vietnamese diacritics included) take leading toward the upper band. A right-to-left deck writes each line either in one direction or starting and ending with right-to-left letters — digits inside are safe, a leading digit, trailing neutral sign, or inline Latin run reorders between the left-to-right preview and PowerPoint. Reflow the text frame or adjust neighboring geometry before using any permitted local size reduction. Let the final line run naturally shorter; avoid mechanically equal lines or a stranded single-character / single-word line when an earlier natural break preserves meaning. |
| Number and punctuation conventions | Digit grouping, decimal marks, quotation marks, and the space before `%` or a unit follow the content language — a consumer audience's everyday usage over the standards-body spelling — and one choice per content language holds deck-wide. |
| Content field | Establish the usable body frame before placing modules. Divide it into one or a small set of macro-regions from information weight and reading order: use unequal weight when the information differs, while true peers may share equal weight. Give each region its own local axes / micro-grid while retaining only the cross-region anchors the composition needs. On a dense page, let the planned content system organize that frame; create breathing room through gutters, module spacing, and intentional voids between semantic clusters. Unorganized residual space that leaves content stranded in one part of the frame is leftover blank, not negative space. |
| Alignment and proximity | Establish shared axes from the current composition. Align related titles, copy, labels, images, and diagram nodes to those edges, centers, or baselines; group related elements more tightly than unrelated groups so spacing carries hierarchy. Break an axis only when the offset performs hierarchy, direction, or tension. |
| Visual weight | Judge weight from area, darkness, saturation, density, stroke, image detail, and elevation together. Distribute it to support the focal path; symmetry is optional, and deliberate imbalance may create direction. |
@@ -67,7 +67,7 @@ When the communication contract conflicts with the workspace, state the best-fit
| Style-only or Style + Brand | `pptx_structure.mode: flat`, `template_reuse_scope: style`; omit `template_adherence` and every structured mapping section |
| `mirror` / `layout` | `pptx_structure.mode: structured`, `template_adherence: strict\|adaptive` (mirror always `strict`); no legacy `baseline`, `template`, `preserve`, `layout_strategy`, or Layout-kind rows |
A Style installed alongside Layout/Deck changes only Direction / method and never forces flat/structured routing; a literal `mirror` plan is compatible only when the Style segment requires no visual or topology change.
A Style installed alongside Layout/Deck changes only Direction / method, never flat/structured routing; a literal `mirror` plan is compatible only when the Style segment requires no visual or topology change. Morph pairs Slide-local groups, never slots.
- **Master roster**: one `pptx_masters` row per Master as `<master_key>: <picker name>`, copied from the workspace roster. Keys are 1–64 ASCII letters/digits/dots/underscores/hyphens starting with a letter or digit; spaces belong only in the picker name. Master visuals are root-level atoms, never `<g>`.
- **Registered Layouts**: list used keys and only deliberately retained unused keys once as `<layout_key>: <master_key> | <PowerPoint layout name> | <prototype source>`. An unused Layout uses `template:<basename>`; a new adaptive Layout uses its first generated `P<NN>`. Reuse keys only for identical fixed atoms and slot ids/types/indices/bounds/bindings. Name authored keys after composition, never topic. Zero-slot Layouts are valid; never invent an empty `utility` kind or full-page fake slot.
@@ -213,7 +213,7 @@ Every template SVG satisfies the structured metadata contract of [`pptx-structur
| Fixed atoms | Authored Master/Layout atoms also carry `data-pptx-editable="false"`; preserved source atoms keep their ownership and comparable paint order (grouping and spelling may differ, regrouping may not) |
| Keys | `standard` / `fidelity` write authored keys; `mirror` preserves the source keys, picker names, placeholder types/indices/bounds, and carrier identity exactly (never replacing `subTitle`, `obj`, `media`, or `dt` with generic body) |
| Slot roles | Authored modes assign `title`, `subtitle`, `body`, `picture`, `chart`, `table`, `object`, `media`, `date`, `footer`, `slide-number` deliberately, with indices only to disambiguate repeated roles |
| Slot bounds | Authored slots derive `data-pptx-bounds` from the intended safe area, column, panel inset, or media frame — never from character count, glyph width, wrapping, or the sample-content box; mirror keeps the source Layout frame |
| Slot bounds | Authored slots derive `data-pptx-bounds` from the intended safe area, column, panel inset, or media frame — never from character count, glyph width, wrapping, or the sample-content box; leave recurring page types some Slide-local field, since a structured deck pairs Morph only on Slide-local groups, never on slots; mirror keeps the source Layout frame |
| Inherited visuals | Repeated in every standalone SVG so preview stays complete; export validates their equality and infers no ownership |
| Source refs | Final templates contain no `data-pptx-source-ref` |
@@ -66,9 +66,10 @@ Conditional design guidance for presentations whose intended use is recorded, se
| Quick generates a PPTX for recorded, self-running, or video delivery | **Hard rule**: enable Custom Animations before SVG authoring, use semantic groups and page-specific choreography, and complete the stage with a validated `animations.json` before base export; deck-wide `-a auto` and page transitions do not satisfy it; pages or groups may stay static — no coverage quota |
| Quick directly delivers a narrated video or MP4 | **Mandatory**: also enable Speaker Notes, Narration Audio, and video export; write the complete per-scene narration to `notes/total.md` before P01 as page-design input (agent-authored wording may be finalized after the roster, final/literal input stays verbatim); resolve narration-governed timing before audio, requiring timestamped page-local SRT for cue sync or subtitle delivery |
| Default generates a recorded or self-running deck and the workflow writes the narration itself | Enable Speaker Notes, Narration Audio, and Custom Animations at Stage 2; write the complete per-scene narration to `notes/total.md` after the approved roster/lock and before Step 5 as page-design input (agent-authored wording may be finalized during final-SVG validation, before audio) |
| Self-running signage, animated, no narration | Custom Animations on, notes and audio off, `--auto-advance` dwell above each page's animation total plus reading time |
| The user explicitly requests static or page-transition-only playback | Keep object animation off; retain the remaining notes/audio/video outcomes |
Default never forces object animation or audio merely because a deck may later be recorded; the Quick requirement selects the capability, not motion coverage.
Default never forces object animation or audio because a deck may later be recorded; the Quick requirement selects the capability, not motion coverage.
---
@@ -19,7 +19,7 @@ Read inputs 2–5 once at the start, then iterate the batch sequentially: apply
| H1 | Out-of-bounds | element bbox outside `canvas.view_box` | shrink or reposition into canvas |
| H2 | Text overflow | text bbox extends past its visual container | reduce font-size or line-break |
| H3 | Text overlap | two `<text>` bboxes intersect (tspans within one text excluded) | reposition or resize |
| H4 | Readability | contrast < 4.5 (small text) / < 3.0 (font-size ≥ 24px), or text directly atop a complex image with no scrim | when neither color is a brand token: position-only escape — add a `<rect>` scrim under the text, or raise the text to ≥ 24px so the 3.0 threshold applies; when either color is a brand token: do not edit → §1.1 |
| H4 | Readability | contrast < 4.5 (small text) / < 3.0 (font-size ≥ 24px), or text directly atop a complex image with no scrim | when neither color is a brand token: position-only escape — add a `<rect>` scrim under the text, raise the text to ≥ 24px so the 3.0 threshold applies, or deepen the stops of a `decoration` gradient already under the text (same geometry and technique, sibling pages of that Layout alike); when either color is a brand token: do not edit → §1.1 |
| ~~H5~~ | Font-ramp drift | covered by `svg_quality_checker.py` (§0) | n/a |
| H6 | Element collision | rect/circle/path bboxes overlap with z-order violating semantics | open spacing |
| H7 | Declared page chrome displaced | page number / header / footer explicitly declared by `design_spec §IX`, `spec_lock.md`, or the installed template with a concrete anchor is covered, missing, or outside `canvas.view_box` | restore only that declared chrome; never invent undeclared chrome |
@@ -34,7 +34,7 @@ If H4 fires and the foreground or background is a brand token from `spec_lock.md
## §2 Soft rules (act only when clearly bad)
When in doubt, leave it — under-fixing beats oscillation.
When in doubt, leave it — under-fixing beats oscillation. A soft hit whose same condition plainly recurs on pages outside the batch is a deck-wide choice: leave it, record it under `untouched_concerns` with reason `deck_wide`, and the aggregating agent applies or drops it once for every page.
| # | Category | Trigger | Fix direction |
|---|----------|---------|---------------|
@@ -90,7 +90,7 @@ One file per page at `<project>/.review/<page>.json`; every `needs_human_items`
"verified_in_iter": 2,
"backup_path": ".review/backup/02_three_steps.iter1.svg"
}],
"untouched_concerns": [{"rule": "S1", "evidence": "...", "reason": "soft-cap reached" | "ambiguous_design_intent"}],
"untouched_concerns": [{"rule": "S1", "evidence": "...", "reason": "soft-cap reached" | "ambiguous_design_intent" | "deck_wide"}],
"needs_human_items": [{"rule": "H9", "suggested_fix_summary": "Hero subtitle declared in spec §IX.4 missing; add a <text> at (80,496) per design language"}],
"design_intent_check": {"spec_says": "TL;DR — emphasize 意图 as the core abstraction", "render_delivers": true, "note": "..."}
}
@@ -58,6 +58,7 @@ python3 scripts/update_repo.py
| PPTX animations | `pptx_animations.py`, `animation_config.py` | [docs/pptx-animations.md](./docs/pptx-animations.md) |
| Animation resources | `sound_sync.py` | [sound vocabulary and sync](../templates/sounds/README.md); [docs/pptx-animations.md](./docs/pptx-animations.md) |
| Spec maintenance | `update_spec.py`, `visualization_recall.py`; legacy `chart_recall.py` | [docs/update_spec.md](./docs/update_spec.md); [docs/visualization-recall.md](./docs/visualization-recall.md) |
| Spec review | `spec_review/server.py`, `check_spec_annotations.py` | [docs/spec_review.md](./docs/spec_review.md) |
| Image tools | `image_gen.py`, `image_treat.py`, `analyze_images.py`, `gemini_watermark_remover.py` | [docs/image.md](./docs/image.md) |
| Maintenance smokes | Inline temporary-project commands | [advanced image and motion](./docs/advanced-image-motion-smoke.md); [mask and gradient](./docs/mask-gradient-smoke.md); [multilingual text](./docs/multilingual-text-smoke.md) |
| Repo maintenance | `update_repo.py` | README install/update section |
@@ -81,6 +81,14 @@ def main(argv: list[str] | None = None) -> int:
if not project_path.exists():
print(f'Error: Project path does not exist: {project_path}', file=sys.stderr)
return 1
if not project_path.is_dir():
# A sidecar path here would make validate look for <file>/animations.json,
# find nothing, and pass an unchecked file.
print(
f'Error: expected a project directory, got a file: {project_path}',
file=sys.stderr,
)
return 1
if args.command == 'scaffold':
try:
@@ -13,6 +13,7 @@ Usage:
python3 scripts/beautify_inventory.py <slide_library.json> [--images <image_manifest.json>] [-o inventory.json]
python3 scripts/beautify_inventory.py <inventory.json> --summary
python3 scripts/beautify_inventory.py <inventory.json> --page N [--with-geometry]
python3 scripts/beautify_inventory.py <inventory.json> --verify <exported.pptx>
Examples:
python3 scripts/beautify_inventory.py projects/x/analysis/<stem>.slide_library.json \
@@ -30,7 +31,9 @@ from __future__ import annotations
import argparse
import json
import re
import sys
from collections import Counter
from pathlib import Path
from typing import Any, Optional
@@ -220,6 +223,100 @@ def _positive_int(value: str) -> int:
return parsed
_WHITESPACE_RE = re.compile(r"\s+")
def _compact(text: str) -> str:
"""Whitespace-free form for containment checks: layout may rewrap, never reword."""
return _WHITESPACE_RE.sub("", text or "")
def _frozen_strings(slide: dict) -> list[str]:
"""Every source string a slide must still carry: paragraphs, table cells, chart labels, SmartArt nodes."""
strings: list[str] = []
for block in slide.get("text_blocks", []):
strings.extend(line for line in (block.get("text") or "").split("\n") if line.strip())
for table in slide.get("tables", []):
for row in table.get("cells", []):
strings.extend(cell for cell in row if cell and cell.strip())
for chart in slide.get("charts", []):
strings.extend(str(c) for c in chart.get("categories", []) if str(c).strip())
strings.extend(str(series.get("name")) for series in chart.get("series", []) if series.get("name"))
for diagram in slide.get("diagrams", []):
strings.extend(node.get("text") for node in diagram.get("nodes", []) if node.get("text"))
return strings
def _exported_text_by_slide(pptx_path: Path) -> dict[int, str]:
"""Compact per-slide text of an exported deck, read with the same analyzer as intake."""
from pptx_ooxml.analyzer import analyze_pptx
library = analyze_pptx(pptx_path)
by_slide: dict[int, str] = {}
for slide in library.get("slides", []):
parts: list[str] = [slot.get("text", "") for slot in slide.get("slots", [])]
for table in slide.get("tables", []):
for row in table.get("rows", []):
parts.extend(cell.get("text", "") for cell in row.get("cells", []))
for chart in slide.get("charts", []):
parts.extend(str(c) for c in chart.get("categories", []))
parts.extend(str(series.get("name", "")) for series in chart.get("series", []))
for diagram in slide.get("diagrams", []):
parts.extend(str(t) for t in diagram.get("text_items", []))
by_slide[int(slide.get("slide_index"))] = _compact("\n".join(parts))
return by_slide
def verify_against_export(inventory: dict, pptx_path: Path) -> tuple[list[dict], bool]:
"""Per-slide containment of every frozen source string in the exported deck."""
exported = _exported_text_by_slide(pptx_path)
rows: list[dict] = []
clean = True
for slide in inventory.get("slides", []):
idx = int(slide.get("slide_index"))
page_text = exported.get(idx)
frozen = _frozen_strings(slide)
if page_text is None:
rows.append({"slide_index": idx, "frozen": len(frozen), "missing": frozen, "exported_page": False})
clean = False
continue
missing = [text for text in frozen if _compact(text) not in page_text]
source_chars = Counter(_compact("".join(frozen)))
added = Counter(page_text) - source_chars
rows.append({
"slide_index": idx,
"frozen": len(frozen),
"missing": missing,
"added_chars": sum(added.values()),
"added_sample": "".join(sorted(added.elements()))[:40],
})
if missing:
clean = False
extra_pages = sorted(set(exported) - {int(s.get("slide_index")) for s in inventory.get("slides", [])})
if extra_pages:
clean = False
rows.append({"slide_index": None, "extra_exported_pages": extra_pages})
return rows, clean
def _print_verify(rows: list[dict], clean: bool, pptx_path: Path) -> None:
print(f"[VERIFY] frozen source strings vs {pptx_path.name}")
for row in rows:
if row.get("slide_index") is None:
print(f" extra exported page(s) with no source slide: {row['extra_exported_pages']}")
continue
if not row.get("exported_page", True):
print(f" slide {row['slide_index']:02d}: no exported page; {row['frozen']} frozen string(s) unplaced")
continue
status = "ok" if not row["missing"] else f"MISSING {len(row['missing'])}"
added = row["added_chars"]
note = f", +{added} char(s) not in source" if added else ""
print(f" slide {row['slide_index']:02d}: {row['frozen']} frozen, {status}{note}")
for text in row["missing"][:5]:
print(f" - {text[:80]}")
print(f"[VERIFY] {'passed: every frozen string is present on its page' if clean else 'FAILED: frozen strings are missing or pages do not match'}")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
@@ -253,6 +350,11 @@ def build_parser() -> argparse.ArgumentParser:
metavar="N",
help="Print one slide's frozen content/data by slide_index to stdout",
)
view_group.add_argument(
"--verify",
metavar="EXPORTED_PPTX",
help="Check that every frozen source string is present on its page of an exported deck; exit 1 when any is missing",
)
parser.add_argument(
"--with-geometry",
action="store_true",
@@ -265,7 +367,7 @@ def main(argv: Optional[list[str]] = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
view_requested = args.summary or args.page is not None
view_requested = args.summary or args.page is not None or args.verify is not None
if view_requested and args.output:
parser.error("--summary/--page write to stdout and cannot be combined with --output")
if args.with_geometry and args.page is None:
@@ -307,6 +409,15 @@ def main(argv: Optional[list[str]] = None) -> int:
_images_by_slide(manifest),
)
if args.verify is not None:
pptx_path = Path(args.verify)
if not pptx_path.is_file():
print(f"[ERROR] exported deck not found: {pptx_path}", file=sys.stderr)
return 1
rows, clean = verify_against_export(inventory, pptx_path)
_print_verify(rows, clean, pptx_path)
return 0 if clean else 1
if args.summary:
print(json.dumps(_summary_view(inventory), ensure_ascii=False, indent=2))
return 0
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""
PPT Master - Spec Annotation Checker
Print compact JSON to-dos and unread direct-edit summaries; acknowledge listed
edits or unchanged comments. See scripts/docs/spec_review.md.
Usage:
python3 scripts/check_spec_annotations.py <project_path> [--applied ID | --ack-edits]
Examples:
python3 scripts/check_spec_annotations.py projects/example
python3 scripts/check_spec_annotations.py projects/example --applied abc123
python3 scripts/check_spec_annotations.py projects/example --ack-edits
Dependencies:
None (only uses the standard library and local project modules)
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
_SCRIPTS_DIR = Path(__file__).resolve().parent
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from console_encoding import configure_utf8_stdio # noqa: E402
from spec_review.store import ReviewStore # noqa: E402
configure_utf8_stdio()
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("project_path")
actions = parser.add_mutually_exclusive_group()
actions.add_argument("--applied", metavar="ID")
actions.add_argument("--ack-edits", action="store_true", help="Consume direct edits from the last CLI listing")
args = parser.parse_args(argv)
store = ReviewStore(Path(args.project_path))
try:
store.document()
if args.applied:
store.remove_annotation(args.applied, applied=True)
result = {"applied": args.applied}
elif args.ack_edits:
result = {"edits_cursor": store.ack_edits()}
else:
result = store.list_todo()
print(json.dumps(result, ensure_ascii=False, separators=(",", ":")))
return 0
except (OSError, ValueError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -147,6 +147,13 @@ def _is_preset_atom(element: ET.Element) -> bool:
return isinstance(element.tag, str) and authored_preset_encoding(element) is not None
def _keeps_local_paint(element: ET.Element) -> bool:
"""Preset atoms and icon references resolve paint from their own attributes."""
return _is_preset_atom(element) or (
_local_name(element.tag) == "use" and element.get("data-icon") is not None
)
def is_canonical_presentation_value(
value: str,
*,
@@ -366,9 +373,9 @@ def _promote_common_group_defaults(
]
if len(children) < 2:
return
# Helper-owned preset atoms keep their paint local; the preset contract
# rejects paint that only arrives from an ancestor group.
if any(_is_preset_atom(child) for child in children):
# Helper-owned preset atoms and icon references keep their paint local;
# both contracts reject paint that only arrives from an ancestor group.
if any(_keeps_local_paint(child) for child in children):
return
element_styles = _style_declarations(element.get("style"))
@@ -419,7 +426,7 @@ def _remove_redundant_inherited_styles(
) -> None:
if (
_local_name(element.tag) in _DEFINITION_SUBTREES
or _is_preset_atom(element)
or _keeps_local_paint(element)
):
return
declarations = _style_declarations(element.get("style"))
@@ -195,7 +195,7 @@
mode_continuous_desc: "Generate the whole deck in one pass.",
mode_split_desc: "Stop after the spec; resume SVG generation in a fresh window.",
refine_off_desc: "Write the Design Spec and execution lock in sequence, then auto-proceed.",
refine_on_desc: "Stop after the Design Spec. Revise any part in chat; approval then creates the execution lock and continues generation.",
refine_on_desc: "Stop after the Design Spec. A review page opens: edit any page directly or leave comments; approval in chat then creates the execution lock and continues generation.",
off_default: "Off",
on: "On",
option_prefix: "Option",
@@ -384,7 +384,7 @@
mode_continuous_desc: "デッキ全体を一気に生成します。",
mode_split_desc: "設計仕様の作成後に停止し、別ウィンドウでSVG生成を再開します。",
refine_off_desc: "設計仕様と実行ロックを順番に作成し、そのまま自動で進みます。",
refine_on_desc: "設計仕様の作成後に停止します。チャットで任意の箇所を修正し、承認後に実行ロックを作成して生成を続けます。",
refine_on_desc: "設計仕様の作成後に停止します。レビューページが開き、各ページを直接編集するかコメントを残せます。チャットで承認すると実行ロックを作成して生成を続けます。",
off_default: "オフ",
on: "オン",
option_prefix: "案",
@@ -573,7 +573,7 @@
mode_continuous_desc: "一次性连续生成整份演示文稿。",
mode_split_desc: "写完设计规范后停止,另开窗口继续生成页面。",
refine_off_desc: "依次生成设计规范和执行锁,然后自动继续。",
refine_on_desc: "生成设计规范后暂停;你可在聊天中修改任何部分,明确确认后再生成执行锁并继续制作。",
refine_on_desc: "生成设计规范后暂停,并打开评审页面:可逐页直接修改或留下修改意见;在聊天中明确确认后再生成执行锁并继续制作。",
off_default: "关",
on: "开",
option_prefix: "方案",
@@ -762,7 +762,7 @@
mode_continuous_desc: "一次性連續生成整份簡報。",
mode_split_desc: "寫完設計規範後停止,另開視窗繼續生成頁面。",
refine_off_desc: "依次生成設計規範和執行鎖,然後自動繼續。",
refine_on_desc: "生成設計規範後暫停;你可在聊天中修改任何部分,明確確認後再生成執行鎖並繼續製作。",
refine_on_desc: "生成設計規範後暫停,並開啟評審頁面:可逐頁直接修改或留下修改意見;在聊天中明確確認後再生成執行鎖並繼續製作。",
off_default: "關",
on: "開",
option_prefix: "方案",
@@ -166,7 +166,11 @@ keys (a pure red/green/blue key also recovers soft alpha and removes spill from
key-dominant blends, leaving an opaque foreground of the key's hue untouched; thin
dark strokes of that hue can still fringe, so choose the key by hue absence;
strict alpha diagnoses off-key haze from the four 10% key-only margins, allowing
soft shadows and glows on a clean key), an
soft shadows and glows on a clean key; a key-hued ground that is not flat —
a gradient, lighting, or texture painted by the image model — is keyed by
key-channel dominance against a local ground estimate instead of one colour,
with a notice to inspect the cut, while a flat ground that merely differs from
the stated key is still rejected with the measured colour to rerun with), an
already prepared RGBA asset or the active host image editor for a standalone cutout, and
[`image-generator.md`](../../references/image-generator.md) §4.4 only for
registered subject/base layers.
@@ -21,7 +21,7 @@ one child `<metadata type="application/json">` (attribute `data-pptx-json` is re
Every payload carries that exact `schema`. Native tables are rectangular grids: `columns` for the optional header row, `rows` for body rows (shorter rows padded unless `strict_grid: true`; at most 1000 resolved rows and columns); `column_widths` / `row_heights` are finite non-negative relative weights matching the grid with one positive value (`row_heights` counts the header row when `columns` is present) (a row's top and bottom padding shrink to what its resolved height can hold beside the text line, since PowerPoint would otherwise grow the row past the drawn one); `header_rows` is an integer in range; `strict_grid`, `style.band_row`, and `bold` are JSON booleans. Exact repetition may be factored into `defaults.cell` / `defaults.paragraph` / `defaults.run` and kebab-case `cell_styles` selected by `cell_style` (precedence: cell defaults → named style → cell fields; object-valued `padding` merges, everything else replaces; `<style>` and `class` remain forbidden).
Cells accept `text`, `fill`, `fill_opacity`, `color`, `align`; no cell-level `font_family` — the face is table-wide through `style.font_family`, so a column set in another face in the fallback falls back to the table face when replaced. (SVG-first parity compares a cell against the fallback `<text>` with its `<tspan>` lines concatenated, so a wrapped cell keeps a trailing space inside every non-final `<tspan>` or the joined words never match.) Cells also take (`l` / `ctr` / `r`; header cells export centred unless `align` is set, so SVG-first parity reports a left- or right-anchored fallback header whose payload leaves `align` unset), `valign` (`top` / `middle` / `bottom`), `bold`, `font_size`, `padding` and side-specific `padding_*`, `border_color`, `border_width`, `borders`, `lang`, `anchor_center`, `horizontal_overflow`. Multi-paragraph text replaces `text` with a non-empty `paragraphs` list of strings or objects — empty paragraph strings are preserved as blank lines — (`align` plus exactly one of `text` or non-empty `runs`; runs carry required `text` and optional `bold`, `italic`, `underline`, `strike`, `color`, `font_size`, one-typeface `font_family`, `lang`, `alt_lang`); unknown fields, wrong types, empty runs, multi-typeface families, and unsupported colors fail. Per-side borders use `borders.left|right|top|bottom|diagonal_down|diagonal_up` as `{ "style": "none" }` or `{ "style": "solid", "color": "#RRGGBB", "width": <positive-px> }`, overriding a uniform `border_color` / `border_width` on style or cell. A missing `lang` follows the deck's `primary_language` when the cell's script matches it, otherwise the script default (`zh-CN`, `ja-JP`, `ko-KR`, `en-US`). `style.band_row: false` disables banding and materialized alternating fills. Typography mirrors the fallback: `style.font_family` and `style.font_size` from the drawn table text, `style.header_font_size` or per-cell `font_size` only where the fallback differs; with no explicit table font, Default uses the deck body family and anchor, Quick its active-context values.
Cells accept `text`, `fill`, `fill_opacity`, `color`, `align` (header cells export centred unless it is set); no cell-level `font_family` — the face is table-wide through `style.font_family`, so a column set in another face in the fallback falls back to the table face when replaced. (SVG-first parity compares a cell against the fallback `<text>` with its `<tspan>` lines concatenated, so a wrapped cell keeps a trailing space inside every non-final `<tspan>` or the joined words never match.) Cells also take (`l` / `ctr` / `r`; header cells export centred unless `align` is set, so SVG-first parity reports a left- or right-anchored fallback header whose payload leaves `align` unset), `valign` (`top` / `middle` / `bottom`), `bold`, `font_size`, `padding` and side-specific `padding_*`, `border_color`, `border_width`, `borders`, `lang`, `anchor_center`, `horizontal_overflow`. Multi-paragraph text replaces `text` with a non-empty `paragraphs` list of strings or objects — empty paragraph strings are preserved as blank lines — (`align` plus exactly one of `text` or non-empty `runs`; runs carry required `text` and optional `bold`, `italic`, `underline`, `strike`, `color`, `font_size`, one-typeface `font_family`, `lang`, `alt_lang`); unknown fields, wrong types, empty runs, multi-typeface families, and unsupported colors fail. Per-side borders use `borders.left|right|top|bottom|diagonal_down|diagonal_up` as `{ "style": "none" }` or `{ "style": "solid", "color": "#RRGGBB", "width": <positive-px> }`, overriding a uniform `border_color` / `border_width` on style or cell. A missing `lang` follows the deck's `primary_language` when the cell's script matches it, otherwise the script default (`zh-CN`, `ja-JP`, `ko-KR`, `en-US`). `style.band_row: false` disables banding and materialized alternating fills. Typography mirrors the fallback: `style.font_family` and `style.font_size` from the drawn table text, `style.header_font_size` or per-cell `font_size` only where the fallback differs; with no explicit table font, Default uses the deck body family and anchor, Quick its active-context values.
`style` accepts `font_family`, `font_size`, `header_font_size`, `band_row`, `padding`, `valign`, `border_color`, `border_width`, `lang`, `table_style_id`, and the table-wide colours `header_fill`, `header_text`, `body_fill`, `body_text`, `band_fill`. SVG-first parity reads a header band drawn as one rect spanning the header rows as `style.header_fill` unless every header cell carries that same `fill` (and the dominant header text colour as `style.header_text` unless every header cell carries it), so a drawn band is projected at table level rather than repeated per cell. Fills are only what the payload resolves: a cell with no `fill` takes `style.header_fill` (header rows) or `style.band_fill` / `style.body_fill` (body rows), and when none of those is set it exports as `noFill` so the slide background shows through — the same as a fallback that draws no cell rect. Text colour never falls to nothing: a cell without `color` takes `defaults.run.color` (plain text is one run, and a `paragraphs` cell whose paragraphs are strings or `text` objects inherits the same way at cell level, so `defaults.run` `bold` / `color` / `font_size` reach both when the cell and `defaults.cell` leave them unset; `paragraphs[].runs` take the run defaults directly), then `style.body_text` / `style.header_text`, then `#1F2937` (`header_text` defaults to `#FFFFFF` only when `header_fill` is set). SVG-first parity reports a body text colour drawn in the fallback that none of those layers would carry, and a first-column colour that neither the cell nor `style.body_text` resolves to.
@@ -34,7 +34,7 @@ Chart payloads carry no `schema` key: the marker's `data-pptx-replace-with` valu
- **Category charts** — `column`, `bar`, `line`, `area`, `pie`, `doughnut`, `pieOfPie`, `barOfPie`, `radar`: `categories` plus `series[].values`. Column, bar, line, and area values may be `null` for a gap (the workbook cell stays blank, the cache omits the point, `dispBlanksAs` is `gap`; at least one value per series is a number) — a fallback that breaks one line into segments is two series of the same colour with `null` outside their segment. Pie-family charts take exactly one series with per-slice colors; `hole_size` is doughnut-only, integer `10..90`, default `75`; no rotation field. Column/bar may set `series[].point_colors` (camelCase `pointColors` is read-compatible; length = values; every entry a colour — a `null` gap is line-only). Bar categories run top-down in payload order — the exporter reverses the category axis and moves the value-axis crossing so the value axis keeps its declared position; `axes.category.reverse: false` restores PowerPoint's bottom-up order (column charts stay left-to-right unless `reverse: true`). Bar/column spacing: root `gap_width` (`0..500`, the empty slot share as a percentage of one bar; PowerPoint default `150`; one category slot is the plot height (or width) divided by the category count and `bar = slot / (1 + gap_width / 100)`, so a chosen bar size converts back to `gap_width = (slot / bar - 1) × 100`) and clustered `overlap` (`-100..100`, the share of one bar the next series covers, negative for a gap); SVG-first export reads both from the fallback bars inside `plot_area` when the payload omits them, JSON-first uses the defaults.
- **Line markers** — `line_style: "lineMarker"` draws a circle at every point in the series colour; `marker_size` (root or per series, SVG px diameter, `2..72` pt after `×0.75`) sizes it. `line_style` itself is a chart-root option — `series[].line_style` is rejected; inside a `combo` it sits on the plot or on a typed series. `series[].point_colors` on a line series marks single points: a colour draws that point's marker in that colour, `null` leaves it bare (with `line_style: "line"` only the coloured points carry markers), so an emphasised endpoint is `[null, …, "#C8102E"]`. Area series draw no markers; a line with markers over a fill is a `combo`. `data_labels` (column, bar, line, and area plots only, combo plots of those types included; pie-family, radar, scatter/bubble, stock, and ChartEx charts reject it — show their values through companion `notes`) is `true` or an object with `show_value`, `position`, `number_format`, `font_size`, `font_family`, `bold`, `color`, per-point `colors`, and `points` (zero-based `idx` plus optional overrides — with an explicit `show_*` flag every point keeps its label and `delete: true` hides one; without one only the listed points are labeled); positions: clustered column/bar `outside_end` / `inside_end` / `inside_base` / `center`, stacked `inside_end` / `inside_base` / `center`, line `above` / `center` / `best_fit`, area none; `series[].data_labels` takes the same object for one series, so two lines can label different points. Set `data_labels.number_format` to match the fallback (the parity gate reads `0`, `0.0`, `0.00`, `#,##0`, `#,##0.0`, their `%` forms — which scale the value by 100 — and a quoted literal prefix/suffix such as `"$"#,##0` or `0.0"%"`; other codes match only the literal value)'s rendered value labels. Rendered forms follow the en-US pattern (`.` decimal, `,` thousands); a deck written with locale separators (`1.234,5`) keeps such numbers out of the chart marker — integer ticks, no `data_labels`, the formatted figures in surrounding text or table cells.
- **Combo** — shared `categories` plus `plots[]` (`type: "column" | "line" | "area"`, own `series`, optional `axis: "secondary"`, optionally own `categories` / `category_numeric` when caches genuinely differ, and `series_indices` for imported identity — same-length unique non-negative integers forming one contiguous `0..N-1` range across plots) or typed `series[]` with per-series `type` / `axis` (adjacent compatible series share a plot). Area series may set `fill_opacity` (`0..1`; `fillOpacity` read-compatible); a line plot with `area_fill: true` exports as an area chart; line/area series may set `line_width` in SVG px (`lineWidth` read-compatible); column plots take `gap_width` / `overlap`, line plots `line_style` / `marker_size`. An area series is a fill without an outline; `line_width` adds a same-colour outline around the whole region (baseline included), so a fallback that strokes only the top edge — or draws markers on it — is an area plot under a line plot with the same values. Export layers areas below columns and lines.
- **Axes** — classic `axes` is a closed object of `category`, `value`, `secondary_category`, `secondary_value`, each with only `kind` (`text` / `date` / `value`), `position`, `visible`, `label_position` (`next_to` / `none` / `low` / `high`), `number_format`, `minimum`, `maximum`, `major_unit` (value axes), `reverse`, `major_gridlines`, `tick_marks` (`none` default / `out` / `in` / `cross`), tick-label `color` / `font_family` / `font_size` (SVG px) when one axis's labels differ from the chart-wide text style, and on value axes `cross_between` (`between` — points sit between ticks, the default written explicitly because renderers disagree when it is absent — or `mid_category` — the first point sits on the axis edge; SVG-first line/area export reads it from where the fallback line starts inside `plot_area`); single-plot `bar` takes `category` left/right and `value` bottom/top; pie-family rejects `axes`. For single-plot category charts, `kind: "date"` is supported only by `area`. Stock dates follow the separate Stock contract below. `position` is honoured through the axis crossing (`top` / `right` cross at the far category, and a reversed crossing axis swaps the ends). `scatter` / `bubble` use `x` and `y` roles only (`kind: "value"`; `x.position` bottom/top, `y.position` left/right) with the same fields. Logarithmic scales, minor units/gridlines, crossing values, display units, and tick skipping are unsupported — a dense text category axis renders every label natively, so thin the categories (or use a date axis) when the fallback shows only some years.
- **Axes** — classic `axes` is a closed object of `category`, `value`, `secondary_category`, `secondary_value`, each with only `kind` (`text` / `date` / `value`), `position`, `visible`, `label_position` (`next_to` / `none` / `low` / `high`), `number_format`, `minimum`, `maximum`, `major_unit` (value axes), `reverse`, `major_gridlines`, `tick_marks` (`none` default / `out` / `in` / `cross`), tick-label `color` / `font_family` / `font_size` (SVG px) when one axis's labels differ from the chart-wide text style, and on value axes `cross_between` (`between` — points sit between ticks, the default written explicitly because renderers disagree when it is absent — or `mid_category` — the first point sits on the axis edge; SVG-first line/area export reads it from where the fallback line starts inside `plot_area`); single-plot `bar` takes `category` left/right and `value` bottom/top; pie-family rejects `axes`. For single-plot category charts, `kind: "date"` is supported only by `area`. Stock dates follow the separate Stock contract below. `position` is honoured through the axis crossing (`top` / `right` cross at the far category, and a reversed crossing axis swaps the ends). `scatter` / `bubble` use `x` and `y` roles only (`kind: "value"`; `x.position` bottom/top, `y.position` left/right) with the same fields. Logarithmic scales, minor units/gridlines, crossing values, display units, and tick skipping are unsupported — a dense text category axis renders every label natively, so when the fallback shows only some years, thin the categories, use a date axis, or set the category axis `label_position: "none"` and keep the sparse year labels as companion `notes[]` texts.
- **XY** — `scatter` and `bubble` use `series[].x` + `series[].y` (`bubble` adds one `series[].size` / `sizes` per point), or `series[].points` as `[x, y]` / `[x, y, size]` tuples or `{x, y, size}` objects.
- **ChartEx** — `treemap` / `sunburst` (`values` plus `levels[level][point]` or path-style `categories`; treemap `parent_label_layout: "banner" | "overlapping" | "none"` (default `overlapping`), PowerPoint labels only the top level and leaves), `histogram` (`values`), `pareto` / `waterfall` / `funnel` (`categories` + `values`; `waterfall` accepts `subtotals` / `subtotal_indices`), `boxWhisker` (`series[].values`, optional `series[].categories`). ChartEx writes no `<cx:title>` without a payload title (an empty title shows the series name), emits title/subtitle as companion text boxes, and takes `style.colors` / root `colors` into its color-style part. ChartEx accepts no `plot_area`, `axes`, or `data_labels` (each is refused); values reach the slide only through companion text: register every drawn value label as its own `notes[]` entry with unique text (each is placed from its fallback `<text>` and survives the native export), and draw no tick labels, since an axis has no channel — or choose a classic type when the labels must be bound to the marks. Non-Microsoft renderers show a limited subset.
- **Stock** — numeric Excel date serials in `categories` / `dates` plus exactly four series open / high / low / close (`series` with four entries or top-level `open` / `high` / `low` / `close`).
@@ -291,6 +291,12 @@ effect prefix without updating these attributes corrupts compatibility.
ppt/presProps.xml p:presentationPr/p:showPr useTimings=1. Do not write showPr
into ppt/presentation.xml.
**Kiosk show**: `svg_to_pptx.py --kiosk` writes `p:showPr loop="1"` with a
`p:kiosk` child (PowerPoint's "Browsed at a kiosk"): the show loops until
Escape and ignores click and keyboard advance, so only `advTm` timings and
hyperlinks move between slides. It leaves `useTimings` to the timing rule, so
a hyperlink-driven touch guide can be a kiosk show without timed advance.
---
## 6. Validation and Read-Back
@@ -0,0 +1,219 @@
# `spec_review/server.py`
Local browser review of `design_spec.md`, with block comments and staged Markdown
editing. The tool writes the same spec file, keeps comments in sidecars, and does
not read or write `spec_lock.md`, invoke a model, or start generation.
## Commands
```bash
python3 skills/ppt-master/scripts/spec_review/server.py <project_path> --daemon
python3 skills/ppt-master/scripts/spec_review/server.py <project_path> --daemon --no-browser
python3 skills/ppt-master/scripts/spec_review/server.py <project_path> --port 6070 --timeout 7200
python3 skills/ppt-master/scripts/spec_review/server.py <project_path> --hold on
python3 skills/ppt-master/scripts/spec_review/server.py <project_path> --hold off
python3 skills/ppt-master/scripts/check_spec_annotations.py <project_path>
python3 skills/ppt-master/scripts/check_spec_annotations.py <project_path> --ack-edits
python3 skills/ppt-master/scripts/check_spec_annotations.py <project_path> --applied <id>
python3 skills/ppt-master/scripts/spec_review/server.py <project_path> --shutdown
```
The daemon launcher waits for `GET /api/health` and prints a compact JSON object
with the actual URL, pid, and port. Diagnostics go to stderr; detached server
output goes to `<project_path>/spec_review/server.log`.
As with other project CLIs, an existing `validation/workflow.log` receives the
launch/check command envelope through the shared console helper; detached server
requests are not copied into that workflow log.
## Lifecycle
- **Port**: bind loopback only. Without `--port`, scan 50 ports starting at `6060`;
an explicit `--port N` binds strictly. Use the returned URL, not an assumed port.
- **Single instance**: `<project_path>/spec_review/lock.json` records pid and port.
A second launch reuses the live instance; a different explicit port fails.
Dead-pid locks are replaced. `service.guard` serializes concurrent launches.
- **Timeout**: `7200s` of inactivity by default; `--timeout 0` disables it.
`/api/state` polling and `/api/health` requests do not refresh activity; other
requests that pass the Host/Origin guard do. An idle open tab does not prevent
timeout. Closing a tab leaves the service running. **Exit review**, `--shutdown`,
timeout, or process termination stops it.
- **Draft lifetime**: per-block drafts live only in server memory. A tab reload
can retrieve them; stopping or restarting the server loses unapplied drafts.
The browser requests leave-page confirmation while it has pending work.
- **Exit review**: the button checks for unapplied drafts and unsaved comments
before calling `POST /api/shutdown`. Pending work requires confirmation.
Successful shutdown stops polling and replaces the review workspace with a
static stopped message in the selected UI language; saved comments remain on disk.
- **Write handoff**: `--hold on` waits for an active Apply to finish and makes
subsequent Apply requests return `423`. The page displays an AI-editing banner.
`--hold off` causes automatic block/hash reload, including when both transitions
occur between polls. Existing drafts survive and stale bases remain conflicts.
- **External changes**: the browser polls the raw-byte SHA-256 every two seconds.
A changed hash prompts reload; reload preserves drafts. After inspecting the
current source, **Use current disk as draft base** explicitly rebases a draft.
A draft whose block was removed externally remains available for copying or
discarding; it cannot be applied to a different block.
External writers must honor hold for coordinated writes: the last hash check
and filesystem replacement cannot form a compare-and-swap with arbitrary writers.
- **Request boundary**: Host must name loopback; a supplied Origin must match the
request origin. Responses disable caching and carry a restrictive CSP. There
are no remote scripts, fonts, styles, or other resources.
## Review surface
The three-column dark UI has ordered block navigation, rendered/source views,
a raw Markdown editor, and block/global comments. First/prev/next/last and
`Left`/`Right`/`Home`/`End` navigation are suppressed while typing. Four languages
(Chinese, Traditional Chinese, English, Japanese) follow `navigator.language`
and a persistent `localStorage` selection.
Blocks use each `##` section, except IX: its lead-in, each Slide heading matching
the shared `SLIDE_HEADING_RE` (levels 3–6), and every other `###` heading are
separate blocks. Non-Slide `###` headings have kind `part` and occurrence keys
`part:01`, `part:02`, etc. Each range ends immediately before the next block.
Navigation renders Parts as clickable group headings with indented Slides below.
Backtick and tilde fences hide internal headings. The preamble and all ordered ranges
reconstruct the exact source bytes. The shared `project_management/spec_blocks.py`
API exposes character offsets and corresponding UTF-8 byte offsets; malformed
duplicate keys receive occurrence suffixes.
**Apply changes** saves the selected block. The service serializes Apply and
hold, checks the supplied SHA-256 against disk (`409` on mismatch), validates the
candidate text with the existing design-spec schema, replaces only the target
byte interval using a sibling temporary file and `os.replace`, and appends edit
history. Schema errors are returned verbatim and displayed without blocking the
save. Direct editing locks the section heading, Slide level/number, and structural
boundaries; a Slide page name may change. A Part permits only title text changes:
its heading stays level 3 and its remaining content stays unchanged. Slides do
not contain the next Part heading, so appending page content does not edit a Part.
Additional unfenced `##` headings or headings matching `SLIDE_HEADING_RE` are
rejected; additional `###` headings in IX are also rejected. Page insertion,
removal, and ordering are comment requests. Changed blocks retain their newline style and terminal
whitespace; a no-op preserves even mixed line endings. Drafts on other unchanged
blocks advance to the new hash after a successful browser Apply.
The renderer uses DOM creation and text nodes for headings, paragraphs, lists
(including nesting), emphasis, inline code, tables, fenced code, quotations, and
rules. LaTeX stays monospace text; unsupported syntax falls back to literal text.
Links and image syntax do not create clickable links or loaded images.
Inline fragments over 4096 characters remain plain text to bound delimiter
backtracking. A rendering exception replaces that block's preview with its full
source in a monospace display; editing and staging remain available.
Comments are saved immediately to `spec_review/annotations.json`, separately
from Apply. Each item contains `id`, `key`, `title`, `spec_sha256` (creation-time
whole-spec hash), `body`, `created_at`, `updated_at`, and a unique `revision`.
Block comments also store `block_sha256`, the creation-time SHA-256 of that raw
block's UTF-8 bytes. Updating a comment preserves its original hashes. API and
CLI listings report `block_changed` as `true` or `false` against the current block;
legacy items without `block_sha256` report `null` (unknown). An absent block has
`block_missing: true` and `block_changed: null`; present blocks have
`block_missing: false`. The UI uses these block statuses. `global` is the global
comment slot and retains the whole-spec `base_current` comparison instead.
`annotations.jsonl` appends `saved`, `updated`, `removed`,
and `annotation_applied` events. `storage.guard` serializes browser/CLI
sidecar transactions and edit-log access across processes. `edits.jsonl` appends `ts`, `key`, `title`,
`before`, `after`, `before_sha256`, and `after_sha256` for each changed block.
`check_spec_annotations.py` prints compact JSON with pending comments, their
current baseline statuses, and unread direct-edit summaries (`ts`, `key`, `title`,
plus the first 12 characters of `before_sha256` and `after_sha256`, no block bodies).
`annotations.json` stores an acknowledged byte offset in `edits_cursor` (default
`0`) and the last CLI listing's end offset in `listed_edits_cursor`. Listing does
not consume edits. `--ack-edits` advances the acknowledged cursor only to that
last listed offset; edits appended after the listing remain unread. It requires a
prior CLI listing and never rewrites `edits.jsonl`. Later listings return only
edits after the acknowledged cursor. `--ack-edits` and `--applied` are mutually exclusive.
Listing also records full-item fingerprints (including revisions) in the same
sidecar's `listed` map. `--applied ID` clears only that listed version; an unlisted or subsequently
changed comment stays pending and the command exits nonzero with stderr detail.
Browser reads do not advance this agent receipt. Updating even to identical text
creates a new revision. A fresh CLI listing replaces the previous listing receipt.
### HTTP API
JSON bodies and responses use UTF-8. Block keys are URL-encoded path components.
| Method and path | Result or request body |
| --- | --- |
| `GET /api/health` | Service identity, project path, pid |
| `GET /api/state` | `sha256`, `hold`, `hold_revision` |
| `GET /api/blocks` | Ordered metadata, annotation/draft flags, global-comment flag, draft keys, orphan drafts, state |
| `GET /api/blocks/<key>` | Original `text`, title, ranges, current hash, optional separate draft |
| `GET /api/spec` | Full raw `text` and `sha256` |
| `PUT /api/drafts/<key>` | `text`, `sha256`, `version` (`null` for a new draft); returns a new draft version |
| `DELETE /api/drafts/<key>` | `version` of the draft to discard |
| `POST /api/apply/<key>` | `sha256`, `version`; returns `saved`, new hash, `validation_errors` |
| `GET /api/annotations` | Pending items with block baseline status, or `base_current` for global comments |
| `POST /api/annotations` | `key`, `body`; returns the saved item |
| `PUT /api/annotations/<id>` | `body`, `revision` |
| `DELETE /api/annotations/<id>` | `revision` |
| `POST /api/hold` | `hold` boolean |
| `POST /api/shutdown` | Stops the service |
Stale draft or comment versions return `409`; malformed bodies and structural
edits return `400`. Request bodies are limited to 4 MiB. No approval or generation
endpoint exists.
## Frontend regression checks
There is no repository JavaScript test runner. Use a disposable project copy
with at least two editable blocks for these manual checks. Start the review
server with the commands above, open its URL, and use the browser's Network
panel to inspect request keys and bodies. To keep write responses in flight
while typing or navigating, run this temporary console snippet; reloading the
tab restores normal fetching:
```javascript
const originalFetch = window.fetch.bind(window);
window.fetch = async (...args) => {
const response = await originalFetch(...args);
if ((args[1]?.method || 'GET') !== 'GET') {
await new Promise(resolve => setTimeout(resolve, 1500));
}
return response;
};
```
1. **Apply target and snapshot**: stage a draft in block B, return to A, and
edit A. Within the 350 ms debounce window, click B and immediately click
Apply while A is still displayed. Repeat after A's draft PUT has started,
adding another character before clicking B and Apply. Expect the queued
navigation to flush A, then Apply to target A with the text present at the
Apply click and the version returned by staging. Only A changes on disk;
B's draft remains. Also wait until B is displayed before clicking Apply in
a separate run: only B should change. The editor and Apply stay disabled
until the pending Apply finishes, including across polling responses.
2. **Typing during responses**: stage an A draft, change the disposable spec
externally, reload its disk content, and choose the rebase action. Type
more text while the draft PUT response is delayed. Expect all new text to
remain in the editor and to stage using the returned version and new base.
Separately save a new annotation and update an existing annotation, typing
more in the same field before each response arrives. Expect the sent text
to be saved, the newer input to remain, and a subsequent update to succeed
with the latest revision. With no additional typing, saving a new annotation
should still clear its input as usual.
3. **Pathological Markdown**: append a line produced by
`'x' + String.fromCharCode(96).repeat(200000) + 'y'` to an editable block.
Expect literal text to appear promptly and the draft PUT to proceed. Also
try an unclosed code fence, hundreds of increasingly indented list items,
and a table with a two-cell header and one-/three-cell body rows. Expect
each preview to finish without throwing or looping. To exercise exception
recovery, temporarily replace `markdown` in the console with a function
that throws, then type: the preview must show the entire block as monospace
source and staging must continue. Reload the tab to restore the renderer.
4. **External edit before metadata refresh**: on a fresh tab without drafts,
run `clearInterval(pollTimer)` in the console to hold off the two-second
poll. Change the disposable spec on disk, then save an annotation. Expect
the reload warning immediately after metadata refresh, while the preview
and displayed fingerprint still describe the old block. Click the in-page
reload button: the preview/fingerprint should advance and the warning should
clear. Reload the tab afterward to restore polling.
## Remote access
Start with `--no-browser` on the remote host. Forward the actual port `<P>` from
launch output or `spec_review/lock.json`: use the IDE's **PORTS** panel, a Termius
local rule bound to `127.0.0.1:<P>`, or
`ssh -L <P>:127.0.0.1:<P> <user>@<host>`. Open `http://localhost:<P>` locally.
@@ -801,7 +801,7 @@ Convert project SVGs into PPTX. EMF/WMF images referenced from `svg_output/` are
Each exported object is named after `data-pptx-shape-name`, else its SVG `id` (or `data-name`), else a positional `Group N` / `TextBox N`; forced-Morph `!!` names still win. The PowerPoint Selection and Animation panes therefore read like the source SVG.
The deck language — the lock's `primary_language`, else the first page's root `<svg lang="...">` (Quick's channel), else `--primary-language TAG` — tags base-template default text (new text boxes, master and layout placeholders) and docProps; a right-to-left language also makes those defaults right-to-left and right-aligned, and the theme's script font for that language (`Arab`, `Hebr`, `Thai`, `Deva`, ...) points at the locked face, which a lockless roster takes from its pages. A run of Latin letters inside a non-Latin deck is tagged `en-US`.
The deck language — the lock's `primary_language`, else the first page's root `<svg lang="...">` (Quick's channel), else `--primary-language TAG` — tags base-template default text (new text boxes, master and layout placeholders) and docProps; a right-to-left language also makes those defaults right-to-left and right-aligned, and the theme's script font for that language (`Arab`, `Hebr`, `Thai`, `Deva`, ...) points at the locked face, which a lockless roster takes from its pages. A run of Latin letters inside a non-Latin deck is tagged `en-US`; the tag is per run, so an English phrase sharing one run with CJK text (`中文 · English` in a single `<text>` with one style) takes the deck language — give it its own `<tspan>` with a distinct attribute when spell-check and hyphenation should treat it as English.
Native formulas use the two markers owned by
[`native-formula.md`](../../references/native-formula.md). A standalone block
@@ -1069,10 +1069,13 @@ Requirements:
`text_measure.py` imports the same single-line DrawingML width estimator used by
the SVG quality checker.
Use Arial, Times New Roman, Georgia, Verdana, or Calibri for bundled per-glyph
advance measurements from `svg_to_pptx/drawingml/font_advances.json` in regular,
bold, italic, and bold-italic styles. Expect other families to keep the
class-average estimate, with the existing fixed advances for monospaced faces.
Use Arial, Times New Roman, Georgia, Verdana, Calibri, Cambria, Trebuchet MS, or
Garamond for bundled per-glyph advance measurements from
`svg_to_pptx/drawingml/font_advances.json` in regular, bold, italic, and
bold-italic styles; Tahoma and Segoe UI carry regular and bold only (Segoe UI is
measured from Selawik, Microsoft's OFL metric-compatible replacement). Expect a
missing style or another family to keep the class-average estimate, with the
existing fixed advances for monospaced faces.
- `measure` prints one `width<TAB>text` line per input, or a JSON array with
`--json`.
@@ -258,7 +258,7 @@ def analyze_pptx(pptx_path: Path) -> dict[str, Any]:
shape_id, shape_name = _shape_identity(container, order)
paragraphs = _paragraph_texts(container)
text = "\n".join(paragraphs)
geometry = _container_geometry(container)
geometry = _container_geometry(container, slide_root)
role = _slot_role(
{
"text": text,
@@ -666,6 +666,73 @@ def _discard_rebuilt_shape_links(root: ET.Element, shape_ids: set[str]) -> None:
visit(root)
def _report_all_dangling_slide_jumps(
entries: dict[str, bytes],
planned_refs: list[SlideRef],
*,
outputs_by_source_part: dict[str, list[str]],
source_ref_by_part: dict[str, SlideRef],
discarded_shape_links: dict[int, set[str]] | None = None,
) -> None:
"""Raise once, naming every planned slide whose jumps leave the plan.
The per-slide remap stops at the first bad relationship; a page plan that
drops two slides from a navigation deck would otherwise surface one
offending page per export attempt.
"""
problems: list[str] = []
for output_index, source_ref in enumerate(planned_refs, start=1):
rels_xml = entries.get(source_ref.rels_name)
slide_xml = entries.get(source_ref.part_name)
if not rels_xml or not slide_xml:
continue
try:
slide_root = ET.fromstring(slide_xml)
rels_root = ET.fromstring(rels_xml)
except ET.ParseError:
continue
# A link on a shape the edited page removed leaves with the shape.
discarded = (discarded_shape_links or {}).get(output_index, set())
if discarded:
_discard_rebuilt_shape_links(slide_root, discarded)
owner_label = f"Source slide {source_ref.index}"
relationship_ids = _slide_jump_relationship_ids(slide_root, owner_label=owner_label)
relationships = {
rel.attrib.get("Id", ""): rel
for rel in rels_root.findall(_qn(REL_NS, "Relationship"))
}
omitted: dict[str, int] = {}
repeated: dict[str, int] = {}
for relationship_id in relationship_ids:
rel = relationships.get(relationship_id)
if rel is None or rel.attrib.get("Type") != SLIDE_REL_TYPE:
continue
target = rel.attrib.get("Target")
if not target:
continue
source_target_part = _normalize_part(target, source_ref.part_name)
if source_target_part == source_ref.part_name:
continue
target_ref = source_ref_by_part.get(source_target_part)
target_label = (
f"source slide {target_ref.index}" if target_ref is not None else source_target_part
)
outputs = outputs_by_source_part.get(source_target_part, [])
if not outputs:
omitted[target_label] = omitted.get(target_label, 0) + 1
elif len(outputs) > 1:
repeated[target_label] = repeated.get(target_label, 0) + 1
for label, count in sorted(omitted.items()):
problems.append(f"{owner_label} links to omitted {label} ({count} relationship(s))")
for label, count in sorted(repeated.items()):
problems.append(f"{owner_label} links to repeated {label} ({count} relationship(s))")
if problems:
raise RuntimeError(
f"{len(problems)} slide-jump target(s) leave the page plan; include each "
"target exactly once or retarget the links: " + "; ".join(problems)
)
def clone_presentation_slides(
source_pptx: Path,
source_slides: tuple[int, ...],
@@ -749,6 +816,13 @@ def clone_presentation_slides(
output_part
)
source_entries = dict(entries)
_report_all_dangling_slide_jumps(
source_entries,
[slide_refs[index] for index in source_slides],
outputs_by_source_part=outputs_by_source_part,
source_ref_by_part=source_ref_by_part,
discarded_shape_links=discarded_shape_links,
)
for output_index, source_index in enumerate(source_slides, start=1):
source_ref = slide_refs[source_index]
output_part, output_rels = output_slides[output_index - 1]
@@ -387,6 +387,7 @@ def _read_diagram_container(
zf: zipfile.ZipFile,
container: ET.Element,
*,
slide_root: ET.Element,
slide_part: str,
slide_index: int,
order: int,
@@ -431,7 +432,7 @@ def _read_diagram_container(
"kind": "smartart",
"shape_id": shape_id,
"shape_name": shape_name,
"geometry": _container_geometry(container),
"geometry": _container_geometry(container, slide_root),
"layout": _layout_info(layout_root, data_root),
"root_ids": [node["id"] for node in nodes if node["parent_id"] is None],
"nodes": nodes,
@@ -452,6 +453,7 @@ def _read_diagram_container(
def _failed_diagram(
container: ET.Element,
*,
slide_root: ET.Element,
slide_index: int,
order: int,
error: Exception,
@@ -463,7 +465,7 @@ def _failed_diagram(
"kind": "smartart",
"shape_id": shape_id,
"shape_name": shape_name,
"geometry": _container_geometry(container),
"geometry": _container_geometry(container, slide_root),
"layout": {"name": None, "unique_id": None, "categories": []},
"root_ids": [],
"nodes": [],
@@ -502,6 +504,7 @@ def read_smartart_diagrams(
diagram = _read_diagram_container(
zf,
container,
slide_root=slide_root,
slide_part=slide_part,
slide_index=slide_index,
order=order,
@@ -512,6 +515,7 @@ def read_smartart_diagrams(
except (OSError, RuntimeError, zipfile.BadZipFile, ET.ParseError, KeyError, ValueError) as exc:
diagram = _failed_diagram(
container,
slide_root=slide_root,
slide_index=slide_index,
order=order,
error=exc,
@@ -182,7 +182,30 @@ def _paragraph_texts(container: ET.Element) -> list[str]:
return [text] if text else []
def _container_geometry(container: ET.Element) -> dict[str, int | None]:
def _group_chain(container: ET.Element, slide_root: ET.Element) -> list[ET.Element]:
"""Return the ``p:grpSp`` ancestors of ``container``, innermost first."""
parents = {child: parent for parent in slide_root.iter() for child in parent}
chain: list[ET.Element] = []
node = parents.get(container)
group_tag = f"{{{NS['p']}}}grpSp"
while node is not None:
if node.tag == group_tag:
chain.append(node)
node = parents.get(node)
return chain
def _container_geometry(
container: ET.Element,
slide_root: ET.Element | None = None,
) -> dict[str, int | None]:
"""Return the container's slide-space box in px.
A shape inside ``p:grpSp`` stores its ``a:xfrm`` in the group's child
space; with ``slide_root`` every enclosing group's ``off/ext`` versus
``chOff/chExt`` mapping is applied so nested siblings do not all report
the same local corner.
"""
xfrm = container.find("p:spPr/a:xfrm", NS)
if xfrm is None:
xfrm = container.find("p:xfrm", NS)
@@ -192,12 +215,42 @@ def _container_geometry(container: ET.Element) -> dict[str, int | None]:
return {"x": None, "y": None, "width": None, "height": None}
off = xfrm.find("a:off", NS)
ext = xfrm.find("a:ext", NS)
return {
"x": _emu_to_px(off.attrib.get("x")) if off is not None else None,
"y": _emu_to_px(off.attrib.get("y")) if off is not None else None,
"width": _emu_to_px(ext.attrib.get("cx")) if ext is not None else None,
"height": _emu_to_px(ext.attrib.get("cy")) if ext is not None else None,
}
try:
x = float(off.attrib["x"]) if off is not None else None
y = float(off.attrib["y"]) if off is not None else None
width = float(ext.attrib["cx"]) if ext is not None else None
height = float(ext.attrib["cy"]) if ext is not None else None
except (KeyError, ValueError):
return {"x": None, "y": None, "width": None, "height": None}
if slide_root is not None:
for group in _group_chain(container, slide_root):
group_xfrm = group.find("p:grpSpPr/a:xfrm", NS)
if group_xfrm is None:
continue
parts = [group_xfrm.find(name, NS) for name in ("a:off", "a:ext", "a:chOff", "a:chExt")]
if any(part is None for part in parts):
continue
g_off, g_ext, ch_off, ch_ext = parts
try:
ch_cx = float(ch_ext.attrib["cx"])
ch_cy = float(ch_ext.attrib["cy"])
scale_x = float(g_ext.attrib["cx"]) / ch_cx if ch_cx else 1.0
scale_y = float(g_ext.attrib["cy"]) / ch_cy if ch_cy else 1.0
if x is not None:
x = float(g_off.attrib["x"]) + (x - float(ch_off.attrib["x"])) * scale_x
if y is not None:
y = float(g_off.attrib["y"]) + (y - float(ch_off.attrib["y"])) * scale_y
except (KeyError, ValueError):
continue
if width is not None:
width *= scale_x
if height is not None:
height *= scale_y
def _px(value: float | None) -> int | None:
return None if value is None else round(value / EMU_PER_INCH * PX_PER_INCH)
return {"x": _px(x), "y": _px(y), "width": _px(width), "height": _px(height)}
def _text_containers(slide_root: ET.Element) -> list[ET.Element]:
@@ -154,6 +154,7 @@ def convert_txbody(
)
if not paragraphs or not _has_visible_text(paragraphs):
return TextResult()
_apply_norm_autofit(body_pr, paragraphs)
# Insets + anchor + wrap
lins = _read_emu_attr(body_pr, "lIns", DEFAULT_INSETS_EMU["l"])
@@ -281,6 +282,7 @@ def convert_vertical_txbody(
strict=strict,
diagnostic_sink=diagnostic_sink,
)
_apply_norm_autofit(body_pr, paragraphs)
if body_pr is not None and body_pr.attrib.get("vert") == "eaVert":
return _convert_east_asian_vertical(
paragraphs,
@@ -763,6 +765,33 @@ def _parse_paragraph(
return para
def _apply_norm_autofit(
body_pr: ET.Element | None,
paragraphs: list[TextParagraph],
) -> None:
"""Scale runs by the stored shrink-on-overflow result PowerPoint renders with."""
autofit = body_pr.find("a:normAutofit", NS) if body_pr is not None else None
if autofit is None:
return
def fraction(attr: str, default: float) -> float:
try:
value = int(autofit.attrib.get(attr, "")) / 100000.0
except ValueError:
return default
return value if 0.0 < value <= 1.0 else default
font_scale = fraction("fontScale", 1.0)
line_scale = 1.0 - fraction("lnSpcReduction", 0.0)
if font_scale == 1.0 and line_scale == 1.0:
return
for para in paragraphs:
para.empty_line_font_size_px *= font_scale
para.line_height_ratio *= line_scale
for run in para.runs:
run.font_size_px *= font_scale
def _font_size_px(
sources: tuple[ET.Element | None, ...],
default_font_size_px: float,
@@ -889,6 +918,9 @@ def _build_run(
alt_lang=alt_lang,
)
# No typeface anywhere in the chain means the theme's minor font pair.
latin_face = latin_face or theme_fonts.get("minorLatin") or None
ea_face = ea_face or theme_fonts.get("minorEastAsia") or None
font_family = _build_font_stack(latin_face, ea_face, cs_face)
hyperlink_href: str | None = None
if rpr is not None and hyperlink_resolver is not None:
@@ -3131,12 +3131,56 @@ def set_package_use_timings(
_ensure_presentation_props_references(parts, props_part=props_part)
def set_package_kiosk_show(parts: MutableMapping[str, bytes]) -> None:
"""Make ppt/presProps.xml a looping kiosk show (browsed at a kiosk).
PowerPoint's "Browsed at a kiosk (full screen)" setting: the show loops
until Escape and ignores click and keyboard advance, so only timings and
hyperlinks move between slides — what a touch-screen guide or a signage
loop needs. `useTimings` is left as the timing pass set it.
"""
rels_root = parse_source_xml(parts[PRESENTATION_RELS_PART])
existing_props_part = _presentation_props_part(rels_root)
props_part = existing_props_part or PRESENTATION_PROPS_PART
if props_part in parts:
source = parts[props_part]
root = parse_source_xml(source)
else:
if existing_props_part is not None:
raise ValueError(
f"presentation properties part is missing: {props_part}"
)
source = (
f'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
f'<p:presentationPr xmlns:p="{PML_NS}"/>'
).encode("utf-8")
root = parse_source_xml(source)
show_properties = root.find(_qn(PML_NS, "showPr"))
if show_properties is None:
show_properties = ET.Element(_qn(PML_NS, "showPr"))
_insert_show_properties(root, show_properties)
show_properties.set("loop", "1")
# CT_ShowProperties opens with one of present / browse / kiosk.
for child in list(show_properties):
if child.tag in {_qn(PML_NS, kind) for kind in ("present", "browse", "kiosk")}:
show_properties.remove(child)
show_properties.insert(0, ET.Element(_qn(PML_NS, "kiosk")))
parts[props_part] = serialize_source_xml(root, source)
_ensure_presentation_props_references(parts, props_part=props_part)
def set_directory_use_timings(
extract_dir: Path,
*,
enabled: bool = True,
enabled: bool | None = True,
kiosk: bool = False,
) -> None:
"""Set presentation timing playback in an extracted PPTX directory."""
"""Set presentation timing playback (and optionally kiosk looping) in an extracted PPTX directory.
``enabled=None`` leaves ``useTimings`` untouched so a kiosk show can be
written without claiming timed advance.
"""
part_names = {
PRESENTATION_RELS_PART,
CONTENT_TYPES_PART,
@@ -3150,7 +3194,10 @@ def set_directory_use_timings(
name: (extract_dir / name).read_bytes()
for name in part_names
}
if enabled is not None:
set_package_use_timings(parts, enabled=enabled)
if kiosk:
set_package_kiosk_show(parts)
for name, payload in parts.items():
path = extract_dir / name
path.parent.mkdir(parents=True, exist_ok=True)
@@ -949,11 +949,18 @@ class ProjectManager:
if suffix in {".md", ".markdown"}:
duplicate_markdown = self._find_equivalent_markdown(source_path, sources_dir)
if duplicate_markdown is not None:
if str(duplicate_markdown) not in summary["markdown"]:
summary["markdown"].append(str(duplicate_markdown))
if propagate_images:
self._propagate_companion_image_assets(duplicate_markdown, project_dir)
# A moved source must leave projects/ even when its content
# already sits in the project under another name.
removed = ""
if effective_move and source_path.resolve() != duplicate_markdown.resolve():
source_path.unlink()
removed = "; the moved duplicate was removed"
summary["notes"].append(
f"{item}: skipped duplicate markdown import because equivalent content already exists as {duplicate_markdown.name}"
f"{item}: skipped duplicate markdown import because equivalent content already exists as {duplicate_markdown.name}{removed}"
)
continue
@@ -27,6 +27,7 @@ from pathlib import Path
from typing import Callable, Iterable
from .paths import SKILL_DIR as _SKILL_DIR
from .spec_blocks import SLIDE_HEADING_RE as _SLIDE_HEADING_RE
from .project_specs import (
default_spec_lock_forbidden,
parse_markdown_artifact,
@@ -55,10 +56,6 @@ PAGE_CONTEXT_TOKEN_TARGET = 2000
LOCK_PROJECTION_TOKEN_TARGET = 1000
_PAGE_RE = re.compile(r"^(?:P)?([0-9]+)$", re.IGNORECASE)
_SLIDE_HEADING_RE = re.compile(
r"^#{3,6}[ \t]+Slide[ \t]+0*([0-9]+)(?:[ \t]*(?:[-:–—]).*)?$",
re.IGNORECASE | re.MULTILINE,
)
_BLOCK_BOUNDARY_RE = re.compile(r"^#{2,6}[ \t]+", re.MULTILINE)
_PART_HEADING_RE = re.compile(r"^###[ \t]+(?!#)(.+?)[ \t]*$", re.MULTILINE)
_PAGE_TOKEN_RE = re.compile(
@@ -1261,7 +1261,27 @@ def _validate_spec_lock_relations(
def validate_markdown_schema(markdown_path: Path, schema_path: Path) -> list[str]:
"""Validate one existing Markdown artifact against a versioned schema."""
try:
text = markdown_path.read_text(encoding="utf-8-sig")
# Candidate validation removes exactly one BOM, as utf-8-sig did here.
text = markdown_path.read_text(encoding="utf-8")
except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc:
return [f"Schema validation could not read {markdown_path.name}: {exc}"]
return validate_markdown_text(text, schema_path, markdown_path=markdown_path)
def validate_markdown_text(
text: str,
schema_path: Path,
*,
markdown_path: Path = Path("design_spec.md"),
) -> list[str]:
"""Validate a candidate without reading or writing its Markdown file.
The path supplies diagnostic names and a base for schema-declared asset
references. A design-spec schema never loads the execution lock.
"""
text = text.removeprefix("\ufeff").replace("\r\n", "\n").replace("\r", "\n")
try:
schema = _load_markdown_schema(schema_path)
except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc:
return [f"Schema validation could not read {markdown_path.name}: {exc}"]
@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""
PPT Master - Lossless Design Spec Blocks
Locate Markdown blocks without normalizing whitespace or line endings.
Usage:
Import split_spec_blocks() and replace_spec_block().
Examples:
from project_management.spec_blocks import split_spec_blocks
Dependencies:
None (only uses the standard library)
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Iterator
# Also used by the legacy page-context reader; keep its grammar unchanged.
SLIDE_HEADING_RE = re.compile(
r"^#{3,6}[ \t]+Slide[ \t]+0*([0-9]+)(?:[ \t]*(?:[-:–—]).*)?$",
re.IGNORECASE | re.MULTILINE,
)
_HEADING_RE = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+(.*)|[ \t]*)$")
_FENCE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
_SECTION_RE = re.compile(r"^([IVXLCDM]+)\.(?:[ \t]|$)", re.IGNORECASE)
_TRAILING_SPACE_RE = re.compile(r"[ \t\r\n]*$")
def _slide_match(line: str):
return SLIDE_HEADING_RE.fullmatch(line.strip(" \t"))
@dataclass(frozen=True)
class SpecBlock:
"""One source range: character offsets plus equivalent UTF-8 byte offsets."""
kind: str
key: str
title: str
start: int
end: int
byte_start: int
byte_end: int
section: str
@dataclass(frozen=True)
class SpecBlocks:
"""The untouched preamble followed by contiguous, ordered source blocks."""
preamble: str
blocks: tuple[SpecBlock, ...]
def markdown_headings(text: str) -> Iterator[tuple[int, int, str]]:
"""Yield (character offset, level, original line) outside fenced code."""
fence = ""
offset = 0
for raw_line in text.splitlines(keepends=True):
line = raw_line.rstrip("\r\n")
match = _FENCE_RE.fullmatch(line)
if fence:
if match and match[1][0] == fence[0] and len(match[1]) >= len(fence) and not match[2].strip():
fence = ""
elif match and not (match[1][0] == "`" and "`" in match[2]):
fence = match[1]
else:
heading = _HEADING_RE.fullmatch(line)
if heading:
yield offset, len(heading[1]), line
offset += len(raw_line)
def split_spec_blocks(text: str) -> SpecBlocks:
"""Partition raw text; preamble + all block slices is exactly the input.
H2 sections are blocks. In section IX each H3-H6 Slide and each non-Slide
H3 Part heading starts a separate block. Parts are numbered by occurrence.
Duplicate keys receive occurrence suffixes so malformed specs remain readable.
"""
starts: list[tuple[int, str, str, str, str]] = []
occurrences: dict[str, int] = {}
section = ""
part_number = 0
for offset, level, line in markdown_headings(text):
title = line.lstrip(" ").lstrip("#").strip()
if level == 2:
match = _SECTION_RE.match(title)
section = match[1].upper() if match else title
kind, key = ("outline", "section:IX") if section == "IX" else ("section", f"section:{section}")
elif section == "IX" and (match := _slide_match(line)):
kind, key = "slide", f"slide:{int(match[1]):02d}"
elif section == "IX" and level == 3:
part_number += 1
kind, key = "part", f"part:{part_number:02d}"
else:
continue
occurrences[key] = occurrences.get(key, 0) + 1
if occurrences[key] > 1:
key = f"{key}~{occurrences[key]}"
starts.append((offset, kind, key, line, section))
preamble = text[:starts[0][0]] if starts else text
blocks: list[SpecBlock] = []
byte_offset = len(preamble.encode("utf-8"))
for index, (start, kind, key, title, section) in enumerate(starts):
end = starts[index + 1][0] if index + 1 < len(starts) else len(text)
byte_end = byte_offset + len(text[start:end].encode("utf-8"))
blocks.append(SpecBlock(kind, key, title, start, end, byte_offset, byte_end, section))
byte_offset = byte_end
return SpecBlocks(preamble, tuple(blocks))
def replace_spec_block(text: str, key: str, replacement: str) -> str:
"""Replace one block, preserving surrounding bytes and its final whitespace.
Browser textareas use LF. Changed text uses the block's original newline
style; an unchanged textarea round trip preserves even mixed line endings.
Section headings, Slide identities, and Part bodies cannot be edited here.
"""
layout = split_spec_blocks(text)
block = next((item for item in layout.blocks if item.key == key), None)
if block is None:
raise ValueError(f"Unknown block {key}; reload the spec.")
before = text[block.start:block.end]
normalize = lambda value: value.replace("\r\n", "\n").replace("\r", "\n")
if normalize(before) == normalize(replacement):
return text
newline = re.search(r"\r\n|\r|\n", before) or re.search(r"\r\n|\r|\n", text)
replacement = normalize(replacement).replace("\n", newline[0] if newline else "\n")
tail = _TRAILING_SPACE_RE.search(before)[0]
replacement = _TRAILING_SPACE_RE.sub("", replacement) + tail
headings = list(markdown_headings(replacement))
if not headings or headings[0][0] != 0:
raise ValueError("Keep the block's heading on the first line.")
first = headings[0][2]
if block.kind == "slide":
old = _slide_match(block.title)
new = _slide_match(first)
if not new or new[1] != old[1] or first[:first.index(new[0]) + new.end(1)] != (
block.title[:block.title.index(old[0]) + old.end(1)]
):
raise ValueError("Keep the Slide heading level and number; only its page name may change.")
elif block.kind == "part":
if headings[0][1] != 3 or _slide_match(first):
raise ValueError("Keep the Part heading level; only its title text may change.")
if normalize(before[len(block.title):]) != normalize(replacement[len(first):]):
raise ValueError("Only the Part title text may change; keep its remaining content unchanged.")
elif first != block.title:
raise ValueError("Keep the section heading unchanged; request structural changes in an annotation.")
for _, level, line in headings[1:]:
if level == 2 or _slide_match(line) or (block.section == "IX" and level == 3):
raise ValueError("Structural heading injection (##, Part, or Slide) is not allowed; use an annotation.")
candidate = text[:block.start] + replacement + text[block.end:]
if [(item.kind, item.key) for item in split_spec_blocks(candidate).blocks] != [
(item.kind, item.key) for item in layout.blocks
]:
raise ValueError("The edit changes block boundaries (check fenced code); use an annotation.")
return candidate
@@ -27,6 +27,7 @@ from project_management.project_specs import (
parse_spec_lock_image_value,
scaffold_project_artifact,
validate_markdown_schema,
validate_markdown_text,
validate_project_artifacts,
)
@@ -42,5 +43,6 @@ __all__ = [
"parse_spec_lock_image_value",
"scaffold_project_artifact",
"validate_markdown_schema",
"validate_markdown_text",
"validate_project_artifacts",
]
@@ -463,6 +463,139 @@ def validate_outline_roster(project_path: str | Path) -> List[str]:
return [' '.join(parts)]
_SLIDE_JUMP_RE = re.compile(r'#slide-([1-9][0-9]*)\b')
_EXTERNAL_LINK_RE = re.compile(r'\b(?:[a-z][a-z0-9+.-]*://|mailto:|tel:)[^\s<>"`「」『』()()]+', re.IGNORECASE)
_LINK_TRAIL_CHARS = ';,.;,。、))]`'
def _outline_hyperlink_lines(design_text: str) -> Dict[int, str | None]:
"""Map each §IX Slide number to its ``Hyperlinks`` line (continuation lines joined)."""
outline_match = re.search(
r'^##[ \t]+IX\.[ \t]+Content Outline\b.*$',
design_text,
flags=re.IGNORECASE | re.MULTILINE,
)
if outline_match is None:
return {}
next_section = re.search(r'^##[ \t]+', design_text[outline_match.end():], flags=re.MULTILINE)
outline_end = (
outline_match.end() + next_section.start() if next_section else len(design_text)
)
outline = design_text[outline_match.end():outline_end]
slide_matches = list(re.finditer(
r'^#{3,6}[ \t]+Slide[ \t]+([0-9]+)\b.*$',
outline,
flags=re.IGNORECASE | re.MULTILINE,
))
lines_by_slide: Dict[int, str | None] = {}
for index, slide_match in enumerate(slide_matches):
block_end = (
slide_matches[index + 1].start() if index + 1 < len(slide_matches) else len(outline)
)
block_lines = outline[slide_match.end():block_end].splitlines()
value = None
for position, line in enumerate(block_lines):
field = re.match(
r'^[ \t]*-[ \t]+(?:\*\*)?Hyperlinks(?:[ \t]*\([^)]*\))?(?:\*\*)?[ \t]*[::](.*)$',
line,
flags=re.IGNORECASE,
)
if field is None:
continue
parts = [field.group(1)]
for continuation in block_lines[position + 1:]:
if not continuation.strip() or not continuation.startswith((' ', '\t')):
break
parts.append(continuation)
value = ' '.join(parts)
break
lines_by_slide[int(slide_match.group(1))] = value
return lines_by_slide
def _svg_hyperlink_targets(svg_file: Path) -> Tuple[set, set] | None:
"""Return (same-deck slide numbers, external hrefs) of an SVG's anchors, or None if unreadable."""
try:
text = svg_file.read_text(encoding='utf-8')
except OSError:
return None
slides, externals = set(), set()
for match in re.finditer(r'<a\b[^>]*?\s(?:xlink:)?href=["\']([^"\']*)["\']', text):
href = match.group(1).strip()
jump = _SLIDE_JUMP_RE.fullmatch(href)
if jump:
slides.add(int(jump.group(1)))
elif href:
externals.add(href)
return slides, externals
def validate_outline_hyperlinks(project_path: str | Path) -> List[str]:
"""Compare each page's hyperlink anchors with its design_spec §IX ``Hyperlinks`` line.
``#slide-N`` is an absolute page number, so a page inserted, dropped, or
moved shifts every jump after it while each target stays inside the
roster and passes the per-file range check. The §IX line names the
planned targets in the same ``#slide-N`` form; a page whose anchors and
plan disagree is reported. Pages with neither are skipped.
"""
root = Path(project_path)
design_spec = next(
(root / name for name in _DESIGN_SPEC_NAMES if (root / name).is_file()),
None,
)
svg_output = root / 'svg_output'
if design_spec is None or not svg_output.is_dir():
return []
try:
design_text = design_spec.read_text(encoding='utf-8-sig')
except OSError as exc:
return [f'Outline hyperlinks: unable to read {design_spec.name}: {exc}']
planned_lines = _outline_hyperlink_lines(design_text)
if not planned_lines:
return []
errors = []
for svg_file in discover_slide_svgs(svg_output):
match = re.match(r'(\d+)', svg_file.name)
if not match:
continue
number = int(match.group(1))
targets = _svg_hyperlink_targets(svg_file)
if targets is None or number not in planned_lines:
continue
svg_slides, svg_externals = targets
line = planned_lines[number]
if line is None:
if svg_slides or svg_externals:
errors.append(
f'Outline hyperlinks: {svg_file.name} carries hyperlinks but '
f'{design_spec.name} §IX Slide {number:02d} has no Hyperlinks line; '
'record each target there (same-deck jumps as #slide-N).'
)
continue
planned_slides = {int(n) for n in _SLIDE_JUMP_RE.findall(line)}
planned_externals = {
found.group(0).rstrip(_LINK_TRAIL_CHARS)
for found in _EXTERNAL_LINK_RE.finditer(line)
}
if planned_slides != svg_slides:
def _fmt(numbers: set) -> str:
return ', '.join(f'#slide-{n}' for n in sorted(numbers)) or '(none)'
errors.append(
f'Outline hyperlinks: {svg_file.name} jumps to {_fmt(svg_slides)} but '
f'{design_spec.name} §IX Slide {number:02d} plans {_fmt(planned_slides)}; '
'retarget the anchors or the plan so both name the current roster.'
)
if planned_externals != svg_externals:
def _fmt_links(links: set) -> str:
return ', '.join(sorted(links)) or '(none)'
errors.append(
f'Outline hyperlinks: {svg_file.name} links to {_fmt_links(svg_externals)} but '
f'{design_spec.name} §IX Slide {number:02d} plans {_fmt_links(planned_externals)}.'
)
return errors
def validate_project_structure(
project_path: str,
verbose: bool = False,
@@ -55,7 +55,7 @@
"skills/ppt-master/references/artifact-ownership.md": 4500,
"skills/ppt-master/workflows/governance/failure-recovery.md": 2750,
"skills/ppt-master/workflows/stages/resume-execute.md": 1750,
"skills/ppt-master/workflows/stages/refine-spec.md": 1000,
"skills/ppt-master/workflows/stages/refine-spec.md": 1500,
"skills/ppt-master/workflows/stages/verify-charts.md": 2500,
"skills/ppt-master/workflows/stages/topic-research.md": 2000,
"skills/ppt-master/workflows/stages/live-preview.md": 1000,
@@ -65,7 +65,7 @@
"skills/ppt-master/workflows/stages/generate-audio.md": 4000,
"skills/ppt-master/workflows/profiles/beautify-pptx.md": 5000,
"skills/ppt-master/workflows/profiles/image-to-pptx.md": 3250,
"skills/ppt-master/workflows/edit-native-pptx.md": 4250,
"skills/ppt-master/workflows/edit-native-pptx.md": 4500,
"skills/ppt-master/workflows/create-template.md": 10000,
"skills/ppt-master/references/template-designer.md": 7000,
"skills/ppt-master/workflows/create-template/create-brand.md": 1500,
@@ -2429,6 +2429,10 @@
"glob": "skills/ppt-master/scripts/docs/svg_editor.md",
"reason": "Browser editor behavior and lifecycle reference; the live-preview stage carries the launch and annotation procedure."
},
{
"glob": "skills/ppt-master/scripts/docs/spec_review.md",
"reason": "Spec review tool reference for browser editing, annotation sidecars, HTTP APIs, and server lifecycle; not loaded as runtime prompt context."
},
{
"glob": "skills/ppt-master/scripts/docs/narration.md",
"reason": "Narration tool behavior and flag reference; the generate-audio stage carries the procedure and confirmation contract."
@@ -80,6 +80,15 @@ _EDGE_DRIFT_MAX_PIXELS = 8
_HAZE_ALPHA_LOW = 20
_HAZE_ALPHA_HIGH = 150
_HAZE_MAX_SHARE = 0.15
# An uneven key ground (gradient, lighting, texture) is keyed by key-channel
# dominance against a local ground estimate instead of distance to one colour.
_UNEVEN_RING_RATIO = 0.01
_UNEVEN_MIN_DOMINANCE = 48
_UNEVEN_RING_KEY_SHARE = 0.98
_UNEVEN_RING_DOMINANCE_RANGE = 32
_UNEVEN_TRIM_ALPHA = 8
_UNEVEN_ESTIMATE_SIDE = 96
_UNEVEN_ESTIMATE_WINDOW = 15
# Corner sample inset (px) and minimum cell fill for the backing-panel notice.
_PANEL_CORNER_INSET = 2
_PANEL_MIN_FILL = 0.75
@@ -422,6 +431,158 @@ def _decontaminate_rgb(
)
def _key_hue_channel(bg: tuple[int, int, int]) -> Optional[int]:
"""Return the channel a key-hued ground is dominated by, if any."""
index = max(range(3), key=lambda channel: bg[channel])
others = max(bg[channel] for channel in range(3) if channel != index)
if bg[index] - others < _UNEVEN_MIN_DOMINANCE:
return None
if others >= bg[index] * _KEY_PURITY_OPAQUE_RATIO:
return None
return index
def _sheet_ground_is_uneven(sheet: Image.Image, bg: Optional[tuple[int, int, int]]) -> bool:
"""Tell whether the sheet's outer gutter is a key-hued field that is not flat."""
try:
import numpy as np
except ImportError:
return False
pixels = np.asarray(sheet.convert("RGB"), dtype=np.int16)
height, width = pixels.shape[:2]
ring_y = max(_BG_SAMPLE_BORDER, round(height * _UNEVEN_RING_RATIO))
ring_x = max(_BG_SAMPLE_BORDER, round(width * _UNEVEN_RING_RATIO))
if ring_y * 2 >= height or ring_x * 2 >= width:
return False
ring = np.ones((height, width), dtype=bool)
ring[ring_y:height - ring_y, ring_x:width - ring_x] = False
ring_pixels = pixels[ring]
ground = bg if bg is not None else tuple(int(v) for v in np.median(ring_pixels, axis=0))
key_index = _key_hue_channel(ground) # type: ignore[arg-type]
if key_index is None:
return False
other = np.delete(ring_pixels, key_index, axis=1).max(axis=1)
dominance = np.clip(ring_pixels[:, key_index] - other, 0, 255)
keyed = (dominance >= _UNEVEN_MIN_DOMINANCE) & (
other < ring_pixels[:, key_index] * _KEY_PURITY_OPAQUE_RATIO
)
if keyed.mean() < _UNEVEN_RING_KEY_SHARE:
return False
low, high = np.percentile(dominance[keyed], (1, 99))
return bool(high - low > _UNEVEN_RING_DOMINANCE_RANGE)
def _dominance_masks(
rgb: Image.Image,
bg: tuple[int, int, int],
tolerance: int,
uneven_sheet: bool = False,
) -> Optional[tuple[Image.Image, Image.Image, Image.Image, Image.Image]]:
"""Key an uneven key-hued ground by key-channel dominance, or return None.
A generated sheet often paints its key as a gradient or textured field, so
no single colour with any tolerance separates it: a tolerance wide enough
to clear the dark end also eats thin strokes and leaves their edges
key-tinted. The ground's invariant is that the key channel dominates the
other two, whatever its brightness. Each pixel's dominance is compared with
a local estimate of the ground's dominance; the shortfall is its opacity,
and colour is un-premultiplied against the same local ground.
Applies only when the cell's outer ring is a clean key-hued gutter and the
ground is not flat, in this cell or (``uneven_sheet``) across the sheet, so
every cell of one sheet is keyed the same way.
"""
key_index = _key_hue_channel(bg)
if key_index is None:
return None
try:
import numpy as np
except ImportError:
return None
pixels = np.asarray(rgb, dtype=np.int16)
height, width = pixels.shape[:2]
other_indexes = [channel for channel in range(3) if channel != key_index]
key = pixels[..., key_index]
other = pixels[..., other_indexes].max(axis=2)
dominance = np.clip(key - other, 0, 255)
ring_y = max(_BG_SAMPLE_BORDER, round(height * _UNEVEN_RING_RATIO))
ring_x = max(_BG_SAMPLE_BORDER, round(width * _UNEVEN_RING_RATIO))
if ring_y * 2 >= height or ring_x * 2 >= width:
return None
ring = np.ones((height, width), dtype=bool)
ring[ring_y:height - ring_y, ring_x:width - ring_x] = False
ring_pixels = pixels[ring]
ring_dominance = dominance[ring]
ring_other = other[ring]
keyed = (ring_dominance >= _UNEVEN_MIN_DOMINANCE) & (
ring_other < ring_pixels[:, key_index] * _KEY_PURITY_OPAQUE_RATIO
)
if keyed.mean() < _UNEVEN_RING_KEY_SHARE:
return None
# Uneven means the gutter spans a dominance range no flat field with
# compression noise produces. A flat ground that merely differs from the
# stated key is not this case: it keeps the single-colour path, which
# rejects it and names the measured colour to rerun with.
low, high = np.percentile(ring_dominance[keyed], (1, 99))
if not uneven_sheet and high - low <= _UNEVEN_RING_DOMINANCE_RANGE:
return None
# Local ground dominance: the brightest dominance in each neighbourhood is
# ground wherever the neighbourhood holds any; a floor covers the interior
# of a large element, where the estimate does not change the result.
scale = max(1, max(width, height) // _UNEVEN_ESTIMATE_SIDE)
small_size = (max(1, width // scale), max(1, height // scale))
dominance_image = Image.fromarray(dominance.astype(np.uint8), "L")
estimate = (
dominance_image.resize(small_size, Image.BOX)
.filter(ImageFilter.MaxFilter(_UNEVEN_ESTIMATE_WINDOW))
.filter(ImageFilter.GaussianBlur(_UNEVEN_ESTIMATE_WINDOW // 4))
.resize((width, height), Image.BILINEAR)
)
floor = max(_UNEVEN_MIN_DOMINANCE, int(np.median(ring_dominance[keyed])) // 2)
ground = np.maximum(np.asarray(estimate, dtype=np.float32), floor)
shortfall = np.clip(ground - dominance, 0, None)
# The gutter holds ground only, so its own shortfall is the field's grain:
# the gate clears that grain even when it exceeds the stated tolerance.
gate = max(float(tolerance), float(np.percentile(shortfall[ring][keyed], 99.5)) + 2.0)
span = np.maximum(ground - gate, 1.0)
opacity = np.clip((shortfall - gate) / span, 0.0, 1.0)
# A colour that merely shares the key's hue keeps a substantial non-key
# channel and stays opaque, as in the pure-key path.
blend = (key > other) & (other < key * _KEY_PURITY_OPAQUE_RATIO)
opacity = np.where(blend, opacity, 1.0)
ground_colour = np.median(ring_pixels[keyed], axis=0).astype(np.float32)
ground_other = float(max(ground_colour[channel] for channel in other_indexes))
backdrop = np.empty(pixels.shape, dtype=np.float32)
for channel in range(3):
backdrop[..., channel] = (
ground + ground_other if channel == key_index else ground_colour[channel]
)
safe = np.maximum(opacity, 1 / 255)[..., None]
colour = (pixels - (1.0 - opacity[..., None]) * backdrop) / safe
colour = np.clip(colour, 0, 255)
# Spill suppression on recovered edges: a blended pixel's key channel
# never exceeds its strongest non-key channel.
edge = blend & (opacity < 1.0)
limit = colour[..., other_indexes].max(axis=2)
colour[..., key_index] = np.where(
edge, np.minimum(colour[..., key_index], limit), colour[..., key_index]
)
alpha_mask = Image.fromarray(np.rint(opacity * 255).astype(np.uint8), "L")
# The gate is a percentile of the gutter's grain, so a few ground pixels sit
# just above it; they are not content and must not reach the trim box.
trim_mask = alpha_mask.point(lambda value: 255 if value > _UNEVEN_TRIM_ALPHA else 0)
distance = np.where(blend, np.clip(shortfall, 0, 255), 255)
diff = Image.fromarray(distance.astype(np.uint8), "L")
keyed_rgb = Image.fromarray(np.rint(colour).astype(np.uint8), "RGB")
return trim_mask, alpha_mask, keyed_rgb, diff
def _soft_mask_from_diff(diff: Image.Image, tolerance: int) -> Image.Image:
"""Build a feathered alpha mask around the tolerance threshold."""
low = max(0, tolerance - _DEFAULT_FEATHER)
@@ -445,9 +606,20 @@ def _content_masks(
cell: Image.Image,
bg: tuple[int, int, int],
tolerance: int,
notices: Optional[list[str]] = None,
uneven_sheet: bool = False,
) -> tuple[Image.Image, Image.Image, Optional[Image.Image], Image.Image]:
"""Build trim/alpha masks, optional chroma-decontaminated RGB, and the key diff."""
rgb = cell.convert("RGB")
uneven = _dominance_masks(rgb, bg, tolerance, uneven_sheet)
if uneven is not None:
if notices is not None:
notices.append(
"the key ground is uneven (gradient, lighting, or texture); keyed by "
"key-channel dominance against the local ground instead of one colour — "
"inspect the cut elements"
)
return uneven
diff = _max_channel_difference(rgb, bg)
trim_mask = diff.point(lambda p: 255 if p > tolerance else 0)
tolerance_gate = _soft_mask_from_diff(diff, tolerance)
@@ -562,6 +734,7 @@ def _haze_finding(
*,
cell: Image.Image,
tolerance: int,
uneven: bool = False,
) -> Optional[str]:
"""Report semi-transparent haze only when the key-only margins are off-key.
@@ -580,6 +753,26 @@ def _haze_finding(
share = hazy / total
if share <= _HAZE_MAX_SHARE:
return None
if uneven:
# Dominance keying has no single stated colour to compare with: the
# four key-only margins must simply have come back transparent.
width, height = alpha_mask.size
margin_x, margin_y = max(1, round(width * 0.10)), max(1, round(height * 0.10))
margins = [
alpha_mask.crop((0, 0, width, margin_y)),
alpha_mask.crop((0, height - margin_y, width, height)),
alpha_mask.crop((0, 0, margin_x, height)),
alpha_mask.crop((width - margin_x, 0, width, height)),
]
total_margin = sum(part.width * part.height for part in margins)
residue = sum(sum(part.histogram()[_HAZE_ALPHA_LOW:]) for part in margins)
if residue / total_margin <= _HAZE_MAX_SHARE:
return None
return (
f"{label}: {residue / total_margin:.0%} of the four 10% key-only margins "
"stayed visible after dominance keying of an uneven ground: an effect or "
"the ground's lighting occupies the margin; regenerate with clear margins"
)
dominant, spread, distance = _sample_sheet_border(
cell, border_ratio=0.10, key=bg,
)
@@ -760,6 +953,7 @@ def slice_sheet(
strict_alpha: bool = False,
bg: Optional[tuple[int, int, int]] = None,
tolerance: int = 18,
_auto_retry: bool = True,
) -> list[Path]:
"""Slice `sheet_path` into rows*cols element PNGs under `output_dir`.
@@ -797,6 +991,7 @@ def slice_sheet(
with Image.open(sheet_path) as source:
sheet = ImageOps.exif_transpose(source).convert("RGBA")
sw, sh = sheet.size
uneven_sheet = (trim or alpha) and _sheet_ground_is_uneven(sheet, bg)
output_dir.mkdir(parents=True, exist_ok=True)
stem = sheet_path.stem
@@ -824,8 +1019,12 @@ def slice_sheet(
bbox = None
if trim or alpha:
cell_bg = bg if bg is not None else _sample_bg(cell, tolerance)
cell_notices: list[str] = []
trim_mask, alpha_mask, keyed_rgb, diff = _content_masks(
cell, cell_bg, tolerance
cell, cell_bg, tolerance, cell_notices, uneven_sheet
)
notices.extend(
f"cell ({r},{c}): {notice}" for notice in cell_notices
)
bbox = trim_mask.getbbox()
if bbox is None:
@@ -838,7 +1037,7 @@ def slice_sheet(
if strict_alpha:
haze = _haze_finding(
f"cell ({r},{c})", alpha_mask, bbox, cell_bg,
cell=cell, tolerance=tolerance,
cell=cell, tolerance=tolerance, uneven=bool(cell_notices),
)
if haze:
findings.append(haze)
@@ -865,6 +1064,31 @@ def slice_sheet(
idx += 1
if findings:
# When every finding is measured key noise or drift (never content on
# an edge), the tool already knows the tolerance that clears it: apply
# it once instead of asking for the same command again.
suggested = [
int(match.group(1))
for finding in findings
for match in [re.search(r"rerun with --tolerance (\d+)", finding)]
if match is not None
]
if (
strict_alpha
and _auto_retry
and len(suggested) == len(findings)
):
retry_tolerance = max(suggested)
_log(
f"[WARN] strict alpha: all {len(findings)} finding(s) are key noise; "
f"auto-retrying once with --tolerance {retry_tolerance} (measured)"
)
return slice_sheet(
sheet_path, rows, cols, output_dir,
names=names, prefix=prefix, inset=inset, trim=trim, alpha=alpha,
strict_alpha=strict_alpha, bg=bg, tolerance=retry_tolerance,
_auto_retry=False,
)
sheet_border = _sample_sheet_border(sheet) if strict_alpha else None
_log_keying_findings(
findings,
@@ -0,0 +1,15 @@
#!/usr/bin/env python3
"""
PPT Master - Spec Review
Browser review, annotations, and staged Markdown edits for design_spec.md.
Usage:
python3 scripts/spec_review/server.py <project_path>
Examples:
python3 scripts/spec_review/server.py projects/example --daemon
Dependencies:
Flask (server); standard library (storage and annotation CLI)
"""
@@ -0,0 +1,350 @@
#!/usr/bin/env python3
"""
PPT Master - Spec Review Server
Serve a local Markdown review surface with staged edits and sidecar comments.
See scripts/docs/spec_review.md for commands, APIs, and runtime files.
Usage:
python3 scripts/spec_review/server.py <project_path> [--daemon]
Examples:
python3 scripts/spec_review/server.py projects/example --daemon --no-browser
python3 scripts/spec_review/server.py projects/example --hold on
Dependencies:
Flask (already used by svg_editor and confirm_ui)
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import signal
import subprocess
import sys
import threading
import time
import urllib.parse
import urllib.request
from pathlib import Path
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from console_encoding import configure_utf8_stdio # noqa: E402
configure_utf8_stdio()
from flask import Flask, jsonify, request, send_from_directory # noqa: E402
from werkzeug.exceptions import HTTPException # noqa: E402
from werkzeug.serving import make_server # noqa: E402
from server_common import ( # noqa: E402
claim_lock,
clear_lock,
find_free_port,
lock_pid,
open_preview_browser,
popen_detached,
process_alive,
read_lock,
release_lock,
validate_port,
)
from spec_review.store import ReviewError, ReviewStore, file_mutex # noqa: E402
PUBLIC_HOST = "127.0.0.1"
DEFAULT_PORT = 6060
logger = logging.getLogger("spec_review")
class _QuietPolls(logging.Filter):
"""Drop successful state polls so real requests stay findable in server.log."""
def filter(self, record: logging.LogRecord) -> bool:
message = record.getMessage()
return not ('"GET /api/state ' in message and '" 200 ' in message)
def create_app(project_dir: str, idle_timeout: int = 7200) -> Flask:
"""Create the testable HTTP surface; lifecycle threads belong to main()."""
store = ReviewStore(Path(project_dir))
store.document()
app = Flask(__name__, static_folder="static", static_url_path="/static")
app.config.update(MAX_CONTENT_LENGTH=4 * 1024 * 1024, LAST_ACTIVITY=time.monotonic(), IDLE_TIMEOUT=idle_timeout)
app.extensions["review_store"] = store
app.extensions["stop_event"] = threading.Event()
@app.before_request
def protect_local_service():
try:
host = urllib.parse.urlsplit("//" + request.host)
if host.hostname not in {PUBLIC_HOST, "localhost", "::1"} or host.username or host.password:
raise ValueError("Host")
host_port = host.port or 80
origin = request.headers.get("Origin")
if origin is not None:
parsed = urllib.parse.urlsplit(origin)
if (parsed.scheme != request.scheme or parsed.hostname != host.hostname
or (parsed.port or 80) != host_port or parsed.username or parsed.password
or parsed.path or parsed.query or parsed.fragment):
raise ValueError("Origin")
except ValueError:
return jsonify(error="Forbidden Host or Origin header"), 403
if request.path not in {"/api/state", "/api/health"}:
app.config["LAST_ACTIVITY"] = time.monotonic()
@app.after_request
def secure_response(response):
response.headers["Cache-Control"] = "no-store"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Content-Security-Policy"] = (
"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; "
"img-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'"
)
return response
@app.errorhandler(ReviewError)
def review_error(error):
return jsonify(error=str(error)), error.status
@app.errorhandler(OSError)
@app.errorhandler(ValueError)
def storage_error(error):
logger.error("spec review storage: %s", error)
return jsonify(error=str(error)), 500
@app.errorhandler(HTTPException)
def http_error(error):
return jsonify(error=error.description), error.code
def payload(*strings: str) -> dict:
data = request.get_json(silent=True)
if not isinstance(data, dict) or any(not isinstance(data.get(key), str) for key in strings):
raise ReviewError("Send a JSON object with string fields: " + ", ".join(strings))
return data
@app.get("/")
def index():
return send_from_directory(app.static_folder, "index.html")
@app.get("/api/health")
def health():
return jsonify(status="ok", service="spec_review", pid=os.getpid(), project=str(store.project))
@app.get("/api/state")
def state():
return jsonify(store.state())
@app.get("/api/blocks")
def blocks():
return jsonify(store.snapshot())
@app.get("/api/spec")
def spec():
return jsonify(store.document())
@app.get("/api/blocks/<path:key>")
def block(key):
return jsonify(store.block(key))
@app.put("/api/drafts/<path:key>")
def stage(key):
data = payload("text", "sha256")
return jsonify(store.stage(key, data["text"], data["sha256"], data.get("version")))
@app.delete("/api/drafts/<path:key>")
def discard(key):
data = payload("version")
store.discard(key, data["version"])
return jsonify(status="ok")
@app.post("/api/apply/<path:key>")
def apply(key):
data = payload("sha256", "version")
return jsonify(store.apply(key, data["sha256"], data["version"]))
@app.route("/api/annotations", methods=["GET", "POST"])
def annotations():
if request.method == "GET":
return jsonify(annotations=store.annotations())
data = payload("key", "body")
return jsonify(store.save_annotation(data["key"], data["body"])), 201
@app.route("/api/annotations/<item_id>", methods=["PUT", "DELETE"])
def annotation(item_id):
data = payload("revision", *( ["body"] if request.method == "PUT" else []))
if request.method == "DELETE":
store.remove_annotation(item_id, data["revision"])
return jsonify(status="ok")
return jsonify(store.save_annotation("", data["body"], item_id, data["revision"]))
@app.post("/api/hold")
def hold():
data = payload()
if not isinstance(data.get("hold"), bool):
raise ReviewError("hold must be true or false")
return jsonify(store.set_hold(data["hold"]))
@app.post("/api/shutdown")
def shutdown():
store.set_hold(True)
app.extensions["stop_event"].set()
return jsonify(status="ok")
return app
def _call(port: int, path: str, data: dict | None = None) -> dict:
url = f"http://{PUBLIC_HOST}:{validate_port(port)}{path}"
request_data = None if data is None else json.dumps(data).encode("utf-8")
req = urllib.request.Request(url, data=request_data, headers={"Content-Type": "application/json"})
with urllib.request.build_opener(urllib.request.ProxyHandler({})).open(req, timeout=2) as response:
return json.load(response)
def _running(project: Path) -> dict | None:
lock = read_lock(project / "spec_review" / "lock.json")
if not lock or not process_alive(lock_pid(lock)):
return None
health = _call(lock.get("port"), "/api/health")
if health.get("service") != "spec_review" or health.get("project") != str(project) or (
health.get("pid") != lock_pid(lock)
):
raise ValueError("The lock does not identify this project's spec review service.")
return lock
def _announce(lock: dict, no_browser: bool) -> None:
url = f"http://{PUBLIC_HOST}:{lock['port']}"
print(json.dumps({"service": "spec_review", "url": url, **lock}, separators=(",", ":")))
if not no_browser:
open_preview_browser(url, logger=logger)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("project_path")
actions = parser.add_mutually_exclusive_group()
actions.add_argument("--daemon", action="store_true")
actions.add_argument("--shutdown", action="store_true")
actions.add_argument("--hold", choices=("on", "off"))
parser.add_argument("--no-browser", action="store_true")
parser.add_argument("--port", type=int, help="Bind exactly this port; otherwise scan from 6060")
parser.add_argument("--timeout", type=int, default=7200, help="Idle seconds; 0 disables the timeout")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(levelname)s spec_review: %(message)s")
logging.getLogger("werkzeug").addFilter(_QuietPolls())
project = Path(args.project_path).resolve()
runtime = project / "spec_review"
lock_file = runtime / "lock.json"
try:
if args.port is not None:
validate_port(args.port)
if args.timeout < 0:
raise ValueError("--timeout must be nonnegative")
existing = _running(project)
if args.shutdown or args.hold:
if not existing:
if args.hold:
raise ValueError("Start spec review before setting --hold.")
clear_lock(lock_file)
print('{"status":"stopped"}')
return 0
path = "/api/shutdown" if args.shutdown else "/api/hold"
result = _call(existing["port"], path, {} if args.shutdown else {"hold": args.hold == "on"})
if args.shutdown:
deadline = time.monotonic() + 5
while read_lock(lock_file) == existing and time.monotonic() < deadline:
time.sleep(0.1)
if read_lock(lock_file) == existing:
raise ValueError("Shutdown is still pending; inspect spec_review/server.log.")
print(json.dumps(result, separators=(",", ":")))
return 0
if existing:
if args.port is not None and existing["port"] != args.port:
raise ValueError("A server is already running on a different port; use --shutdown first.")
_announce(existing, args.no_browser)
return 0
if not (project / "design_spec.md").is_file():
raise ValueError("The project must contain design_spec.md.")
runtime.mkdir(parents=True, exist_ok=True)
if args.daemon:
command = [sys.executable, str(Path(__file__).resolve()), str(project),
"--no-browser", "--timeout", str(args.timeout)]
if args.port is not None:
command.extend(["--port", str(args.port)])
with (runtime / "server.log").open("a", encoding="utf-8") as log:
process = popen_detached(command, stdin=subprocess.DEVNULL, stdout=log, stderr=log, logger=logger)
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
try:
existing = _running(project)
except (OSError, ValueError):
existing = None
if existing:
if args.port is not None and existing["port"] != args.port:
raise ValueError("A concurrent launch claimed another port; use --shutdown first.")
_announce(existing, args.no_browser)
return 0
if process.poll() is not None:
break
time.sleep(0.2)
if process.poll() is None:
process.terminate()
raise ValueError("Server failed to start; inspect spec_review/server.log.")
# Serialize startup without changing the shared servers' lock behavior.
with file_mutex(runtime / "service.guard"):
existing = _running(project)
if existing:
if args.port is not None and args.port != existing["port"]:
raise ValueError("A concurrent launch claimed another port; use --shutdown first.")
_announce(existing, args.no_browser)
return 0
app = create_app(str(project), args.timeout)
port = args.port if args.port is not None else find_free_port(DEFAULT_PORT)
httpd = make_server(PUBLIC_HOST, port, app, threaded=True)
# Let in-flight writes finish before server_close releases the slot.
httpd.daemon_threads = False
if claim_lock(lock_file, port):
httpd.server_close()
raise ValueError("Another process claimed the project lock; launch again.")
stop = app.extensions["stop_event"]
def watch():
while not stop.wait(0.25):
with app.extensions["review_store"].mutex:
if args.timeout and time.monotonic() - app.config["LAST_ACTIVITY"] > args.timeout:
logger.info("idle timeout reached")
break
httpd.shutdown()
signal.signal(signal.SIGTERM, lambda *_: stop.set())
threading.Thread(target=watch, daemon=True).start()
try:
_announce({"pid": os.getpid(), "port": port}, args.no_browser)
httpd.serve_forever(poll_interval=0.2)
except KeyboardInterrupt:
stop.set()
finally:
stop.set()
httpd.server_close()
release_lock(lock_file)
return 0
except (OSError, ValueError, RuntimeError) as exc:
logger.error("%s", exc)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,496 @@
'use strict';
const strings = {
en: {
first: 'First', prev: 'Prev', next: 'Next', last: 'Last', source: 'View source', rendered: 'Rendered view',
reload: 'Reload from disk', editor: 'Block Markdown', apply: 'Apply changes', undo: 'Discard draft',
rebase: 'Use current disk as draft base', annotations: 'Annotations', thisBlock: 'This block', global: 'Global',
newAnnotation: 'New annotation', saveAnnotation: 'Save annotation', save: 'Update', remove: 'Delete',
annotationHint: 'Annotations are saved separately from the Design Spec.',
structureHint: 'Keep section headings and Slide levels/numbers. Part blocks allow title text edits only. Request page additions, deletions, or reordering in annotations.',
hold: 'AI is editing — Apply is paused.', changed: 'The spec changed on disk. Reload to read the latest version.',
conflict: 'The spec changed. Drafts are retained; reload and review the current source before choosing a new draft base.',
pending: 'Unapplied draft', staging: 'Staging…', clean: 'No draft', saved: 'Saved', validation: 'Schema diagnostics (saved)',
missing: 'This block was removed externally. Its draft is retained for copying or discarding.',
stale: 'Base differs from current spec', current: 'Base matches current spec', comments: 'Comments', draft: 'Draft',
blockChanged: 'This block changed since the comment was created', blockUnchanged: 'This block is unchanged since the comment was created',
blockUnknown: 'Block baseline unknown', blockMissing: 'This block no longer exists',
exit: 'Exit review', exitConfirm: 'There are unapplied drafts or unsaved comments. Stop review and discard pending work?',
stopped: 'Spec review has stopped. You can close this tab.',
disconnected: 'Cannot reach spec review. Local edits are retained.', rebaseConfirm: 'Stage this text against the current disk version, replacing any current staged draft? Review the source first; Apply will replace this block.',
},
zh: {
first: '首块', prev: '上一块', next: '下一块', last: '末块', source: '查看原文', rendered: '渲染视图',
reload: '重载磁盘原文', editor: '块 Markdown', apply: '应用更改', undo: '撤销草稿', rebase: '以当前磁盘版本为草稿基准',
annotations: '修改意见', thisBlock: '当前块', global: '全局', newAnnotation: '新增意见', saveAnnotation: '保存意见',
save: '更新', remove: '删除', annotationHint: '意见独立保存,不写入 Design Spec。',
structureHint: '保留章节标题及 Slide 层级和编号;Part 块仅可改标题文字。增页、删页、调序请写修改意见。',
hold: 'AI 正在修改,已暂停应用更改。', changed: '磁盘原文已变化,请重载查看最新版本。',
conflict: '原文已变化,草稿已保留;请重载并核对最新原文,再选择新的草稿基准。',
pending: '有未应用草稿', staging: '暂存中…', clean: '无草稿', saved: '已保存', validation: '校验提示(已保存)',
missing: '该块已被外部删除,草稿仍可复制或撤销。',
stale: '基准与当前原文不同', current: '基准与当前原文相同', comments: '意见', draft: '草稿',
blockChanged: '该块自意见创建后已变化', blockUnchanged: '该块自意见创建后未变化',
blockUnknown: '该块的意见基准未知', blockMissing: '该块已不存在',
exit: '退出评审', exitConfirm: '存在未应用草稿或未保存意见。是否停止评审并放弃待保存内容?',
stopped: '评审服务已停止,可以关闭此标签页。',
disconnected: '无法连接评审服务,本地编辑已保留。', rebaseConfirm: '以当前磁盘版本为基准暂存此内容,替换服务端的当前草稿?请先核对最新原文;应用时将替换该块。',
},
'zh-TW': {
first: '首塊', prev: '上一塊', next: '下一塊', last: '末塊', source: '查看原文', rendered: '算繪檢視',
reload: '重新載入磁碟原文', editor: '區塊 Markdown', apply: '套用變更', undo: '撤銷草稿', rebase: '以目前磁碟版本為草稿基準',
annotations: '修改意見', thisBlock: '目前區塊', global: '全域', newAnnotation: '新增意見', saveAnnotation: '儲存意見',
save: '更新', remove: '刪除', annotationHint: '意見獨立儲存,不寫入 Design Spec。',
structureHint: '保留章節標題及 Slide 層級和編號;Part 區塊僅可改標題文字。增頁、刪頁、調序請寫修改意見。',
hold: 'AI 正在修改,已暫停套用變更。', changed: '磁碟原文已變更,請重新載入最新版本。',
conflict: '原文已變更,草稿已保留;請重新載入並核對原文,再選擇新的草稿基準。',
pending: '有未套用草稿', staging: '暫存中…', clean: '無草稿', saved: '已儲存', validation: '驗證提示(已儲存)',
missing: '該區塊已被外部刪除,草稿仍可複製或撤銷。',
stale: '基準與目前原文不同', current: '基準與目前原文相同', comments: '意見', draft: '草稿',
blockChanged: '該區塊自意見建立後已變更', blockUnchanged: '該區塊自意見建立後未變更',
blockUnknown: '該區塊的意見基準未知', blockMissing: '該區塊已不存在',
exit: '退出評審', exitConfirm: '存在未套用草稿或未儲存意見。是否停止評審並放棄待儲存內容?',
stopped: '評審服務已停止,可以關閉此分頁。',
disconnected: '無法連線評審服務,本機編輯已保留。', rebaseConfirm: '以目前磁碟版本為基準暫存此內容,取代伺服器上的目前草稿?請先核對最新原文;套用時將取代該區塊。',
},
ja: {
first: '先頭', prev: '前へ', next: '次へ', last: '末尾', source: '原文を表示', rendered: '表示に戻る',
reload: 'ディスクから再読込', editor: 'ブロック Markdown', apply: '変更を適用', undo: '下書きを破棄',
rebase: '現在の原文を下書きの基準にする', annotations: '修正コメント', thisBlock: '現在のブロック', global: '全体',
newAnnotation: '新しいコメント', saveAnnotation: 'コメントを保存', save: '更新', remove: '削除',
annotationHint: 'コメントは Design Spec と別に保存されます。',
structureHint: 'セクション見出しと Slide の階層・番号は固定です。Part は見出しの文字だけ変更できます。追加・削除・並べ替えはコメントで依頼してください。',
hold: 'AI が編集中です。変更の適用は一時停止中です。', changed: '原文が変更されました。再読込してください。',
conflict: '原文が変更されました。下書きは保持されています。再読込して原文を確認し、新しい基準を選択してください。',
pending: '未適用の下書き', staging: '一時保存中…', clean: '下書きなし', saved: '保存済み', validation: '検証結果(保存済み)',
missing: 'このブロックは外部で削除されました。下書きはコピーまたは破棄できます。',
stale: '基準が現在の原文と異なります', current: '基準は現在の原文と一致', comments: 'コメント', draft: '下書き',
blockChanged: 'コメント作成後にこのブロックは変更されています', blockUnchanged: 'コメント作成後にこのブロックは変更されていません',
blockUnknown: 'ブロックの基準は不明です', blockMissing: 'このブロックは存在しません',
exit: 'レビューを終了', exitConfirm: '未適用の下書きまたは未保存のコメントがあります。レビューを終了して未保存の内容を破棄しますか?',
stopped: 'レビューサービスは停止しました。このタブを閉じてください。',
disconnected: 'サービスに接続できません。編集内容は保持されています。', rebaseConfirm: '現在の原文を基準にこの内容を一時保存し、サーバーの下書きを置き換えますか?原文を先に確認してください。適用でこのブロックを置き換えます。',
},
};
const $ = id => document.getElementById(id);
const node = (tag, text) => {
const result = document.createElement(tag);
if (text !== undefined) result.textContent = text;
return result;
};
const detected = navigator.language.toLowerCase();
let lang = detected.startsWith('zh') ? (/tw|hk|hant/.test(detected) ? 'zh-TW' : 'zh') :
(detected.startsWith('ja') ? 'ja' : 'en');
try { lang = localStorage.getItem('spec-review-language') || lang; } catch (_) { /* Optional browser storage. */ }
if (!strings[lang]) lang = 'en';
const t = key => strings[lang][key];
// User Markdown never crosses an HTML parser boundary. Unsupported constructs
// remain literal text; links and image syntax never create active elements.
function inline(parent, text, depth = 0) {
// Bound regex backtracking even for long runs of unmatched delimiters.
if (depth > 12 || text.length > 4096) { parent.append(document.createTextNode(text)); return; }
const pattern = /(`+)(.+?)\1|\*\*(.+?)\*\*|__(.+?)__|\*([^*]+)\*|_([^_]+)_|(\$[^$\n]+\$|\\\([^\n]*?\\\))/g;
let start = 0;
for (const match of text.matchAll(pattern)) {
parent.append(document.createTextNode(text.slice(start, match.index)));
const tag = match[1] || match[7] ? 'code' : (match[3] || match[4] ? 'strong' : 'em');
const child = node(tag);
const value = match[2] || match[3] || match[4] || match[5] || match[6] || match[7];
if (tag === 'code') child.textContent = value;
else inline(child, value, depth + 1);
parent.append(child);
start = match.index + match[0].length;
}
parent.append(document.createTextNode(text.slice(start)));
}
const listItem = line => /^( *)([-+*]|\d+[.)])\s+(.*)$/.exec(line);
const fenceLine = line => /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
function tableCells(line) {
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split(/(?<!\\)\|/).map(cell => cell.trim());
}
function markdown(text, target, depth = 0) {
if (depth > 12) { target.append(node('pre', text)); return; }
const lines = text.replace(/\r\n?/g, '\n').split('\n');
let i = 0;
function list(indent, nesting = 0) {
if (nesting > 12) {
const body = [];
while (i < lines.length && lines[i].search(/\S/) >= indent) body.push(lines[i++]);
return node('pre', body.join('\n'));
}
const first = listItem(lines[i]);
const ordered = /^\d/.test(first[2]);
const container = node(ordered ? 'ol' : 'ul');
if (ordered) container.start = parseInt(first[2], 10);
while (i < lines.length) {
const item = listItem(lines[i]);
if (!item || item[1].length !== indent || /^\d/.test(item[2]) !== ordered) break;
const li = node('li'); inline(li, item[3]); container.append(li); i++;
while (i < lines.length) {
const next = listItem(lines[i]);
if (next && next[1].length > indent) { li.append(list(next[1].length, nesting + 1)); continue; }
if (!next && lines[i].trim() && lines[i].search(/\S/) > indent) {
li.append(node('pre', lines[i++])); continue;
}
break;
}
}
return container;
}
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) { i++; continue; }
const fence = fenceLine(line);
if (fence && !(fence[1][0] === '`' && fence[2].includes('`'))) {
const body = []; i++;
while (i < lines.length) {
const closing = fenceLine(lines[i]);
if (closing && closing[1][0] === fence[1][0] && closing[1].length >= fence[1].length && !closing[2].trim()) {
i++; break;
}
body.push(lines[i++]);
}
const pre = node('pre'); pre.append(node('code', body.join('\n'))); target.append(pre); continue;
}
if (/^\s*(\$\$|\\\[|\\begin\{)/.test(line)) {
const body = [line]; i++;
const end = line.trim().startsWith('$$') ? '$$' : (line.includes('\\[') ? '\\]' : '\\end{');
if (!line.trim().slice(2).includes(end)) {
while (i < lines.length) { const part = lines[i++]; body.push(part); if (part.includes(end)) break; }
}
target.append(node('pre', body.join('\n'))); continue;
}
const heading = /^ {0,3}(#{1,6})\s+(.*)$/.exec(line);
if (heading) { const h = node('h' + heading[1].length); inline(h, heading[2]); target.append(h); i++; continue; }
if (/^ {0,3}((\*\s*){3,}|(-\s*){3,}|(_\s*){3,})$/.test(line)) { target.append(node('hr')); i++; continue; }
if (/^ {0,3}>/.test(line)) {
const body = [];
while (i < lines.length && /^ {0,3}>/.test(lines[i])) body.push(lines[i++].replace(/^ {0,3}> ?/, ''));
const quote = node('blockquote'); markdown(body.join('\n'), quote, depth + 1); target.append(quote); continue;
}
if (line.includes('|') && i + 1 < lines.length && tableCells(lines[i + 1]).every(cell => /^:?-{3,}:?$/.test(cell))) {
const table = node('table'); const head = node('thead'); const row = node('tr');
tableCells(line).forEach(cell => { const th = node('th'); inline(th, cell); row.append(th); });
head.append(row); table.append(head); const body = node('tbody'); i += 2;
while (i < lines.length && lines[i].trim() && lines[i].includes('|')) {
const tr = node('tr');
tableCells(lines[i++]).forEach(cell => { const td = node('td'); inline(td, cell); tr.append(td); });
body.append(tr);
}
table.append(body); target.append(table); continue;
}
const item = listItem(line);
if (item) { target.append(list(item[1].length)); continue; }
if (/^( {4}|\t)|^\s*(<|!\[|\[\^|\[.+\]:|[|]|:::|\\|={3,}\s*$)/.test(line)) {
target.append(node('pre', line)); i++; continue;
}
const body = [line]; i++;
while (i < lines.length && lines[i].trim() && !listItem(lines[i]) && !fenceLine(lines[i]) &&
!/^\s*(#|>|\||<|!\[|\[|\\|\$\$|:::|[-*_]{3,}|={3,})|^( {4}|\t)/.test(lines[i]) &&
!(lines[i].includes('|') && i + 1 < lines.length &&
tableCells(lines[i + 1]).every(cell => /^:?-{3,}:?$/.test(cell)))) {
body.push(lines[i++]);
}
const paragraph = node('p'); inline(paragraph, body.join('\n')); target.append(paragraph);
}
}
let metadata = {blocks: [], orphan_drafts: [], draft_keys: [], sha256: '', hold: false, hold_revision: 0};
let current = null;
let source = false;
let stageTimer;
let stopped = false;
let applying = false;
let queue = Promise.resolve();
const drafts = new Map();
const annotationBuffers = new Map();
let comments = [];
const pathKey = key => encodeURIComponent(key);
const hasDrafts = () => drafts.size > 0 || metadata.draft_keys.length > 0;
const annotationKey = () => $('scope').value === 'global' ? 'global' : current?.key;
function navigationBlocks() {
const blocks = [...metadata.blocks, ...metadata.orphan_drafts];
for (const [key, draft] of drafts) {
if (!blocks.some(block => block.key === key)) blocks.push({key, title: draft.title || key, kind: 'missing'});
}
return blocks;
}
async function api(path, method = 'GET', data) {
let response;
try {
response = await fetch(path, {method, cache: 'no-store', headers: {'Content-Type': 'application/json'},
body: data === undefined ? undefined : JSON.stringify(data)});
} catch (_) { throw new Error(t('disconnected')); }
const result = await response.json();
if (!response.ok) {
const error = new Error(result.error || String(response.status)); error.status = response.status; throw error;
}
return result;
}
function showError(error) {
$('error').textContent = error.message; $('error').hidden = false;
if (error.status === 409) showConflict(true);
if (error.status === 423) { metadata.hold = true; renderStatus(); }
}
function run(action) {
queue = queue.then(() => { if (!stopped) return action(); }).catch(showError);
return queue;
}
function showConflict(conflict = hasDrafts()) {
$('conflict').textContent = t(conflict ? 'conflict' : 'changed'); $('conflict').hidden = false;
}
function renderStatus() {
$('hold').textContent = t('hold'); $('hold').hidden = !metadata.hold;
const draft = drafts.get(current?.key);
$('draft-status').textContent = t(draft?.dirty ? 'staging' : (draft ? 'pending' : 'clean'));
$('editor').disabled = applying || !current;
$('apply').disabled = applying || !draft || metadata.hold || current?.missing;
$('undo').disabled = applying || !draft;
$('rebase').disabled = applying;
$('rebase').hidden = !draft || current?.missing || (draft.sha256 === current?.sha256 &&
(draft.version || null) === (current?.draft?.version || null));
$('fingerprint').textContent = current?.sha256 ?? metadata.sha256;
}
function renderNavigation() {
$('blocks').replaceChildren();
const blocks = navigationBlocks();
blocks.forEach(block => {
const button = node('button', block.title.replace(/^\s*#+\s*/, ''));
button.className = [block.kind, block.key === current?.key ? 'selected' : ''].join(' ');
if (block.key === current?.key) button.setAttribute('aria-current', 'true');
if (block.has_annotations) button.append(Object.assign(node('span', t('comments')), {className: 'badge'}));
if (block.has_draft || drafts.has(block.key)) button.append(Object.assign(node('span', t('draft')), {className: 'badge'}));
button.addEventListener('click', () => navigate(block.key)); $('blocks').append(button);
});
const index = blocks.findIndex(block => block.key === current?.key);
$('position').textContent = blocks.length ? `${index + 1} / ${blocks.length}` : '0 / 0';
$('first').disabled = $('prev').disabled = index <= 0;
$('next').disabled = $('last').disabled = index >= blocks.length - 1;
}
function renderPreview() {
const text = drafts.get(current?.key)?.text ?? current?.text ?? '';
$('preview').replaceChildren();
try { markdown(text, $('preview')); }
catch (_) { $('preview').replaceChildren(node('pre', text)); }
// Source view always shows the current disk block, so a stale draft can be
// compared with its replacement before explicitly choosing a new base.
$('raw').textContent = current?.text || '';
$('raw').hidden = !source; $('preview').hidden = source;
$('source').textContent = t(source ? 'rendered' : 'source');
$('source').setAttribute('aria-pressed', String(source));
}
async function stage(key, text = drafts.get(key)?.text) {
const draft = drafts.get(key);
if (!draft) return;
if (!draft.dirty && draft.text === text) return {...draft};
const result = await api('/api/drafts/' + pathKey(key), 'PUT', {
text, sha256: draft.sha256, version: draft.version || null,
});
draft.version = result.version;
draft.dirty = draft.text !== text;
if (current?.key === key) current.draft = result;
if (!metadata.draft_keys.includes(key)) metadata.draft_keys.push(key);
renderStatus(); renderNavigation();
return result;
}
async function loadComments() {
comments = (await api('/api/annotations')).annotations; renderComments();
}
function renderComments() {
$('comments').replaceChildren();
for (const item of comments.filter(item => item.key === annotationKey())) {
const card = node('div'); card.className = 'comment';
card.append(node('small', `${item.id} · ${item.title}`));
const status = item.key === 'global' ? (item.base_current ? 'current' : 'stale') :
(item.block_missing ? 'blockMissing' : (item.block_changed === true ? 'blockChanged' :
(item.block_changed === false ? 'blockUnchanged' : 'blockUnknown')));
card.append(node('small', t(status)));
const textarea = node('textarea'); textarea.rows = 4; textarea.value = annotationBuffers.get(item.id) ?? item.body;
textarea.addEventListener('input', () => annotationBuffers.set(item.id, textarea.value)); card.append(textarea);
const buttons = node('div'); buttons.className = 'toolbar';
const save = node('button', t('save')); const remove = node('button', t('remove'));
save.addEventListener('click', () => {
const body = textarea.value;
run(async () => {
const result = await api('/api/annotations/' + item.id, 'PUT', {body, revision: item.revision});
Object.assign(item, result);
if (annotationBuffers.get(item.id) === body) annotationBuffers.delete(item.id);
await refreshMetadata(); await loadComments();
});
});
remove.addEventListener('click', () => run(async () => {
await api('/api/annotations/' + item.id, 'DELETE', {revision: item.revision});
annotationBuffers.delete(item.id); await refreshMetadata(); await loadComments();
}));
buttons.append(save, remove); card.append(buttons); $('comments').append(card);
}
$('annotation').value = annotationBuffers.get('new:' + annotationKey()) || '';
}
async function refreshMetadata() {
metadata = await api('/api/blocks');
if (current && current.sha256 !== metadata.sha256) showConflict();
renderNavigation(); renderStatus();
}
async function select(key) {
if (!key) { current = null; $('editor').disabled = true; renderStatus(); return; }
let block;
try { block = await api('/api/blocks/' + pathKey(key)); }
catch (error) {
if (error.status !== 404 || !drafts.has(key)) throw error;
block = {key, title: drafts.get(key).title || key, text: '', sha256: metadata.sha256, missing: true};
}
current = block;
if (block.draft && !drafts.has(key)) drafts.set(key, {...block.draft, dirty: false});
const draft = drafts.get(key);
if (draft && block.draft && !draft.dirty && draft.version === block.draft.version) {
draft.sha256 = block.draft.sha256;
}
$('title').textContent = block.title;
$('editor').value = draft?.text ?? block.text; $('editor').disabled = false; $('editor').readOnly = !!block.missing;
renderPreview(); renderNavigation(); renderStatus(); renderComments();
if (draft && draft.sha256 !== block.sha256) showConflict(true);
if (block.missing) { $('conflict').textContent = t('missing'); $('conflict').hidden = false; }
}
function navigate(key) {
run(async () => {
clearTimeout(stageTimer);
if (current) await stage(current.key).catch(showError);
await select(key);
});
}
async function reload() {
const key = current?.key;
await refreshMetadata();
$('conflict').hidden = true;
const blocks = navigationBlocks();
await select(blocks.some(block => block.key === key) ? key : blocks[0]?.key);
await loadComments();
if ([...drafts.values()].some(draft => draft.sha256 !== metadata.sha256)) showConflict(true);
}
function localize() {
document.documentElement.lang = lang; $('language').value = lang;
document.querySelectorAll('[data-label]').forEach(element => { element.textContent = t(element.dataset.label); });
if (stopped) { $('stopped').textContent = t('stopped'); return; }
renderStatus(); renderNavigation(); renderPreview(); renderComments();
if (!$('conflict').hidden) showConflict();
}
$('language').addEventListener('change', () => {
lang = $('language').value;
try { localStorage.setItem('spec-review-language', lang); } catch (_) { /* Optional browser storage. */ }
localize();
});
$('source').addEventListener('click', () => { source = !source; renderPreview(); });
$('reload').addEventListener('click', () => run(reload));
$('exit').addEventListener('click', () => run(async () => {
await refreshMetadata();
if ((hasDrafts() || [...annotationBuffers.values()].some(Boolean)) && !window.confirm(t('exitConfirm'))) return;
clearTimeout(stageTimer);
await api('/api/shutdown', 'POST', {});
stopped = true; clearInterval(pollTimer);
document.querySelector('.workspace').hidden = true;
for (const id of ['hold', 'conflict', 'error', 'exit']) $(id).hidden = true;
$('fingerprint').textContent = '';
$('stopped').textContent = t('stopped'); $('stopped').hidden = false;
}));
$('scope').addEventListener('change', renderComments);
$('annotation').addEventListener('input', () => annotationBuffers.set('new:' + annotationKey(), $('annotation').value));
$('editor').addEventListener('input', () => {
if (!current) return;
const key = current.key;
const draft = drafts.get(key) || {sha256: current.sha256, version: null, title: current.title};
Object.assign(draft, {text: $('editor').value, dirty: true}); drafts.set(key, draft);
renderPreview(); renderStatus(); renderNavigation();
clearTimeout(stageTimer); stageTimer = setTimeout(() => run(() => stage(key)), 350);
});
$('apply').addEventListener('click', () => {
const key = current?.key; const draft = drafts.get(key);
if (!draft || applying) return;
const snapshot = {...draft};
applying = true; clearTimeout(stageTimer); renderStatus();
run(async () => {
try {
if (drafts.get(key) !== draft) { showConflict(true); return; }
// The queue awaits earlier stages; stage this click's text with their returned version.
const staged = await stage(key, snapshot.text); const base = staged.sha256;
const result = await api('/api/apply/' + pathKey(key), 'POST', {sha256: base, version: staged.version});
if (draft.text === snapshot.text) drafts.delete(key);
else Object.assign(draft, {version: null, dirty: true});
for (const other of drafts.values()) if (other.sha256 === base) other.sha256 = result.sha256;
$('validation-errors').textContent = result.validation_errors.join('\n');
$('validation').hidden = result.validation_errors.length === 0;
$('error').hidden = true; await reload();
if (current?.key === key && !drafts.has(key)) $('draft-status').textContent = t('saved');
} finally { applying = false; renderStatus(); }
});
});
$('undo').addEventListener('click', () => run(async () => {
const key = current.key; const draft = drafts.get(key);
const latest = current.missing && !draft?.version ? current : await api('/api/blocks/' + pathKey(key));
if (draft?.version && draft.version === latest.draft?.version) {
await api('/api/drafts/' + pathKey(key), 'DELETE', {version: draft.version});
}
drafts.delete(key); $('error').hidden = true; await reload();
}));
$('rebase').addEventListener('click', () => run(async () => {
if (!window.confirm(t('rebaseConfirm'))) return;
const draft = drafts.get(current.key);
const latest = await api('/api/blocks/' + pathKey(current.key));
if (latest.sha256 !== current.sha256 || (latest.draft?.version || null) !== (current.draft?.version || null)) {
await reload(); showConflict(true); return;
}
const text = draft.text;
const result = await api('/api/drafts/' + pathKey(current.key), 'PUT', {
text, sha256: latest.sha256, version: latest.draft?.version || null,
});
Object.assign(draft, {sha256: result.sha256, version: result.version, dirty: draft.text !== text});
$('error').hidden = true; await reload();
}));
$('add').addEventListener('click', () => {
const key = annotationKey(); const body = annotationBuffers.get('new:' + key) || '';
run(async () => {
await api('/api/annotations', 'POST', {key, body});
if (annotationBuffers.get('new:' + key) === body) annotationBuffers.delete('new:' + key);
await refreshMetadata(); await loadComments();
});
});
function step(direction) {
const blocks = navigationBlocks();
const index = blocks.findIndex(block => block.key === current?.key);
const next = direction === 'first' ? 0 : (direction === 'last' ? blocks.length - 1 : index + direction);
if (blocks[next]) navigate(blocks[next].key);
}
for (const [id, direction] of [['first', 'first'], ['prev', -1], ['next', 1], ['last', 'last']]) {
$(id).addEventListener('click', () => step(direction));
}
document.addEventListener('keydown', event => {
if (stopped) return;
if (event.target.closest('input, textarea, select, [contenteditable]') || event.ctrlKey || event.metaKey || event.altKey) return;
const action = {ArrowLeft: -1, ArrowRight: 1, Home: 'first', End: 'last'}[event.key];
if (action !== undefined) { event.preventDefault(); step(action); }
});
window.addEventListener('beforeunload', event => {
if (!stopped && (hasDrafts() || [...annotationBuffers.values()].some(Boolean))) {
event.preventDefault(); event.returnValue = '';
}
});
let polling = false;
const pollTimer = setInterval(async () => {
if (polling || stopped) return;
polling = true;
try {
const state = await api('/api/state');
if (stopped) return;
const released = !state.hold && state.hold_revision !== metadata.hold_revision;
metadata.hold = state.hold; metadata.hold_revision = state.hold_revision;
renderStatus();
if (released) await run(reload);
else if (state.sha256 !== (current?.sha256 ?? metadata.sha256)) showConflict();
} catch (error) { if (!stopped) showError(error); }
finally { polling = false; }
}, 2000);
localize(); run(reload);
@@ -0,0 +1,60 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PPT Master · Spec Review</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/app.js" defer></script>
</head>
<body>
<header><strong>PPT Master · Spec Review</strong><span id="fingerprint"></span>
<select id="language" aria-label="Language">
<option value="zh">中文</option><option value="zh-TW">繁體中文</option>
<option value="en">English</option><option value="ja">日本語</option>
</select>
<button id="exit" data-label="exit"></button>
</header>
<p id="stopped" class="banner" role="status" hidden></p>
<div id="hold" class="banner" role="status" hidden></div>
<div id="conflict" class="banner" role="status" hidden></div>
<div id="error" class="banner error" role="alert" hidden></div>
<div class="workspace">
<aside class="navigation">
<div class="navigation-buttons">
<button id="first" data-label="first"></button><button id="prev" data-label="prev"></button>
<button id="next" data-label="next"></button><button id="last" data-label="last"></button>
</div>
<div id="position"></div><nav id="blocks" aria-label="Spec blocks"></nav>
</aside>
<main>
<div class="toolbar"><h1 id="title"></h1>
<button id="source" data-label="source" aria-pressed="false"></button>
<button id="reload" data-label="reload"></button>
</div>
<article id="preview" class="markdown"></article>
<pre id="raw" hidden></pre>
<section class="editor">
<label for="editor" data-label="editor"></label>
<p class="hint" data-label="structureHint"></p>
<textarea id="editor" spellcheck="false"></textarea>
<div class="toolbar"><span id="draft-status" role="status"></span>
<button id="undo" data-label="undo"></button><button id="rebase" data-label="rebase" hidden></button>
<button id="apply" class="primary" data-label="apply"></button>
</div>
<div id="validation" hidden><h2 data-label="validation"></h2><pre id="validation-errors"></pre></div>
</section>
</main>
<aside class="annotations">
<h2 data-label="annotations"></h2>
<select id="scope" aria-label="Annotation scope"><option value="block" data-label="thisBlock"></option>
<option value="global" data-label="global"></option></select>
<div id="comments"></div>
<label for="annotation" data-label="newAnnotation"></label>
<textarea id="annotation" rows="6"></textarea>
<button id="add" class="primary" data-label="saveAnnotation"></button>
<p class="hint" data-label="annotationHint"></p>
</aside>
</div>
</body>
</html>
@@ -0,0 +1,74 @@
:root {
color-scheme: dark;
--bg: #12121f;
--panel: #1a1a2e;
--line: #34344c;
--text: #e4e5ef;
--muted: #a4a7bf;
--accent: #4a9eff;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.6 system-ui, sans-serif; }
header { padding: 12px 20px; border-bottom: 1px solid var(--line); display: flex; gap: 20px; align-items: center; }
header strong { color: var(--accent); }
#fingerprint { flex: 1; color: var(--muted); font-family: monospace; overflow-wrap: anywhere; }
button, select, textarea { font: inherit; color: inherit; background: #23233b; border: 1px solid var(--line); border-radius: 5px; }
button { cursor: pointer; padding: 6px 10px; }
button:hover { border-color: var(--accent); }
button:disabled { opacity: .45; cursor: default; }
button.primary { color: #07192f; background: var(--accent); font-weight: 600; }
select { padding: 6px; }
textarea { width: 100%; resize: vertical; padding: 12px; }
button:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.workspace { display: grid; grid-template-columns: 245px minmax(320px, 1fr) 300px; height: calc(100vh - 60px); }
aside, main { overflow: auto; min-width: 0; padding: 16px; }
aside { background: var(--panel); }
.navigation { border-right: 1px solid var(--line); }
.annotations { border-left: 1px solid var(--line); }
.navigation-buttons { display: flex; flex-wrap: wrap; gap: 4px; }
#position { color: var(--muted); padding: 10px 0; }
#blocks { display: flex; flex-direction: column; gap: 4px; }
#blocks button { text-align: left; border-color: transparent; background: transparent; overflow-wrap: anywhere; }
#blocks button.slide { padding-left: 22px; }
#blocks button.part { margin-top: 12px; border-top: 1px solid var(--line); border-radius: 0; font-weight: 700; color: var(--accent); }
#blocks button.selected { background: #273552; border-color: var(--accent); }
.badge { color: #eabd64; margin-left: 7px; font-size: 11px; }
.toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.toolbar h1 { font-size: 19px; flex: 1 1 100%; margin: 0 0 8px; }
.toolbar span { flex: 1; }
.hint { color: var(--muted); font-size: 12px; }
.banner { padding: 8px 20px; background: #4b3a20; white-space: pre-wrap; }
.banner.error { background: #552933; }
.editor { margin-top: 24px; border-top: 1px solid var(--line); padding-top: 18px; }
#editor { min-height: 300px; font: 13px/1.6 ui-monospace, monospace; tab-size: 4; }
#draft-status { color: #eabd64; }
#validation { color: #edbd6c; }
.annotations h2 { margin-top: 0; font-size: 17px; }
.annotations label { display: block; margin-top: 18px; }
.comment { padding: 14px 0; border-bottom: 1px solid var(--line); }
.comment small { display: block; color: var(--muted); overflow-wrap: anywhere; }
.comment .toolbar { margin-top: 6px; }
.markdown { margin-top: 24px; overflow-wrap: anywhere; }
.markdown h1, .markdown h2, .markdown h3, .markdown h4 { line-height: 1.35; }
.markdown h2 { border-bottom: 1px solid var(--line); padding-bottom: 8px; font-size: 22px; }
.markdown h4 { color: #b8d7ff; font-size: 20px; }
.markdown li { margin: 5px 0; }
.markdown blockquote { border-left: 3px solid var(--accent); margin-left: 0; padding: 1px 16px; color: var(--muted); }
.markdown table { border-collapse: collapse; width: 100%; font-size: 13px; margin: 18px 0; }
.markdown th, .markdown td { border: 1px solid var(--line); padding: 7px 10px; text-align: left; }
.markdown th { background: var(--panel); }
pre, code { font-family: ui-monospace, monospace; background: #0e101c; }
pre { padding: 14px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; tab-size: 4; }
code { padding: 2px 4px; border-radius: 3px; }
pre code { padding: 0; }
hr { border: 0; border-top: 1px solid var(--line); margin: 24px 0; }
@media (max-width: 1000px) {
.workspace { grid-template-columns: 190px minmax(300px, 1fr); height: auto; }
.annotations { grid-column: 1 / -1; border-top: 1px solid var(--line); }
}
@media (max-width: 600px) {
header { flex-wrap: wrap; }
.workspace { display: block; }
.navigation { max-height: 240px; }
}
@@ -0,0 +1,352 @@
#!/usr/bin/env python3
"""
PPT Master - Spec Review Storage
Serialize annotation transactions and stage lossless edits of one Design Spec.
See scripts/docs/spec_review.md for the sidecars and HTTP contract.
Usage:
Import ReviewStore from this module.
Examples:
store = ReviewStore(project_path)
Dependencies:
None (only uses the standard library and local project modules)
"""
from __future__ import annotations
import hashlib
import json
import os
import stat
import tempfile
import threading
import uuid
from contextlib import contextmanager
from dataclasses import asdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterator
from project_management.project_specs import SCHEMA_DIR, validate_markdown_text
from project_management.spec_blocks import replace_spec_block, split_spec_blocks
class ReviewError(ValueError):
"""Report an actionable HTTP/CLI conflict without losing pending work."""
def __init__(self, message: str, status: int = 400):
super().__init__(message)
self.status = status
def sha256(data: bytes) -> str:
"""Hash exactly the bytes on disk."""
return hashlib.sha256(data).hexdigest()
def timestamp() -> str:
"""Return an unambiguous UTC event time."""
return datetime.now(timezone.utc).isoformat()
def _annotation_fingerprint(item: dict) -> str:
return sha256(json.dumps(item, sort_keys=True, ensure_ascii=False).encode("utf-8"))
@contextmanager
def file_mutex(path: Path) -> Iterator[None]:
"""Hold a cross-process lock, released by the OS even after a crash."""
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a+b") as handle:
if os.name == "nt":
import msvcrt
if handle.tell() == 0:
handle.write(b"\0")
handle.flush()
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
try:
yield
finally:
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
def atomic_write(path: Path, data: bytes) -> None:
"""Flush a sibling temporary file and atomically replace its destination."""
descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
temporary = Path(name)
try:
with os.fdopen(descriptor, "wb") as handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
if path.exists():
temporary.chmod(stat.S_IMODE(path.stat().st_mode))
os.replace(temporary, path)
finally:
temporary.unlink(missing_ok=True)
class ReviewStore:
"""Keep drafts in memory and comments in one process-safe sidecar."""
def __init__(self, project: Path):
self.project = project.resolve()
self.path = self.project / "design_spec.md"
self.runtime = self.project / "spec_review"
self.mutex = threading.RLock()
self.drafts: dict[str, dict] = {}
self.hold = False
self.hold_revision = 0
def _read(self) -> tuple[bytes, str, str]:
raw = self.path.read_bytes()
return raw, raw.decode("utf-8"), sha256(raw)
def _append(self, filename: str, record: dict) -> None:
self.runtime.mkdir(parents=True, exist_ok=True)
with (self.runtime / filename).open("a", encoding="utf-8", newline="\n") as handle:
handle.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
handle.flush()
os.fsync(handle.fileno())
@contextmanager
def _annotations(self) -> Iterator[dict]:
with self.mutex, file_mutex(self.runtime / "storage.guard"):
path = self.runtime / "annotations.json"
data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {"items": [], "listed": {}}
if not isinstance(data, dict) or not isinstance(data.get("items"), list) or not isinstance(
data.get("listed"), dict
):
raise ValueError("Invalid annotations.json; repair the sidecar before continuing.")
yield data
def _save_annotations(self, data: dict) -> None:
atomic_write(
self.runtime / "annotations.json",
(json.dumps(data, ensure_ascii=False, indent=2) + "\n").encode("utf-8"),
)
def state(self) -> dict:
"""Return the current disk hash and handoff state."""
with self.mutex:
_, _, digest = self._read()
return {"sha256": digest, "hold": self.hold, "hold_revision": self.hold_revision}
def snapshot(self) -> dict:
"""Return ordered block metadata and per-block pending status."""
with self.mutex, self._annotations() as data:
_, text, digest = self._read()
pending = {item["key"] for item in data["items"]}
blocks = [
{**asdict(block), "has_annotations": block.key in pending, "has_draft": block.key in self.drafts}
for block in split_spec_blocks(text).blocks
]
live_keys = {block["key"] for block in blocks}
orphans = [
{"key": key, "title": draft["title"], "kind": "missing", "has_draft": True,
"has_annotations": key in pending}
for key, draft in self.drafts.items() if key not in live_keys
]
return {
"sha256": digest, "hold": self.hold, "hold_revision": self.hold_revision,
"blocks": blocks, "global_annotations": "global" in pending,
"draft_keys": list(self.drafts),
"orphan_drafts": orphans,
}
def document(self) -> dict:
"""Read the full raw spec without universal-newline translation."""
with self.mutex:
_, text, digest = self._read()
return {"text": text, "sha256": digest}
def block(self, key: str) -> dict:
"""Return original block text and its separate in-memory draft."""
with self.mutex:
_, text, digest = self._read()
block = next((block for block in split_spec_blocks(text).blocks if block.key == key), None)
if block is None:
if key in self.drafts:
draft = self.drafts[key]
return {"key": key, "title": draft["title"], "text": "", "sha256": digest,
"draft": draft, "missing": True}
raise ReviewError("Block no longer exists; reload the spec.", 404)
return {**asdict(block), "text": text[block.start:block.end], "sha256": digest,
"draft": self.drafts.get(key)}
def stage(self, key: str, text: str, base: str, version: str | None) -> dict:
"""Stage text without touching the spec; preserve stale drafts for review."""
with self.mutex:
block = self.block(key)
current = self.drafts.get(key)
if (current or {}).get("version") != version:
raise ReviewError("Another tab changed this draft; reload before editing it.", 409)
if base != block["sha256"] and (not current or current["sha256"] != base):
raise ReviewError("The spec changed on disk; reload. Your local draft is retained.", 409)
draft = {"text": text, "sha256": base, "version": uuid.uuid4().hex, "title": block["title"]}
self.drafts[key] = draft
return draft
def discard(self, key: str, version: str | None) -> None:
"""Discard only the draft version the browser actually saw."""
with self.mutex:
if (self.drafts.get(key) or {}).get("version") != version:
raise ReviewError("Another tab changed this draft; reload before discarding it.", 409)
self.drafts.pop(key, None)
def set_hold(self, enabled: bool) -> dict:
"""Wait for any active Apply, then switch the write handoff state."""
with self.mutex:
if self.hold != enabled:
self.hold_revision += 1
self.hold = enabled
return self.state()
def apply(self, key: str, base: str, version: str) -> dict:
"""Compare, validate, and atomically replace one byte range under a lock."""
with self.mutex, file_mutex(self.runtime / "storage.guard"):
if self.hold:
raise ReviewError("AI is editing; Apply is paused until hold is off.", 423)
raw, text, digest = self._read()
draft = self.drafts.get(key)
if base != digest or (draft and draft["sha256"] != digest):
raise ReviewError("The spec changed on disk; reload. The draft is retained.", 409)
if not draft or draft["version"] != version:
raise ReviewError("The staged draft changed; reload before applying it.", 409)
try:
candidate = replace_spec_block(text, key, draft["text"])
except ValueError as exc:
raise ReviewError(str(exc)) from exc
blocks = split_spec_blocks(text).blocks
before = next(block for block in blocks if block.key == key)
after = next(block for block in split_spec_blocks(candidate).blocks if block.key == key)
new_text = candidate[after.start:after.end]
payload = raw[:before.byte_start] + new_text.encode("utf-8") + raw[before.byte_end:]
errors = validate_markdown_text(candidate, SCHEMA_DIR / "design_spec.schema.json", markdown_path=self.path)
if sha256(self.path.read_bytes()) != digest:
raise ReviewError("The spec changed during validation; reload. The draft is retained.", 409)
new_digest = sha256(payload)
if payload != raw:
atomic_write(self.path, payload)
self._append("edits.jsonl", {
"ts": timestamp(), "key": key, "title": after.title,
"before": text[before.start:before.end], "after": new_text,
"before_sha256": digest, "after_sha256": new_digest,
})
del self.drafts[key]
# Other blocks were byte-identical; our own successful edit does not
# invalidate their drafts. External edits never take this path.
for remaining in self.drafts.values():
if remaining["sha256"] == digest:
remaining["sha256"] = new_digest
return {"sha256": new_digest, "validation_errors": errors, "saved": True}
def annotations(self) -> list[dict]:
"""Read comments without acknowledging them on behalf of an agent."""
with self._annotations() as data:
_, text, digest = self._read()
return self._current_annotations(data, text, digest)
def _current_annotations(self, data: dict, text: str, digest: str) -> list[dict]:
block_hashes = {
block.key: sha256(text[block.start:block.end].encode("utf-8"))
for block in split_spec_blocks(text).blocks
}
items = []
for item in data["items"]:
if item["key"] == "global":
status = {"base_current": item["spec_sha256"] == digest}
else:
current = block_hashes.get(item["key"])
base = item.get("block_sha256")
status = {"block_missing": current is None,
"block_changed": current != base if current is not None and base else None}
items.append({**item, **status})
return items
def save_annotation(self, key: str, body: str, item_id: str | None = None, revision: str | None = None) -> dict:
"""Create or update a comment, keeping its creation-time fingerprints."""
if not body.strip():
raise ReviewError("Enter an annotation before saving.")
with self._annotations() as data:
if item_id:
item = next((item for item in data["items"] if item["id"] == item_id), None)
if item is None or item["revision"] != revision:
raise ReviewError("The annotation changed or was removed; reload it.", 409)
event = "updated"
else:
block = self.block(key) if key != "global" else {"title": "Global", **self.document()}
item = {"id": uuid.uuid4().hex, "key": key, "title": block["title"],
"spec_sha256": block["sha256"], "created_at": timestamp()}
if key != "global" and not block.get("missing"):
item["block_sha256"] = sha256(block["text"].encode("utf-8"))
data["items"].append(item)
event = "saved"
item.update(body=body, updated_at=timestamp(), revision=uuid.uuid4().hex)
self._save_annotations(data)
self._append("annotations.jsonl", {"event": event, "ts": timestamp(), **item})
return item
def remove_annotation(self, item_id: str, revision: str | None = None, *, applied: bool = False) -> None:
"""Clear a comment only if its browser/listed version still matches."""
with self._annotations() as data:
item = next((item for item in data["items"] if item["id"] == item_id), None)
expected = data["listed"].get(item_id) if applied else revision
actual = _annotation_fingerprint(item) if applied and item is not None else (item or {}).get("revision")
if item is None or actual != expected:
raise ReviewError(
"Annotation changed since it was listed, or was not listed; retained. List again.", 409,
)
data["items"].remove(item)
data["listed"].pop(item_id, None)
self._save_annotations(data)
self._append("annotations.jsonl", {
"event": "annotation_applied" if applied else "removed", "ts": timestamp(), **item,
})
def list_todo(self) -> dict:
"""Record listed comment revisions and the last unread edit's byte offset."""
with self._annotations() as data:
_, text, digest = self._read()
fields = ("id", "key", "title", "body", "spec_sha256", "block_sha256",
"base_current", "block_changed", "block_missing")
items = [{key: item[key] for key in fields if key in item}
for item in self._current_annotations(data, text, digest)]
edits_path = self.runtime / "edits.jsonl"
edits = []
cursor = data.get("edits_cursor", 0)
if edits_path.exists():
with edits_path.open("rb") as handle:
handle.seek(cursor)
for line in handle:
event = json.loads(line)
edits.append({**{key: event[key] for key in ("ts", "key", "title")},
**{key: event[key][:12] for key in ("before_sha256", "after_sha256")}})
cursor = handle.tell()
data["listed"] = {item["id"]: _annotation_fingerprint(item) for item in data["items"]}
data["listed_edits_cursor"] = cursor
self._save_annotations(data)
return {"sha256": digest, "annotations": items, "edits": edits}
def ack_edits(self) -> int:
"""Consume only edits included in the last CLI listing, preserving the log."""
with self._annotations() as data:
if "listed_edits_cursor" not in data:
raise ReviewError("List direct edits before acknowledging them.", 409)
data["edits_cursor"] = data["listed_edits_cursor"]
self._save_annotations(data)
return data["edits_cursor"]
@@ -51,12 +51,14 @@ try:
from project_utils import (
CANVAS_FORMATS,
validate_communication_trace,
validate_outline_hyperlinks,
validate_outline_roster,
)
except ImportError:
print("Warning: Unable to import project_utils")
CANVAS_FORMATS = {}
validate_communication_trace = None
validate_outline_hyperlinks = None
validate_outline_roster = None
from svg_to_pptx.canvas_contract import (
@@ -76,6 +78,7 @@ except ImportError:
try:
from svg_to_pptx.animation_config import (
effective_top_level as _effective_top_level,
load_animation_config as _load_animation_config,
usable_animation_group_id as _usable_animation_group_id,
validate_animation_config as _validate_animation_config,
@@ -83,6 +86,7 @@ try:
validate_transition_config as _validate_transition_config,
)
except ImportError as exc:
_effective_top_level = None
_load_animation_config = None
_validate_animation_config = None
_validate_animation_config_errors = None
@@ -1209,6 +1213,7 @@ class SVGQualityChecker:
# severity is 'error' or 'warning'. Printed in print_summary.
self._template_issues: List[Tuple[str, str, str]] = []
self._spec_only_template_kind: str | None = None
self._template_roster_pages = 0
self._animation_issues: List[Tuple[str, str]] = []
self._illustration_issues: List[Tuple[str, str, str]] = []
self._communication_trace_issues: List[Tuple[str, str]] = []
@@ -1720,6 +1725,7 @@ class SVGQualityChecker:
result,
included_text_ids,
unchanged_text_ids,
baseline_root=baseline_root,
)
self._check_roundtrip_unsupported_table_edits(
root,
@@ -1775,9 +1781,31 @@ class SVGQualityChecker:
) is not None:
continue
try:
semantic_shape_text_component(shape, included_text_ids=included_text_ids)
component = semantic_shape_text_component(
shape, included_text_ids=included_text_ids,
)
except ValueError as exc:
result['errors'].append(f"{_element_label(shape)}: {exc}")
continue
if (
component is None
or _classify_paragraph_block is None
or not any(
_local_name(child) == 'tspan'
and any(child.get(name) for name in ('x', 'y', 'dy'))
for child in component
)
or _classify_paragraph_block(
component, preserve_line_breaks=True,
) is not None
):
continue
result['errors'].append(
f"{_element_label(shape)}: multi-line semantic shape text must "
"stay one paragraph block, which export otherwise splits into "
"several text components; write the first line on <text x y> "
"and each later line as <tspan x dy>, never an absolute tspan y"
)
@staticmethod
def _roundtrip_text_diff_ids(
@@ -2140,6 +2168,7 @@ class SVGQualityChecker:
result: Dict,
included_text_ids: set[int],
unchanged_text_ids: set[int],
baseline_root: ET.Element | None = None,
) -> None:
"""Calibrate source text widths, then check edited owning frames."""
helpers = (
@@ -2217,6 +2246,7 @@ class SVGQualityChecker:
max(positive_overflow_ratios, default=0.0),
_ROUNDTRIP_TEXT_CALIBRATION_CAP,
)
source_slot_overflow = self._roundtrip_source_slot_overflow(baseline_root)
result['info']['roundtrip_text_calibration'] = {
'factor': calibration,
'measured_unchanged': measured_unchanged,
@@ -2276,6 +2306,12 @@ class SVGQualityChecker:
)
if corrected_horizontal_ratio <= 0:
continue
# The source text of this same slot already reached this far.
if horizontal_ratio <= source_slot_overflow.get(
self._roundtrip_frame_source_ref(text_element, parent_by_id),
0.0,
):
continue
left, top, right, bottom = estimated
frame_left, frame_top, frame_right, frame_bottom = frame
overflow_detail = (
@@ -2302,6 +2338,68 @@ class SVGQualityChecker:
else:
result['errors'].append(finding)
@staticmethod
def _roundtrip_frame_source_ref(
text_element: ET.Element,
parent_by_id: Dict[int, ET.Element],
) -> str | None:
"""Return the source ref of the object whose frame owns one text."""
current: ET.Element | None = text_element
while current is not None:
if current.get('data-pptx-frame') is not None:
return current.get('data-pptx-source-ref')
current = parent_by_id.get(id(current))
return None
def _roundtrip_source_slot_overflow(
self,
baseline_root: ET.Element | None,
) -> Dict[str | None, float]:
"""Measure how far each imported slot's own source text overflows it."""
if baseline_root is None:
return {}
try:
font_sizes = _resolve_project_font_sizes(baseline_root)
letter_spacings = _resolve_project_letter_spacings(
baseline_root,
font_sizes,
)
except ValueError:
return {}
parent_by_id = {
id(child): parent
for parent in baseline_root.iter()
for child in list(parent)
}
overflow: Dict[str | None, float] = {}
for text_element in baseline_root.iter(f'{{{SVG_NS}}}text'):
source_ref = self._roundtrip_frame_source_ref(
text_element,
parent_by_id,
)
if source_ref is None:
continue
estimated = self._estimated_text_bounds(
text_element,
parent_by_id,
font_sizes,
letter_spacings,
include_headroom=True,
)
_label, frame, _error, _inferred = self._roundtrip_text_frame(
text_element,
parent_by_id,
)
if estimated is None or frame is None:
continue
metrics = self._bounds_overflow_metrics(estimated, frame)
if metrics is not None and metrics[1] > 0:
overflow[source_ref] = max(
overflow.get(source_ref, 0.0),
metrics[1],
)
return overflow
@classmethod
def _roundtrip_text_frame(
cls,
@@ -4863,7 +4961,12 @@ class SVGQualityChecker:
),
outer=boundary,
repair=(
'expand the root module bounds into available '
# A Layout slot's bounds are its contract on every page.
'reflow the text, or map the page to a Layout whose '
'slot is larger; slot bounds stay as the prototype '
'declares them'
if module.get('data-pptx-placeholder') is not None
else 'expand the root module bounds into available '
'non-overlapping space; otherwise reflow the text'
),
width_diagnostic=self._text_width_diagnostic(
@@ -5657,6 +5760,8 @@ class SVGQualityChecker:
visual_index = 0
for child in root:
if _effective_top_level is not None:
child = _effective_top_level(child)
tag = _local_name(child)
if tag in non_visual:
continue
@@ -5759,6 +5864,8 @@ class SVGQualityChecker:
signatures: List[Tuple[object, ...]] = []
visual_index = 0
for child in root:
if _effective_top_level is not None:
child = _effective_top_level(child)
tag = _local_name(child)
if tag in non_visual:
continue
@@ -6953,19 +7060,27 @@ class SVGQualityChecker:
continue
license_name = str(item.get('license_name') or '').upper()
# Only a Creative Commons licence has a token the credit must
# repeat; a publisher's own source-credit terms bind the credit to
# the author/source name alone.
if 'CC' in license_name or 'CREATIVE COMMONS' in license_name:
license_token = 'CC BY-SA' if 'BY-SA' in license_name else 'CC BY'
else:
license_token = None
author = str(item.get('author') or '').strip()
has_credit = bool(author) and any(
author.casefold() in block.casefold()
and license_token in block.upper()
and (license_token is None or license_token in block.upper())
for block in credit_blocks
)
if not has_credit:
expected = f"{author or 'unknown author'}"
if license_token:
expected += f"; {license_token}"
result['errors'].append(
f"Missing image-specific inline attribution for sourced "
f"image {filename} ({author or 'unknown author'}; "
f"{license_token}). Add compact author + license credit per "
f"references/image-searcher.md §7."
f"image {filename} ({expected}). Add compact author + "
f"license credit per references/image-searcher.md §7."
)
@classmethod
@@ -7396,6 +7511,11 @@ class SVGQualityChecker:
('error', message)
for message in validate_outline_roster(project_path)
)
if not self.partial_roster and validate_outline_hyperlinks is not None:
self._communication_trace_issues.extend(
('error', message)
for message in validate_outline_hyperlinks(project_path)
)
return self.results
def _check_pptx_structure_contract(
@@ -9168,6 +9288,7 @@ class SVGQualityChecker:
custom_contract = self._extract_frontmatter_placeholders(spec_text) if spec_text else {}
on_disk = {p.stem for p in svg_files}
self._template_roster_pages = len(spec_pages)
if spec_pages:
spec_set = set(spec_pages)
@@ -9807,6 +9928,11 @@ class SVGQualityChecker:
pretty_kind = self._spec_only_template_kind.title()
print(f" {pretty_kind} design_spec.md contract passed.")
if not errors:
if self._template_roster_pages:
print(
f" Roster contract passed "
f"({self._template_roster_pages} declared page(s) matched)."
)
if self._spec_only_template_kind is None:
print(" No structural roster issues.")
print(" Conventional placeholder-name hints may be declared through "
@@ -310,8 +310,11 @@ def main() -> None:
print(
"[TIP] Structured "
+ "/".join(checker._structured_native_slots)
+ " placeholder slot(s) are filled by native objects: export "
"with --native-charts-and-tables (the standard export refuses them)."
+ " placeholder slot(s) are filled by native objects: this deck "
"exports only with --native-charts-and-tables and the standard "
"export fails, and the object inside the slot is no top-level animation "
"anchor; for both forms author it as a Slide-local marker group "
"beside the slots instead."
)
if checker._has_incomplete_page_roster:
print(
@@ -34,7 +34,7 @@ from pptx_transitions import (
)
from slide_roster import discover_slide_svgs
from .drawingml.utils import SVG_NS
from .drawingml.utils import SVG_NS, XLINK_NS
from .pptx_package.narration import AUDIO_CONTENT_TYPES
from .semantic_markers import is_static_page_frame
@@ -201,6 +201,7 @@ def scan_root_primitives(svg_path: Path) -> dict[str, str]:
root = ET.parse(str(svg_path)).getroot()
primitives: dict[str, str] = {}
for child in root:
child = effective_top_level(child)
tag = _tag_name(child)
if tag in _NON_VISUAL_TAGS or tag == 'g':
continue
@@ -215,6 +216,39 @@ def scan_root_primitives(svg_path: Path) -> dict[str, str]:
return primitives
_ANCHOR_OWN_ATTRIBUTES = frozenset((
'href',
f'{{{XLINK_NS}}}href',
'xlink:href',
))
def anchor_wrapped_group(elem: ET.Element) -> ET.Element | None:
"""Return the single ``<g>`` a bare hyperlink anchor wraps, else ``None``.
``<a href><g id>…</g></a>`` is the canonical whole-object link
(native-hyperlinks.md §2). The anchor carries only its target, so the
group inside is the page's real top-level unit: it stays the animation
anchor and the checker's grouping unit, and export attaches the click to
its leaves. An anchor with other attributes or several children is an
ordinary container.
"""
if _tag_name(elem) != 'a':
return None
if any(attr not in _ANCHOR_OWN_ATTRIBUTES for attr in elem.attrib):
return None
visual = [child for child in elem if _tag_name(child) not in _NON_VISUAL_TAGS]
if len(visual) != 1 or _tag_name(visual[0]) != 'g':
return None
return visual[0]
def effective_top_level(elem: ET.Element) -> ET.Element:
"""Return the element a top-level scan should treat ``elem`` as."""
wrapped = anchor_wrapped_group(elem)
return elem if wrapped is None else wrapped
def usable_animation_group_id(raw: str | None) -> str | None:
"""Return one nonblank SVG animation anchor verbatim, else ``None``."""
return raw if raw and raw.strip() else None
@@ -237,6 +271,7 @@ def scan_svg_targets(
page_reference_size = _page_reference_size(root)
for child in root:
child = effective_top_level(child)
tag = _tag_name(child)
if tag in _NON_VISUAL_TAGS:
continue
@@ -105,7 +105,12 @@ from .elements import (
project_image_errors,
project_nested_svg_crop_errors,
)
from ..animation_config import is_chrome_id, usable_animation_group_id
from ..animation_config import (
anchor_wrapped_group,
effective_top_level,
is_chrome_id,
usable_animation_group_id,
)
from ..canvas_contract import (
CanvasContractError,
parse_project_svg_root,
@@ -1459,7 +1464,11 @@ def convert_g(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
def convert_a(elem: ET.Element, ctx: ConvertContext) -> ShapeResult | None:
"""Convert one standard SVG anchor into a clickable DrawingML object."""
result = convert_g(elem, ctx)
# A bare anchor around one <g> is transparent: the group converts at the
# anchor's own depth, so a top-level <a href><g id> stays an animation
# anchor, and the click lands on its leaves.
wrapped = anchor_wrapped_group(elem)
result = convert_g(wrapped if wrapped is not None else elem, ctx)
if result is None:
return None
return apply_shape_hyperlink(result, ctx, svg_hyperlink_href(elem))
@@ -2376,7 +2385,7 @@ def convert_svg_to_slide_shapes(
converted = 0
skipped = 0
has_top_level_group = any(
child.tag.replace(f'{{{SVG_NS}}}', '') == 'g'
effective_top_level(child).tag.replace(f'{{{SVG_NS}}}', '') == 'g'
for child in root
)
background_xml, background_skip_id = (
@@ -2409,11 +2418,12 @@ def convert_svg_to_slide_shapes(
shapes.append(result.xml)
converted += 1
m = re.search(r'<p:cNvPr id="(\d+)"', result.xml)
elem_id = child.get('id')
role = child.get('data-pptx-role')
placeholder = child.get('data-pptx-placeholder')
unit = effective_top_level(child)
elem_id = unit.get('id')
role = unit.get('data-pptx-role')
placeholder = unit.get('data-pptx-placeholder')
has_explicit_semantics = role is not None or placeholder is not None
structurally_static = child.get('data-pptx-layer') is not None
structurally_static = unit.get('data-pptx-layer') is not None
legacy_chrome = (
is_static_page_frame(role, placeholder)
if has_explicit_semantics
@@ -3669,6 +3669,19 @@ _FIXED_SPACE_EMS = {
}
_UNDECOMPOSED_BASE_LETTERS = str.maketrans('đĐłŁøØħĦ', 'dDlLoOhH')
def _base_letter(ch: str) -> str:
"""Return the unaccented letter a precomposed character advances like."""
decomposed = unicodedata.normalize('NFD', ch)
if len(decomposed) > 1 and all(
unicodedata.category(mark) == 'Mn' for mark in decomposed[1:]
):
ch = decomposed[0]
return ch.translate(_UNDECOMPOSED_BASE_LETTERS)
def _estimate_character_width(ch: str, font_size: float) -> float:
if (
0xFF00 <= ord(ch) <= 0xFFEF
@@ -3681,6 +3694,7 @@ def _estimate_character_width(ch: str, font_size: float) -> float:
return font_size * 0.3
if ch in _FIXED_SPACE_EMS:
return font_size * _FIXED_SPACE_EMS[ch]
ch = _base_letter(ch)
if ch in 'mMwWOQ%':
return font_size * 0.75
if ch in 'iIlj!|':
@@ -3766,6 +3780,18 @@ def estimate_text_cluster_widths(
):
widths.append(sum(advances[ch] for ch in cluster) * font_size)
continue
# A precomposed letter the table lacks advances like its base letter.
bases = [
_base_letter(ch) for ch in cluster if not _is_grapheme_extend(ch)
]
if advances is not None and not cjk and bases and all(
ch in advances and not _is_emoji_base(ch) for ch in bases
) and all(
unicodedata.category(ch) == 'Mn' and not 0xFE00 <= ord(ch) <= 0xFE0F
for ch in cluster if _is_grapheme_extend(ch)
):
widths.append(sum(advances[ch] for ch in bases) * font_size)
continue
width = _estimate_grapheme_width(cluster, font_size)
widths.append(width * 1.05 if bold and not cjk else width)
return widths
@@ -1260,9 +1260,22 @@ def _native_table_border_topology_warnings(
row_count = len(row_edges) - 1
col_count = len(column_edges) - 1
for record in shape_records:
if record.tag != "line" or record.stroke is None:
if record.stroke is None:
continue
x1, y1, x2, y2 = record.bounds
if record.tag == "rect":
# A stroked rect on the table's outer bounds draws the outer frame.
if (
abs(x1 - column_edges[0]) <= 2 and abs(x2 - column_edges[-1]) <= 2
and abs(y1 - row_edges[0]) <= 2 and abs(y2 - row_edges[-1]) <= 2
):
for col_idx in range(col_count):
covered.update({("h", 0, col_idx), ("h", row_count, col_idx)})
for row_idx in range(row_count):
covered.update({("v", 0, row_idx), ("v", col_count, row_idx)})
continue
if record.tag != "line":
continue
if abs(y2 - y1) <= 1:
boundary = next(
(idx for idx, edge in enumerate(row_edges) if abs(edge - y1) <= 2),
@@ -6626,6 +6626,7 @@ def create_pptx_with_native_svg(
transition: str | None = 'fade',
transition_duration: float = 0.5,
auto_advance: float | None = None,
kiosk: bool = False,
use_compat_mode: bool = True,
notes: dict[str, str] | None = None,
enable_notes: bool = True,
@@ -6698,6 +6699,7 @@ def create_pptx_with_native_svg(
generated page transition.
transition_duration: Transition duration in seconds.
auto_advance: Auto-advance interval in seconds.
kiosk: Write a looping kiosk show (no click/keyboard advance).
use_compat_mode: Retained for API compatibility; ignored in native mode.
notes: Notes dict, key is SVG stem, value is notes content.
enable_notes: Whether to enable notes embedding.
@@ -8449,8 +8451,12 @@ def create_pptx_with_native_svg(
f"{pruned_page_plan_parts} unreachable part(s)"
)
if package_uses_timings:
set_directory_use_timings(extract_dir)
if package_uses_timings or kiosk:
set_directory_use_timings(
extract_dir,
enabled=True if package_uses_timings else None,
kiosk=kiosk,
)
reinjected_resources = _reinject_roundtrip_resources(
extract_dir,
@@ -1964,6 +1964,7 @@ def _resolve_animation_config_source(
def main(argv: list[str] | None = None) -> int:
"""CLI entry point for the SVG to PPTX conversion tool."""
require_skill_integrity()
roundtrip_summary_line: str | None = None
transition_choices = [
'none',
*NATIVE_TRANSITION_KEYS,
@@ -2229,6 +2230,10 @@ Recorded narration:
help='Transition duration in seconds (default: 0.4)')
parser.add_argument('--auto-advance', type=non_negative_float, default=None,
help='Auto-advance interval in seconds (default: manual advance)')
parser.add_argument('--kiosk', action='store_true',
help='Export as a looping kiosk show: PowerPoint ignores click and '
'keyboard advance, so only --auto-advance timings and hyperlinks '
'move between slides')
parser.add_argument('-a', '--animation', type=str, choices=animation_choices,
default=None,
@@ -3083,7 +3088,9 @@ Recorded narration:
file=sys.stderr,
)
return 1
print(
# Printed only after the package is written: a failed export must not
# leave a receipt that reads like a delivery.
roundtrip_summary_line = (
" Round-trip export summary: "
f"output_pages={len(roundtrip_pages)} "
f"passthrough={direct_passthrough_count} "
@@ -3264,7 +3271,8 @@ Recorded narration:
# is predictable; an explicit -o keeps the caller's exact name untouched.
native_tag = "_native_charts_tables" if args.native_objects else ""
narrated_tag = "_narrated" if (args.recorded_narration or args.narration_audio_dir) else ""
native_path = exports_dir / f"{project_name}_{timestamp}{native_tag}{narrated_tag}.pptx"
kiosk_tag = "_kiosk" if args.kiosk else ""
native_path = exports_dir / f"{project_name}_{timestamp}{native_tag}{narrated_tag}{kiosk_tag}.pptx"
# Preserve svg_output/ only when it is the actual source. A custom -s
# directory remains the caller-owned source and is not copied under a
# misleading svg_output backup name.
@@ -3750,6 +3758,7 @@ Recorded narration:
transition_sound=transition_sound,
transition_duration=transition_duration,
auto_advance=auto_advance,
kiosk=args.kiosk,
notes=notes,
enable_notes=enable_notes,
animation=animation,
@@ -3828,6 +3837,8 @@ Recorded narration:
except (TemplateStructureError, ValueError) as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
if success and roundtrip_summary_line is not None:
print(roundtrip_summary_line)
# Archive svg_output/ once per default-flow export. This preserves the
# authored SVG sources under backup/<ts>/svg_output/ for inspection and
@@ -3025,7 +3025,9 @@ def _layout_contract_difference(
details.append("generated Layout element order differs")
if not details:
details.append(
"shared Layout element metadata, geometry, topology, or content differs"
"shared Layout element metadata (data-pptx-* attributes), geometry, "
"topology, or content differs; fills, strokes, and gradients may be "
"repainted"
)
return "; ".join(details)
@@ -3323,8 +3325,13 @@ def template_prototype_errors(
errors.append(
f"{spec.svg_path.name}: template Master structure differs "
f"from prototype {reference.svg_path.name}; strict and adaptive "
"routes must retain its ids, topology, geometry, and content"
+ (" including mirror visual styling" if literal_visual else "")
"routes must retain its ids, topology, geometry, data-pptx-* "
"metadata, and content"
+ (
" including mirror visual styling"
if literal_visual
else "; fills, strokes, and gradients may be repainted"
)
+ (
f"; first difference: {master_difference}"
if master_difference else ""
@@ -0,0 +1,81 @@
"""Regressions from the 2026-09-18 template-plus-new-material Edit Native run."""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
from xml.etree import ElementTree as ET
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
from pptx_to_svg.emu_units import Xfrm # noqa: E402
from pptx_to_svg.txbody_to_svg import convert_txbody # noqa: E402
from svg_quality.checker import SVGQualityChecker # noqa: E402
A = "http://schemas.openxmlformats.org/drawingml/2006/main"
SVG = "http://www.w3.org/2000/svg"
def _tx_body(body_pr: str, run_pr: str = '<a:rPr sz="6000"/>') -> ET.Element:
return ET.fromstring(
f'<p:txBody xmlns:a="{A}" '
'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">'
f'{body_pr}<a:p><a:r>{run_pr}<a:t>61%</a:t></a:r></a:p></p:txBody>'
)
def _convert(tx_body: ET.Element, theme_fonts: dict[str, str] | None = None) -> str:
return convert_txbody(
tx_body, Xfrm(x=0, y=0, w=400, h=200), None, theme_fonts=theme_fonts,
).svg
class ImportedTextFidelityTests(unittest.TestCase):
def test_norm_autofit_font_scale_reaches_the_svg_font_size(self) -> None:
plain = _convert(_tx_body('<a:bodyPr wrap="none"/>'))
shrunk = _convert(_tx_body(
'<a:bodyPr wrap="none"><a:normAutofit fontScale="25000" '
'lnSpcReduction="20000"/></a:bodyPr>'
))
self.assertIn('font-size="80"', plain)
self.assertIn('font-size="20"', shrunk)
def test_run_without_any_typeface_takes_the_theme_minor_pair(self) -> None:
svg = _convert(
_tx_body('<a:bodyPr/>'),
{"minorLatin": "Arial", "minorEastAsia": "微软雅黑"},
)
self.assertIn('font-family="Arial, &quot;微软雅黑&quot;, sans-serif"', svg)
class SemanticShapeParagraphBlockTests(unittest.TestCase):
def _errors(self, text: str) -> list[str]:
root = ET.fromstring(
f'<svg xmlns="{SVG}" viewBox="0 0 1280 720">'
'<g id="s" data-pptx-semantic-object="shape" '
f'data-pptx-frame="0 0 600 200">{text}</g></svg>'
)
result: dict = {"errors": [], "warnings": []}
SVGQualityChecker._check_semantic_shape_text(root, result)
return result["errors"]
def test_absolute_y_rows_are_refused_before_export(self) -> None:
errors = self._errors(
'<text x="300" font-size="40"><tspan x="300" y="80">a</tspan>'
'<tspan x="300" y="130">b</tspan></text>'
)
self.assertEqual(len(errors), 1)
self.assertIn("<tspan x dy>", errors[0])
def test_relative_dy_rows_pass(self) -> None:
self.assertEqual(self._errors(
'<text x="300" y="80" font-size="40">a'
'<tspan x="300" dy="50">b</tspan></text>'
), [])
if __name__ == "__main__":
unittest.main()
@@ -132,7 +132,7 @@ class SliceImagesDiagnosticsTests(unittest.TestCase):
self.assertIn("--bg #57B265 --tolerance 12", result.stderr)
self.assertFalse((output_dir / "element.png").exists())
def test_strict_alpha_names_edge_wide_near_key_noise_as_key_noise(self) -> None:
def test_strict_alpha_retries_once_when_every_finding_is_key_noise(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
sheet_path = root / "sheet.png"
@@ -170,11 +170,13 @@ class SliceImagesDiagnosticsTests(unittest.TestCase):
check=False,
)
self.assertEqual(result.returncode, 1)
self.assertIn("this is key noise", result.stderr)
# Every finding is measured key noise, so the tool retries once
# with the tolerance it measured instead of asking for a rerun.
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("key noise", result.stderr)
self.assertIn("auto-retrying once with --tolerance", result.stderr)
self.assertNotIn("content reaches the", result.stderr)
self.assertIn("Suggested rerun:", result.stderr)
self.assertFalse((output_dir / "element.png").exists())
self.assertTrue((output_dir / "element.png").exists())
def test_inset_accepts_horizontal_and_vertical_fractions(self) -> None:
from slice_images import parse_inset
@@ -248,6 +250,46 @@ class SliceImagesDiagnosticsTests(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(Image.open(output_dir / "mark.png").size, (41, 41))
def test_uneven_key_ground_is_keyed_by_dominance_without_eating_thin_strokes(self) -> None:
import numpy as np
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
sheet_path = root / "sheet.png"
output_dir = root / "output"
size = 400
rng = np.random.default_rng(7)
yy, xx = np.mgrid[0:size, 0:size]
t = (xx + yy) / (2 * size)
# A generated key painted as a gradient with grain: #00FF00 -> #25D23A.
ground = np.stack([37 * t, 255 - 45 * t, 58 * t], axis=2)
ground += rng.normal(0, 3.0, ground.shape)
scale = 4
ink = Image.new("L", (size * scale, size * scale), 0)
draw = ImageDraw.Draw(ink)
for index in range(9):
offset = (110 + index * 22) * scale
draw.line([(100 * scale, offset), (300 * scale, offset)], fill=255, width=scale)
draw.line([(offset, 100 * scale), (offset, 300 * scale)], fill=255, width=scale * 2)
ink = ink.resize((size, size), Image.LANCZOS)
coverage = np.asarray(ink, dtype=np.float32)[..., None] / 255
sheet = ground * (1 - coverage) + np.array([18, 18, 18]) * coverage
Image.fromarray(np.clip(sheet, 0, 255).astype(np.uint8), "RGB").save(sheet_path)
slice_sheet(
sheet_path, 1, 1, output_dir, names=["lines"],
alpha=True, strict_alpha=True, bg=(0, 255, 0),
)
cut = np.asarray(Image.open(output_dir / "lines.png").convert("RGBA"), dtype=np.float32)
alpha = cut[..., 3] / 255
reference = coverage[..., 0]
self.assertGreater(alpha[reference > 0.9].mean(), 0.95)
self.assertLess(alpha[reference < 0.02].mean(), 0.01)
opaque = alpha > 0.8
key_tinted = opaque & (cut[..., 1] > cut[..., 0] + 30) & (cut[..., 1] > cut[..., 2] + 30)
self.assertEqual(int(key_tinted.sum()), 0)
def test_strict_alpha_names_painted_card_cells_instead_of_a_key_rerun(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
@@ -0,0 +1,585 @@
#!/usr/bin/env python3
"""Regression tests for lossless spec review, concurrent edits, and comment receipts."""
from __future__ import annotations
import io
import json
import sys
import tempfile
import unittest
from concurrent.futures import ThreadPoolExecutor
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
from unittest.mock import Mock, patch
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
from check_spec_annotations import main as check_main # noqa: E402
from project_management.spec_blocks import replace_spec_block, split_spec_blocks # noqa: E402
from project_specs import SCHEMA_DIR, validate_markdown_schema, validate_markdown_text # noqa: E402
from spec_review.server import create_app, main as server_main # noqa: E402
from spec_review.store import ReviewError, ReviewStore, sha256 # noqa: E402
SPEC = """<!-- ppt-master-schema: design-spec/v1 -->
# Complete 深度 - Design Spec
## I. Project Information
| Item | Value |
| --- | --- |
| Page Count | 2 |
| Design Spec Depth | complete |
## II. Canvas Specification
1280 × 720
## III. Visual Theme
### Theme Style
Long prose **with emphasis**.
## IV. Typography System
| Role | Font |
| --- | --- |
| Body | Arial |
## V. Layout Principles
Whitespace.
## VI. Icon Usage Specification
None.
## VIII. Image Resource List
None.
## IX. Content Outline
Introduction.
### Part 1 - Opening
#### Slide 01 - 开篇
- **Audience move**: Understand.
- **Relationships**: none
- A complete paragraph that can be changed.
- A nested list.
| Region | Value |
| --- | --- |
| North | 12 |
### Part 2 - Closing
#### Slide 02 - Close
- **Audience move**: Decide.
- **Relationships**: none
- Closing paragraph.
## X. Speaker Notes Requirements
None.
"""
SCHEMA = SCHEMA_DIR / "design_spec.schema.json"
class SpecBlocksTests(unittest.TestCase):
def test_lossless_round_trips(self):
fenced = SPEC.replace("| Region | Value |", "```md\n## x\n#### Slide 99 - fake\n```\n\n| Region | Value |")
tilde = SPEC.replace(
"| Region | Value |", "~~~~md\n## x\n#### Slide 99 - fake\n~~~\n~~~~\n\n| Region | Value |",
)
for text in (SPEC, SPEC.replace("\n", "\r\n"), SPEC.rstrip("\n"), fenced, tilde, "preamble only"):
with self.subTest(ending=repr(text[-8:]), fenced="fake" in text):
layout = split_spec_blocks(text)
joined = layout.preamble + "".join(text[block.start:block.end] for block in layout.blocks)
self.assertEqual(joined.encode("utf-8"), text.encode("utf-8"))
raw = text.encode("utf-8")
for block in layout.blocks:
self.assertEqual(raw[block.byte_start:block.byte_end], text[block.start:block.end].encode("utf-8"))
self.assertNotIn("slide:99", [block.key for block in layout.blocks])
def test_block_order_and_part_ownership(self):
layout = split_spec_blocks(SPEC)
self.assertEqual([block.key for block in layout.blocks][-6:],
["section:IX", "part:01", "slide:01", "part:02", "slide:02", "section:X"])
parts = [block for block in layout.blocks if block.kind == "part"]
self.assertEqual([SPEC[block.start:block.end] for block in parts],
["### Part 1 - Opening\n\n", "### Part 2 - Closing\n\n"])
slide = next(block for block in layout.blocks if block.key == "slide:01")
self.assertNotIn("### Part 2", SPEC[slide.start:slide.end])
self.assertEqual(slide.title, "#### Slide 01 - 开篇")
def test_slide_headings_at_every_supported_level(self):
for level in range(3, 7):
with self.subTest(level=level):
text = SPEC.replace("#### Slide 01", "#" * level + " Slide 01")
layout = split_spec_blocks(text)
slide = next(block for block in layout.blocks if block.key == "slide:01")
self.assertEqual(slide.kind, "slide")
self.assertEqual(len([block for block in layout.blocks if block.kind == "part"]), 2)
self.assertEqual(layout.preamble + "".join(text[b.start:b.end] for b in layout.blocks), text)
draft = text[slide.start:slide.end].replace("开篇", "New title")
self.assertEqual(replace_spec_block(text, slide.key, draft), text.replace("开篇", "New title"))
def test_non_slide_h3_is_part_regardless_of_title(self):
text = SPEC.replace("### Part 1 - Opening", "### Introduction")
block = next(block for block in split_spec_blocks(text).blocks if block.key == "part:01")
self.assertEqual(block.kind, "part")
self.assertEqual(text[block.start:block.end], "### Introduction\n\n")
def test_replacement_changes_only_target_bytes(self):
for text in (SPEC, SPEC.replace("\n", "\r\n"), SPEC.rstrip("\n")):
with self.subTest(crlf="\r" in text):
old = next(block for block in split_spec_blocks(text).blocks if block.key == "slide:01")
draft = text[old.start:old.end].replace("开篇", "新的页名").replace("can be changed", "was changed")
candidate = replace_spec_block(text, old.key, draft.replace("\r\n", "\n"))
new = next(block for block in split_spec_blocks(candidate).blocks if block.key == old.key)
self.assertEqual(text.encode()[:old.byte_start], candidate.encode()[:new.byte_start])
self.assertEqual(text.encode()[old.byte_end:], candidate.encode()[new.byte_end:])
if "\r" in text:
self.assertNotIn("\n", candidate.replace("\r\n", ""))
def test_noop_preserves_mixed_endings_and_terminal_whitespace(self):
text = SPEC.replace("Whitespace.\n", "Whitespace.\r\n")
block = next(block for block in split_spec_blocks(text).blocks if block.key == "section:V")
self.assertEqual(replace_spec_block(text, block.key, text[block.start:block.end].replace("\r\n", "\n")), text)
for tail in ("", "\n", "\n\n", "\r\n \r\n"):
text = SPEC.rstrip() + tail
block = split_spec_blocks(text).blocks[-1]
candidate = replace_spec_block(text, block.key, "## X. Speaker Notes Requirements\nChanged.\n\n\n")
self.assertEqual(candidate[block.start:], "## X. Speaker Notes Requirements\nChanged." + tail)
def test_structural_injection_and_heading_changes_rejected(self):
block = next(block for block in split_spec_blocks(SPEC).blocks if block.key == "slide:02")
original = SPEC[block.start:block.end]
changes = [original + heading for heading in (
"## Extra", "#### Slide 99 - Injected", "#### Slide 99 ", "### Part 3",
"### Slide 99", "##### Slide 99 - Injected", "###### Slide 99 - Injected",
)]
changes += [original.replace("Slide 02", "Slide 03"), original.replace("#### Slide", "### Slide"),
"prefix\n" + original, original + "```\n"]
for draft in changes:
with self.subTest(draft=draft[-40:]), self.assertRaises(ValueError):
replace_spec_block(SPEC, block.key, draft)
def test_part_allows_only_title_text_edits(self):
text = SPEC.replace("### Part 2 - Closing\n", "### Part 2 - Closing\nExisting part description.\n")
for text in (text, text.replace("\n", "\r\n")):
block = next(block for block in split_spec_blocks(text).blocks if block.key == "part:02")
original = text[block.start:block.end]
renamed = original.replace("Closing", "Final decisions").replace("\r\n", "\n")
expected = text.replace("### Part 2 - Closing", "### Part 2 - Final decisions")
self.assertEqual(replace_spec_block(text, block.key, renamed), expected)
changes = [original + heading for heading in (
"## Extra", "### Another part", "### Slide 99", "#### Slide 99", "New body text",
)]
changes += [
original.replace("### Part", "#### Part"), original.replace("### Part 2 - Closing", "### Slide 99"),
original.replace("Existing part description.", "Changed body."),
]
for draft in changes:
with self.subTest(draft=draft), self.assertRaises(ValueError):
replace_spec_block(text, block.key, draft)
with self.assertRaisesRegex(ValueError, "section heading"):
replace_spec_block(SPEC, "section:V", "## V. Renamed\nBody\n")
def test_slide_injection_outside_ix_is_rejected(self):
block = next(block for block in split_spec_blocks(SPEC).blocks if block.key == "section:V")
for level in range(3, 7):
with self.subTest(level=level), self.assertRaisesRegex(ValueError, "Structural heading"):
replace_spec_block(SPEC, block.key, SPEC[block.start:block.end] + "#" * level + " Slide 99\n")
def test_headings_inside_both_fences_can_be_edited(self):
block = next(block for block in split_spec_blocks(SPEC).blocks if block.key == "slide:02")
for fence in ("```", "~~~"):
draft = SPEC[block.start:block.end] + (
f"{fence}\n## x\n### Slide 99\n#### Slide 99 - fake\n### Part\n{fence}\n"
)
candidate = replace_spec_block(SPEC, block.key, draft)
self.assertEqual(len(split_spec_blocks(candidate).blocks), len(split_spec_blocks(SPEC).blocks))
class ReviewServiceTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.path = self.root / "design_spec.md"
self.path.write_bytes(SPEC.encode())
self.app = create_app(str(self.root), idle_timeout=0)
self.client = self.app.test_client()
self.store = self.app.extensions["review_store"]
def draft(self, key="slide:02", text=None):
block = self.client.get(f"/api/blocks/{key}").get_json()
response = self.client.put(f"/api/drafts/{key}", json={
"sha256": block["sha256"], "text": text or block["text"].replace("Closing paragraph", "Edited paragraph"),
})
self.assertEqual(response.status_code, 200, response.get_json())
return response.get_json()
def check(self, *args):
stdout, stderr = io.StringIO(), io.StringIO()
with redirect_stdout(stdout), redirect_stderr(stderr):
code = check_main([str(self.root), *args])
return code, stdout.getvalue(), stderr.getvalue()
def test_read_api_returns_raw_crlf_and_metadata(self):
raw = SPEC.replace("\n", "\r\n").encode()
self.path.write_bytes(raw)
self.assertEqual(self.client.get("/api/health").get_json()["service"], "spec_review")
document = self.client.get("/api/spec").get_json()
self.assertEqual(document["text"].encode(), raw)
self.assertEqual(document["sha256"], sha256(raw))
blocks = self.client.get("/api/blocks").get_json()
self.assertEqual(blocks["blocks"][-2]["key"], "slide:02")
self.assertFalse(any(block["has_draft"] for block in blocks["blocks"]))
def test_staging_and_discard_never_write_spec(self):
before = self.path.read_bytes()
draft = self.draft()
self.assertEqual(self.path.read_bytes(), before)
self.assertFalse((self.root / "spec_review" / "edits.jsonl").exists())
result = self.client.delete("/api/drafts/slide:02", json={"version": draft["version"]})
self.assertEqual(result.status_code, 200)
self.assertFalse(self.client.get("/api/blocks/slide:02").get_json()["draft"])
self.assertEqual(self.path.read_bytes(), before)
def test_apply_crlf_and_history(self):
self.path.write_bytes(SPEC.replace("\n", "\r\n").encode())
block = self.client.get("/api/blocks/slide:02").get_json()
draft = self.draft(text=block["text"].replace("Closing", "Edited").replace("\r\n", "\n"))
result = self.client.post("/api/apply/slide:02", json=draft)
self.assertEqual(result.status_code, 200, result.get_json())
self.assertEqual(result.get_json()["validation_errors"], [])
self.assertNotIn(b"\n", self.path.read_bytes().replace(b"\r\n", b""))
log = json.loads((self.root / "spec_review/edits.jsonl").read_text())
self.assertEqual(log["before_sha256"], draft["sha256"])
self.assertEqual(log["after_sha256"], sha256(self.path.read_bytes()))
self.assertIn("Edited", log["after"])
def test_apply_append_to_last_slide_of_part(self):
block = self.client.get("/api/blocks/slide:01").get_json()
self.assertNotIn("### Part 2", block["text"])
draft = self.draft("slide:01", block["text"].rstrip() + "\nAdded at page end.\n\n")
response = self.client.post("/api/apply/slide:01", json=draft)
self.assertEqual(response.status_code, 200, response.get_json())
self.assertEqual(self.path.read_bytes(),
SPEC.replace("| North | 12 |\n", "| North | 12 |\nAdded at page end.\n").encode())
def test_sha_mismatch_is_409_and_draft_survives(self):
draft = self.draft()
self.path.write_bytes(self.path.read_bytes() + b"External change\n")
before = self.path.read_bytes()
result = self.client.post("/api/apply/slide:02", json=draft)
self.assertEqual(result.status_code, 409)
self.assertEqual(self.path.read_bytes(), before)
self.assertEqual(self.client.get("/api/blocks/slide:02").get_json()["draft"], draft)
def test_external_block_removal_keeps_draft_accessible(self):
draft = self.draft()
block = next(block for block in split_spec_blocks(SPEC).blocks if block.key == "slide:02")
self.path.write_bytes((SPEC[:block.start] + SPEC[block.end:]).encode())
listing = self.client.get("/api/blocks").get_json()
self.assertEqual(listing["orphan_drafts"][0]["key"], "slide:02")
missing = self.client.get("/api/blocks/slide:02").get_json()
self.assertTrue(missing["missing"])
self.assertEqual(missing["draft"], draft)
self.assertEqual(self.client.post("/api/apply/slide:02", json=draft).status_code, 409)
discarded = self.client.delete("/api/drafts/slide:02", json={"version": draft["version"]})
self.assertEqual(discarded.status_code, 200)
def test_hold_is_423_and_resume_reports_revision(self):
draft = self.draft()
self.assertEqual(self.client.post("/api/hold", json={"hold": True}).status_code, 200)
self.assertEqual(self.client.post("/api/apply/slide:02", json=draft).status_code, 423)
released = self.client.post("/api/hold", json={"hold": False}).get_json()
self.assertEqual(released["hold_revision"], 2)
self.assertEqual(self.client.post("/api/apply/slide:02", json=draft).status_code, 200)
def test_apply_rejects_structure_without_mutation(self):
for heading in ("## Injected", "### Slide 99"):
with self.subTest(heading=heading):
draft = self.draft(text=f"#### Slide 02 - Close\n{heading}\nBody\n")
before = self.path.read_bytes()
result = self.client.post("/api/apply/slide:02", json=draft)
self.assertEqual(result.status_code, 400)
self.assertIn("Structural heading", result.get_json()["error"])
self.assertEqual(self.path.read_bytes(), before)
self.assertEqual(self.client.delete("/api/drafts/slide:02", json=draft).status_code, 200)
def test_schema_errors_are_advisory_and_returned_unchanged(self):
draft = self.draft(text="#### Slide 02 - Close\nMissing schema fields.\n")
result = self.client.post("/api/apply/slide:02", json=draft)
self.assertEqual(result.status_code, 200)
errors = result.get_json()["validation_errors"]
self.assertTrue(errors)
self.assertEqual(errors, validate_markdown_schema(self.path, SCHEMA))
self.assertIn(b"Missing schema fields.", self.path.read_bytes())
self.assertFalse((self.root / "spec_lock.md").exists())
def test_candidate_and_file_validation_match_without_reading_spec_lock(self):
variants = (SPEC, SPEC.replace("\n", "\r\n"), "\ufeff" + SPEC,
"\ufeff\ufeff" + SPEC, SPEC.replace("design-spec/v1", "bad"))
for text in variants:
self.path.write_bytes(text.encode())
disk = validate_markdown_schema(self.path, SCHEMA)
if text.startswith("\ufeff\ufeff"):
self.assertTrue(any("missing ppt-master-schema marker" in error for error in disk))
read_text = Path.read_text
def guarded(path, *args, **kwargs):
self.assertNotIn(path.name, ("spec_lock.md", "design_spec.md"))
return read_text(path, *args, **kwargs)
with patch.object(Path, "read_text", guarded):
self.assertEqual(validate_markdown_text(text, SCHEMA, markdown_path=self.path), disk)
missing = self.root / "missing.schema.json"
self.assertEqual(validate_markdown_text(text, missing, markdown_path=self.path),
validate_markdown_schema(self.path, missing))
def test_comment_crud_is_sidecar_only(self):
before = self.path.read_bytes()
saved = self.client.post("/api/annotations", json={"key": "global", "body": "Full deck feedback"})
self.assertEqual(saved.status_code, 201)
item = saved.get_json()
changed = self.client.put(
f"/api/annotations/{item['id']}", json={"revision": item["revision"], "body": "New text"},
)
updated = changed.get_json()
self.assertEqual(updated["spec_sha256"], sha256(before))
self.assertNotEqual(updated["revision"], item["revision"])
self.assertEqual(self.client.delete(f"/api/annotations/{item['id']}",
json={"revision": item["revision"]}).status_code, 409)
self.assertEqual(self.client.delete(f"/api/annotations/{item['id']}",
json={"revision": updated["revision"]}).status_code, 200)
self.assertEqual(self.path.read_bytes(), before)
events = [json.loads(line)["event"] for line in
(self.root / "spec_review/annotations.jsonl").read_text().splitlines()]
self.assertEqual(events, ["saved", "updated", "removed"])
def test_applied_unchanged_comment_is_removed_and_logged(self):
item = self.store.save_annotation("slide:01", "Change the opening")
self.assertEqual(self.check()[0], 0)
self.assertEqual(self.check("--applied", item["id"])[0], 0)
self.assertEqual(self.store.annotations(), [])
self.assertIn('"event":"annotation_applied"', (self.root / "spec_review/annotations.jsonl").read_text())
def test_applied_changed_comment_is_retained(self):
item = self.store.save_annotation("slide:01", "Original")
self.check()
self.store.save_annotation("", "Updated", item["id"], item["revision"])
code, output, errors = self.check("--applied", item["id"])
self.assertEqual(code, 1)
self.assertEqual(output, "")
self.assertIn("changed since it was listed", errors)
self.assertEqual(self.store.annotations()[0]["body"], "Updated")
def test_applied_unlisted_and_same_text_revisions_are_rejected(self):
item = self.store.save_annotation("global", "Same text")
self.assertEqual(self.check("--applied", item["id"])[0], 1)
self.check()
self.store.save_annotation("", item["body"], item["id"], item["revision"])
self.assertEqual(self.check("--applied", item["id"])[0], 1)
def test_applied_detects_manual_sidecar_change_without_revision_bump(self):
item = self.store.save_annotation("global", "Original")
self.check()
path = self.root / "spec_review/annotations.json"
data = json.loads(path.read_text())
data["items"][0]["body"] = "Manually changed"
path.write_text(json.dumps(data), encoding="utf-8")
self.assertEqual(self.check("--applied", item["id"])[0], 1)
self.assertEqual(self.store.annotations()[0]["body"], "Manually changed")
def test_todo_includes_hash_status_and_edit_summary_without_full_text(self):
self.store.save_annotation("slide:02", "Review it")
draft = self.draft()
self.client.post("/api/apply/slide:02", json=draft)
code, output, errors = self.check()
self.assertEqual((code, errors), (0, ""))
result = json.loads(output)
self.assertTrue(result["annotations"][0]["block_changed"])
self.assertFalse(result["annotations"][0]["block_missing"])
self.assertEqual(set(result["edits"][0]), {"ts", "key", "title", "before_sha256", "after_sha256"})
self.assertEqual(result["edits"][0]["before_sha256"], draft["sha256"][:12])
self.assertEqual(result["edits"][0]["after_sha256"], sha256(self.path.read_bytes())[:12])
self.assertNotIn("Edited paragraph", output)
def test_ack_edits_consumes_only_last_listing_and_preserves_history(self):
self.store.save_annotation("global", "Keep pending")
draft = self.draft()
self.assertEqual(self.client.post("/api/apply/slide:02", json=draft).status_code, 200)
for _ in range(2):
self.assertEqual(len(json.loads(self.check()[1])["edits"]), 1)
# A new Apply after the listing must survive acknowledgement of that listing.
draft = self.draft(text=self.store.block("slide:02")["text"].replace("Edited", "Edited again"))
self.assertEqual(self.client.post("/api/apply/slide:02", json=draft).status_code, 200)
log = self.root / "spec_review/edits.jsonl"
history = log.read_bytes()
self.assertEqual(self.check("--ack-edits")[0], 0)
result = json.loads(self.check()[1])
self.assertEqual(len(result["annotations"]), 1)
self.assertEqual(len(result["edits"]), 1)
self.assertEqual(result["edits"][0]["before_sha256"], draft["sha256"][:12])
self.assertEqual(self.check("--ack-edits")[0], 0)
self.assertEqual(json.loads(self.check()[1])["edits"], [])
self.assertEqual(log.read_bytes(), history)
self.assertEqual(ReviewStore(self.root).list_todo()["edits"], [])
def test_ack_edits_requires_cli_listing(self):
self.client.get("/api/annotations")
code, output, errors = self.check("--ack-edits")
self.assertEqual((code, output), (1, ""))
self.assertIn("List direct edits before acknowledging", errors)
def test_block_comment_baselines_ignore_edits_to_other_blocks(self):
self.path.write_bytes(SPEC.replace("\n", "\r\n").encode())
first = self.store.save_annotation("slide:01", "Opening feedback")
second = self.store.save_annotation("slide:02", "Closing feedback")
self.store.save_annotation("global", "Whole spec feedback")
self.assertEqual(first["block_sha256"], sha256(self.store.block("slide:01")["text"].encode()))
before = self.client.get("/api/annotations").get_json()["annotations"]
self.assertFalse(before[0]["block_changed"])
self.assertFalse(before[1]["block_changed"])
self.assertTrue(before[2]["base_current"])
draft = self.draft()
self.assertEqual(self.client.post("/api/apply/slide:02", json=draft).status_code, 200)
updated = self.store.save_annotation("", "Updated feedback", second["id"], second["revision"])
self.assertEqual(updated["block_sha256"], second["block_sha256"])
for items in (self.client.get("/api/annotations").get_json()["annotations"],
json.loads(self.check()[1])["annotations"]):
self.assertFalse(items[0]["block_changed"])
self.assertTrue(items[1]["block_changed"])
self.assertFalse(items[0]["block_missing"])
self.assertNotIn("base_current", items[0])
self.assertFalse(items[2]["base_current"])
self.assertNotIn("block_changed", items[2])
def test_block_comment_unknown_and_missing_baselines(self):
self.store.save_annotation("slide:01", "Legacy feedback")
self.store.save_annotation("slide:02", "Missing block feedback")
sidecar = self.root / "spec_review/annotations.json"
data = json.loads(sidecar.read_text())
del data["items"][0]["block_sha256"]
sidecar.write_text(json.dumps(data), encoding="utf-8")
block = next(block for block in split_spec_blocks(SPEC).blocks if block.key == "slide:02")
self.path.write_bytes((SPEC[:block.start] + SPEC[block.end:]).encode())
for items in (self.client.get("/api/annotations").get_json()["annotations"],
json.loads(self.check()[1])["annotations"]):
self.assertIsNone(items[0]["block_changed"])
self.assertFalse(items[0]["block_missing"])
self.assertIsNone(items[1]["block_changed"])
self.assertTrue(items[1]["block_missing"])
def test_polling_and_health_do_not_refresh_activity(self):
self.app.config["LAST_ACTIVITY"] = 10.0
with patch("spec_review.server.time.monotonic", return_value=8000.0):
for path in ("/api/state", "/api/state?poll=1", "/api/health"):
for _ in range(2):
self.assertEqual(self.client.get(path).status_code, 200)
self.assertEqual(self.app.config["LAST_ACTIVITY"], 10.0)
self.assertEqual(self.client.get("/api/blocks").status_code, 200)
self.assertEqual(self.app.config["LAST_ACTIVITY"], 8000.0)
with patch("spec_review.server.time.monotonic", return_value=9000.0):
response = self.client.post("/api/annotations", json={"key": "global", "body": "Active"})
self.assertEqual(response.status_code, 201)
self.assertEqual(self.app.config["LAST_ACTIVITY"], 9000.0)
def test_shutdown_stops_service_without_applying_pending_draft(self):
self.draft()
before = self.path.read_bytes()
response = self.client.post("/api/shutdown", json={})
self.assertEqual(response.status_code, 200)
self.assertTrue(self.app.extensions["stop_event"].is_set())
self.assertTrue(self.store.hold)
self.assertEqual(self.path.read_bytes(), before)
def test_two_applies_have_one_winner(self):
draft = self.draft()
def apply():
try:
self.store.apply("slide:02", draft["sha256"], draft["version"])
return 200
except ReviewError as exc:
return exc.status
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(lambda _: apply(), range(2)))
self.assertEqual(sorted(results), [200, 409])
self.assertEqual(len((self.root / "spec_review/edits.jsonl").read_text().splitlines()), 1)
def test_other_block_draft_rebases_only_after_own_edit(self):
first = self.draft("slide:01")
second = self.draft("slide:02")
result = self.client.post("/api/apply/slide:02", json=second).get_json()
remaining = self.store.block("slide:01")["draft"]
self.assertEqual(remaining["sha256"], result["sha256"])
self.assertEqual(remaining["version"], first["version"])
self.path.write_bytes(self.path.read_bytes() + b"external\n")
self.assertNotEqual(self.store.block("slide:01")["sha256"], remaining["sha256"])
def test_atomic_replace_failure_preserves_file_and_draft(self):
draft = self.draft()
before = self.path.read_bytes()
with patch("spec_review.store.os.replace", side_effect=OSError("Replace failed")):
response = self.client.post("/api/apply/slide:02", json=draft)
self.assertEqual(response.status_code, 500)
self.assertEqual(self.path.read_bytes(), before)
self.assertIsNotNone(self.store.block("slide:02")["draft"])
self.assertEqual(list(self.root.glob(".design_spec.md.*")), [])
def test_draft_versions_prevent_cross_tab_overwrite(self):
draft = self.draft()
response = self.client.put("/api/drafts/slide:02", json={"text": "lost", "sha256": draft["sha256"]})
self.assertEqual(response.status_code, 409)
self.assertEqual(self.store.block("slide:02")["draft"], draft)
def test_host_origin_and_json_guards(self):
for headers in ({"Host": "attacker.example"}, {"Origin": "null"}, {"Origin": "http://attacker.example"},
{"Origin": "http://localhost:7777"}):
self.assertEqual(self.client.get("/api/state", headers=headers).status_code, 403)
self.assertEqual(self.client.get("/api/state", headers={"Origin": "http://localhost"}).status_code, 200)
self.assertEqual(self.client.post("/api/annotations", json=[]).status_code, 400)
self.assertEqual(self.client.post("/api/hold", json={"hold": "on"}).status_code, 400)
response = self.client.get("/")
self.assertEqual(response.status_code, 200)
self.assertIn("default-src 'none'", response.headers["Content-Security-Policy"])
response.close()
def test_cli_help_and_invalid_port_have_no_runtime_side_effect(self):
with redirect_stdout(io.StringIO()), self.assertRaises(SystemExit) as result:
server_main(["--help"])
self.assertEqual(result.exception.code, 0)
self.assertEqual(server_main([str(self.root), "--port", "0"]), 1)
self.assertFalse((self.root / "spec_review").exists())
def test_explicit_port_is_strict_and_foreground_releases_lock(self):
httpd = Mock()
with patch("spec_review.server._running", return_value=None), patch(
"spec_review.server.make_server", return_value=httpd,
) as bind, patch("spec_review.server.find_free_port") as scan, patch(
"spec_review.server.threading.Thread.start",
), patch("spec_review.server.signal.signal"), redirect_stdout(io.StringIO()):
self.assertEqual(server_main([str(self.root), "--port", "6077", "--no-browser"]), 0)
scan.assert_not_called()
self.assertEqual(bind.call_args.args[:2], ("127.0.0.1", 6077))
httpd.server_close.assert_called_once()
self.assertFalse((self.root / "spec_review/lock.json").exists())
def test_explicit_port_bind_failure_does_not_fall_back(self):
with patch("spec_review.server._running", return_value=None), patch(
"spec_review.server.make_server", side_effect=OSError("Address already in use"),
), patch("spec_review.server.find_free_port") as scan:
self.assertEqual(server_main([str(self.root), "--port", "6077", "--no-browser"]), 1)
scan.assert_not_called()
self.assertFalse((self.root / "spec_review/lock.json").exists())
def test_hold_cli_and_running_port_mismatch(self):
with patch("spec_review.server._running", return_value={"pid": 1234, "port": 6077}), patch(
"spec_review.server._call", return_value={"hold": True},
) as call, redirect_stdout(io.StringIO()):
self.assertEqual(server_main([str(self.root), "--hold", "on"]), 0)
call.assert_called_once_with(6077, "/api/hold", {"hold": True})
self.assertEqual(server_main([str(self.root), "--port", "6088", "--no-browser"]), 1)
@@ -125,7 +125,7 @@ class TextMeasureTests(unittest.TestCase):
text = 'The dissemination layer covers the poster you stand next to at a conference session'
crude = sum(estimate_text_cluster_widths(text, 16))
self.assertAlmostEqual(crude, 661.6)
for family in ('Segoe UI', 'Unlisted Sans', 'Segoe UI, Arial'):
for family in ('Aptos', 'Unlisted Sans', 'Aptos, Arial'):
with self.subTest(family=family):
self.assertEqual(
measure_text(text, size=16, family=family, include_headroom=False),
@@ -202,7 +202,7 @@ class TextMeasureTests(unittest.TestCase):
)
def test_extended_clusters_keep_existing_widths_and_tracking(self) -> None:
text = 'e\u0301👩🏽‍💻🇨🇳1️⃣Aア'
text = '👩🏽‍💻🇨🇳1️⃣Aア'
for weight in ('400', 'bold'):
with self.subTest(weight=weight):
crude = estimate_text_cluster_widths(text, 20, weight)
@@ -216,6 +216,13 @@ class TextMeasureTests(unittest.TestCase):
sum(crude) + 2 * (len(crude) - 1),
)
def test_accented_letters_outside_the_table_advance_like_their_base(self) -> None:
for family in ('Arial', 'Cambria'):
with self.subTest(family=family):
accented = estimate_text_cluster_widths('ỔỊe\u0301ệ', 20, font_family=family)
plain = estimate_text_cluster_widths('OIee', 20, font_family=family)
self.assertEqual(accented, plain)
def test_arial_black_keeps_wide_family_factor(self) -> None:
crude = sum(estimate_text_cluster_widths('CAPS', 20, 'bold'))
self.assertAlmostEqual(
@@ -245,7 +245,8 @@ def discover_pages(project_path: Path, requested: list[str] | None) -> list[str]
if not requested:
return all_svgs
selected: list[str] = []
for token in requested:
tokens = [part for item in requested for part in item.split(',') if part]
for token in tokens:
match = next((n for n in all_svgs if n.startswith(token) or n == token), None)
if match is None:
raise ValueError(f'no SVG matches token {token!r} in {svg_dir}')
@@ -310,7 +311,8 @@ def main() -> int:
parser.add_argument(
'--pages', nargs='+', default=None,
help='Page tokens to render (default: all SVGs in svg_output/). '
"Accepts '02', '02_three_steps', or '02_three_steps.svg'.",
"Accepts '02', '02_three_steps', or '02_three_steps.svg'; several "
"pages are space- or comma-separated (--pages 02 04 or --pages 02,04).",
)
parser.add_argument(
'--server-url', default=None,
@@ -187,7 +187,7 @@ One ordered Slide block per page; count and order equal §I `Page Count`; `Conte
With Speaker Notes disabled, §X keeps only `- **Generation**: disabled`; an explicit notes-off/audio-on conflict blocks before authoring. When a final/literal narration script will become notes or audio, §X `Content` names the source and says `preserve verbatim`, with the segmented script kept in `notes/total.md`.
Optional Slide lines, added only when the capability earns a place (never an empty or `none` placeholder): `Mathematical content` (a valid delimiter-free LaTeX body — content authority for [`native-formula.md`](../references/native-formula.md), not a policy or marker; Executor chooses text, inline, or block); `Visualization` / `Images` when the Slide consumes §VII/§VIII or a page-local visual model, naming every value-driven geometry, cell grid, and child visual (only independent Chart/Table entries carry keys; qualitative relationships stay on the `Relationships` line, never a model name or grammar enum; §IX may choose a custom fallback; native construction is discovered by Executor, never a Design Spec field); `Motion suggestion` (purpose and semantic order/relationship, never registry keys, options, timing, ids, or coverage — it never activates execution, creates content, or binds implementation; required visible image states go in `Composition` / `Images`; a unit that visibly continues from the previous Slide is named here as a Morph candidate whether or not motion is enabled); keyed `Native-ready`; `Fact IDs` for sourced claims; `Data class: scenario` for invented values. Except on preservation paths, `Cover impact` carries a binding hook and adaptable composition, and `Closing impact` the same split only when the deck genuinely resolves. Roster, order, content, and `Relationships` stay authoritative; §V/§IX layout, cover/closing composition, capability, motif, non-`ai` §VIII image-layout, and §VII directions are References under [`strategist.md`](../references/strategist.md) §1 Confirmed-value semantics. When the user, a template, or a resource contract requires such a property, write `(binding)` after the field label (`- **Composition (binding)**:`) and Executor follows it literally. For free-design pages, `Composition` describes relationships, hierarchy, focus, and optional macro region/span suggestions — never element-level `x` / `y` / `width` / `height`, fixed gaps, or an authoring method; literal geometry is preserved only when the user requires it or a mirror/template contract owns it.
Optional Slide lines, added only when the capability earns a place (never an empty or `none` placeholder): `Mathematical content` (a valid delimiter-free LaTeX body — content authority for [`native-formula.md`](../references/native-formula.md), not a policy or marker; Executor chooses text, inline, or block); `Visualization` / `Images` when the Slide consumes §VII/§VIII or a page-local visual model, naming every value-driven geometry, cell grid, and child visual (only independent Chart/Table entries carry keys; qualitative relationships stay on the `Relationships` line, never a model name or grammar enum; §IX may choose a custom fallback; native construction is discovered by Executor, never a Design Spec field); `Motion suggestion` (purpose and semantic order/relationship, never registry keys, options, timing, ids, or coverage — it never activates execution, creates content, or binds implementation; required visible image states go in `Composition` / `Images`; a unit that visibly continues from the previous Slide is named here as a Morph candidate whether or not motion is enabled); keyed `Native-ready`; `Hyperlinks` when the page links — each linked text/object with its target, same-deck jumps as `#slide-N` and external destinations as the full URI, so the final checker can compare the page's anchor targets (not the linked wording) against the plan; `Fact IDs` for sourced claims; `Data class: scenario` for invented values. Except on preservation paths, `Cover impact` carries a binding hook and adaptable composition, and `Closing impact` the same split only when the deck genuinely resolves. Roster, order, content, and `Relationships` stay authoritative; §V/§IX layout, cover/closing composition, capability, motif, non-`ai` §VIII image-layout, and §VII directions are References under [`strategist.md`](../references/strategist.md) §1 Confirmed-value semantics. When the user, a template, or a resource contract requires such a property, write `(binding)` after the field label (`- **Composition (binding)**:`) and Executor follows it literally. For free-design pages, `Composition` describes relationships, hierarchy, focus, and optional macro region/span suggestions — never element-level `x` / `y` / `width` / `height`, fixed gaps, or an authoring method; literal geometry is preserved only when the user requires it or a mirror/template contract owns it.
---
@@ -28,7 +28,7 @@ Missing names, or one batch mixing the four stylistic libraries, exit non-zero;
<use data-icon="simple-icons/github" x="100" y="200" width="48" height="48" fill="#181717"/>
```
`data-icon` is `<library>/<icon-name>` (filename without `.svg`), case-sensitive because it resolves a real file — bundled names are canonical lowercase (`tabler-outline/award`), custom icons keep their file case; `x`, `y` position; `width`, `height` size (32–48px recommended); `fill` color. A complete `library/name` identifier is mandatory: bare names, abbreviated namespaces, paths into `templates/icons/`, and unsynced bundled files fail rather than fall back. `finalize_svg.py` embeds every placeholder during post-processing (`scripts/svg_finalize/embed_icons.py svg_output/*.svg` runs it manually).
`data-icon` is `<library>/<icon-name>` (filename without `.svg`), case-sensitive because it resolves a real file — bundled names are canonical lowercase (`tabler-outline/award`), custom icons keep their file case; `x`, `y` position; `width`, `height` size (32–48px recommended on a 1280-wide canvas; scale with canvas width); `fill` color. A complete `library/name` identifier is mandatory: bare names, abbreviated namespaces, paths into `templates/icons/`, and unsynced bundled files fail rather than fall back. `finalize_svg.py` embeds every placeholder during post-processing (`scripts/svg_finalize/embed_icons.py svg_output/*.svg` runs it manually).
## Searching for Icons
@@ -5,8 +5,8 @@
<defs>
<linearGradient id="hero-full-scrim-paint" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#000000" stop-opacity="0"/>
<stop offset="0.55" stop-color="#000000" stop-opacity="0.30"/>
<stop offset="1" stop-color="#000000" stop-opacity="0.78"/>
<stop offset="0.43" stop-color="#000000" stop-opacity="0.60"/>
<stop offset="1" stop-color="#000000" stop-opacity="0.80"/>
</linearGradient>
</defs>
<rect id="master-background" x="0" y="0" width="1280" height="720" fill="#0F172A"

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -5,7 +5,7 @@
<defs>
<linearGradient id="hero-side-scrim-paint" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#000000" stop-opacity="0.88"/>
<stop offset="0.55" stop-color="#000000" stop-opacity="0.30"/>
<stop offset="0.82" stop-color="#000000" stop-opacity="0.62"/>
<stop offset="1" stop-color="#000000" stop-opacity="0"/>
</linearGradient>
</defs>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -5,10 +5,12 @@
<defs>
<linearGradient id="triptych-top-paint" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#000000" stop-opacity="0.78"/>
<stop offset="0.68" stop-color="#000000" stop-opacity="0.62"/>
<stop offset="1" stop-color="#000000" stop-opacity="0"/>
</linearGradient>
<linearGradient id="triptych-bottom-paint" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#000000" stop-opacity="0"/>
<stop offset="0.43" stop-color="#000000" stop-opacity="0.62"/>
<stop offset="1" stop-color="#000000" stop-opacity="0.80"/>
</linearGradient>
</defs>

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -5,6 +5,7 @@
<defs>
<linearGradient id="grid-scrim-paint" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#000000" stop-opacity="0"/>
<stop offset="0.23" stop-color="#000000" stop-opacity="0.60"/>
<stop offset="1" stop-color="#000000" stop-opacity="0.80"/>
</linearGradient>
</defs>

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -5,6 +5,7 @@
<defs>
<linearGradient id="closing-full-scrim-paint" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#000000" stop-opacity="0"/>
<stop offset="0.40" stop-color="#000000" stop-opacity="0.60"/>
<stop offset="1" stop-color="#000000" stop-opacity="0.82"/>
</linearGradient>
</defs>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -49,7 +49,7 @@ remain downstream decisions.
|---|---|
| No safe frame for pictures | Every `picture` slot reaches at least one canvas edge: full canvas on `hero_full`, `hero_side_scrim`, `chapter_full`, `quote_over_image`, and `closing_full`; an exact half on the two `split_bleed` variants; equal vertical bands on `triptych`; four `640×360` quadrants on `image_grid_four`. Text keeps an 80 px margin, pictures keep none. Insetting a picture inside a margin turns this system into an ordinary content layout. |
| Scrims are Slide-local, by PowerPoint's rule | PowerPoint paints Layout shapes beneath Slide content, and the picture is Slide content, so an overlay cannot be a Layout atom. Every scrim is therefore a Slide-local `data-pptx-role="decoration"` rect placed after its picture slot in document order. It travels as a prototype pattern, not as Layout-inherited chrome, and a page authored from this system must carry its own scrim. |
| Four overlay techniques | `hero_full` and `closing_full` use a bottom vertical fade (black `0 → 0.78 / 0.82`). `hero_side_scrim` uses a horizontal directional scrim (`0.88 → 0.30 → 0`) so the picture stays readable on the open side. `chapter_full` uses a full-canvas graded wash (`0.44 → 0.68`) that darkens toward its text. `quote_over_image` uses a raised-floor radial vignette (`0.42 → 0.72`) so a centered quote holds contrast while the frame edges fall away. |
| Four overlay techniques | `hero_full` and `closing_full` use a bottom vertical fade (black `0 → 0.60 → 0.80 / 0.82`, the middle stop sitting where the title slot begins). `hero_side_scrim` uses a horizontal directional scrim (`0.88 → 0.62 → 0`, holding through the text slots' right edge) so the picture stays readable on the open side. Every fade keeps at least `0.60` behind its own text slot and falls away only past it. `chapter_full` uses a full-canvas graded wash (`0.44 → 0.68`) that darkens toward its text. `quote_over_image` uses a raised-floor radial vignette (`0.42 → 0.72`) so a centered quote holds contrast while the frame edges fall away. |
| Overlays are gradients, never solid rects | A full-canvas solid `<rect>` is reserved for background ownership in a structured package and is compiled into the background plane, which sits below every picture. Every overlay here therefore carries a gradient paint even where the intent reads as a flat wash. Replacing one with a solid fill plus `fill-opacity` inverts its stacking and fails template validation. |
| Dark base plane | The single Master carries a dark background. The reusable structural fact is that the base plane is dark because text sits on imagery and reverses out of it; the prototype hex is replaceable preview paint, not an identity value. It is visible only where no picture covers it: the text half of the split pages, and all of `full_statement`. |
| A page without a picture | `full_statement` carries no picture slot. A system built entirely on imagery needs one page that stops, or every page competes for the same attention. |
@@ -20,7 +20,7 @@
data-pptx-bounds="88 176 712 408">
<g id="chart-insight-chart-carrier" data-pptx-carrier="true"
data-pptx-replace-with="chart" data-pptx-fallback-sha256="e39e729fbcd88647bc948bf86f6974592347d171897ee0568376b13330fdb95b">
<metadata type="application/json">{"name":"chart_insight","x":88,"y":176,"width":712,"height":408,"type":"column","categories":["Q1","Q2","Q3","Q4"],"series":[{"name":"Actual","values":[42,55,63,78]},{"name":"Plan","values":[48,58,68,74]}],"show_legend":true,"legend_position":"bottom","style":{"colors":["#64748B","#CBD5E1"],"chart_area_fill":"#F4F6F8","plot_area_fill":"#FFFFFF","text_color":"#475569","axis_color":"#CBD5E1","grid_color":"#E2E8F0","font_family":"Arial"}}</metadata>
<metadata type="application/json">{"name":"chart_insight","x":88,"y":176,"width":712,"height":408,"type":"column","categories":["Q1","Q2","Q3","Q4"],"series":[{"name":"Actual","values":[42,55,63,78]},{"name":"Plan","values":[48,58,68,74]}],"show_legend":true,"legend_position":"bottom","style":{"colors":["#64748B","#CBD5E1"],"chart_area_fill":"#F4F6F8","plot_area_fill":"#FFFFFF","text_color":"#64748B","axis_color":"#E2E8F0","grid_color":"#E2E8F0","font_family":"Arial"}}</metadata>
<rect x="88" y="176" width="712" height="408" rx="8" fill="#FFFFFF"/>
<rect x="586" y="202" width="12" height="12" rx="2" fill="#64748B"/>
<text x="606" y="213" fill="#64748B" font-family="Arial, sans-serif" font-size="14">Actual</text>

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

@@ -20,7 +20,7 @@
data-pptx-bounds="88 176 776 408">
<g id="table-summary-table-carrier" data-pptx-carrier="true"
data-pptx-replace-with="table" data-pptx-fallback-sha256="6537e9228a8e8691fd1870f794329de56b7c51a8c6c40fe4e37d2d8963659f61">
<metadata type="application/json">{"schema":"ppt-master.semantic-table.v2","name":"table_summary","x":88,"y":176,"width":776,"height":408,"strict_grid":true,"column_widths":[3,2,2],"row_heights":[1,1,1,1,1],"style":{"font_family":"Arial","font_size":17,"header_font_size":16,"header_fill":"#E2E8F0","header_text":"#334155","body_fill":"#FFFFFF","body_text":"#475569","band_fill":"#F8FAFC","border_color":"#D6DCE3","border_width":1,"padding":{"left":12,"right":12,"top":6,"bottom":6},"valign":"middle"},"columns":[{"text":"Metric","bold":true},{"text":"Current","align":"r","bold":true},{"text":"Target","align":"r","bold":true}],"rows":[[{"text":"Adoption"},{"text":"68%","align":"r","bold":true},{"text":"80%","align":"r"}],[{"text":"Cycle time"},{"text":"12 d","align":"r","bold":true},{"text":"8 d","align":"r"}],[{"text":"Quality"},{"text":"94%","align":"r","bold":true},{"text":"97%","align":"r"}],[{"text":"Cost index"},{"text":"1.08","align":"r","bold":true},{"text":"1.00","align":"r"}]]}</metadata>
<metadata type="application/json">{"schema":"ppt-master.semantic-table.v2","name":"table_summary","x":88,"y":176,"width":776,"height":408,"strict_grid":true,"column_widths":[3,2,2],"row_heights":[72,84,84,84,84],"style":{"font_family":"Arial","font_size":17,"header_font_size":16,"header_fill":"#E2E8F0","header_text":"#334155","body_fill":"#FFFFFF","body_text":"#475569","band_fill":"#F8FAFC","border_color":"#D6DCE3","border_width":1,"padding":{"left":12,"right":12,"top":6,"bottom":6},"valign":"middle"},"columns":[{"text":"Metric","bold":true,"align":"l"},{"text":"Current","align":"r","bold":true},{"text":"Target","align":"r","bold":true}],"rows":[[{"text":"Adoption"},{"text":"68%","align":"r","bold":true,"color":"#334155"},{"text":"80%","align":"r"}],[{"text":"Cycle time","fill":"#F8FAFC"},{"text":"12 d","align":"r","bold":true,"fill":"#F8FAFC","color":"#334155"},{"text":"8 d","align":"r","fill":"#F8FAFC"}],[{"text":"Quality"},{"text":"94%","align":"r","bold":true,"color":"#334155"},{"text":"97%","align":"r"}],[{"text":"Cost index","fill":"#F8FAFC"},{"text":"1.08","align":"r","bold":true,"fill":"#F8FAFC","color":"#334155"},{"text":"1.00","align":"r","fill":"#F8FAFC"}]]}</metadata>
<rect x="88" y="176" width="776" height="408" rx="8" fill="#FFFFFF" stroke="#D6DCE3" stroke-width="1"/>
<rect x="88" y="176" width="776" height="72" rx="8" fill="#E2E8F0"/>
<rect x="88" y="248" width="776" height="84" fill="#FFFFFF"/>

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff. Show more