@lark-apaas/coding-steering 0.1.32 → 0.1.33-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. package/package.json +1 -1
  2. package/steering/design-html/skills/pptx-style-extract/SKILL.md +58 -22
  3. package/steering/design-html/skills/pptx-style-extract/font-fallback.yaml +3 -3
  4. package/steering/design-html/skills/pptx-style-extract/scripts/census.py +18 -12
  5. package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +153 -8
  6. package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +1768 -241
  7. package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +325 -22
  8. package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +1 -1
  9. package/steering/design-html/skills/pptx-style-extract/scripts/package.py +379 -156
  10. package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +6 -3
  11. package/steering/design-html/skills/pptx-style-extract/scripts/query.py +4 -9
  12. package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +16 -10
  13. package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +57 -0
  14. package/steering/design-html/skills/pptx-style-extract/scripts/test_color_contract.py +60 -0
  15. package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +62 -0
  16. package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +378 -0
  17. package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +98 -0
  18. package/steering/design-html/skills/pptx-style-extract/scripts/test_rounded_contract.py +112 -0
  19. package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +168 -0
  20. package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +27 -15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/coding-steering",
3
- "version": "0.1.32",
3
+ "version": "0.1.33-beta.0",
4
4
  "description": "Stack-specific steering content for miaoda-coding templates",
5
5
  "type": "module",
6
6
  "files": [
@@ -1,21 +1,27 @@
1
1
  ---
2
2
  name: pptx-style-extract
3
- description: 从 PPTX/POTX 模板抽取 deck 风格包 v2(design.md + layouts.md + assets/ + ref/)。当用户上传 PPTX/POTX 并要求按附件模板、参考 PPT 风格、保持同款视觉、生成演示/PPT/deck 时,必须先用本 skill 抽取风格;不要只读附件文本摘要后开工。
3
+ description: 从 PPTX/POTX 附件抽取 deck 风格包 v2(design.md + layouts.md + assets/ + ref/)。**对话里出现 .pptx / .potx 附件就先跑本 skill**——默认它是要参考的模板,不需要用户明说「按这个风格」。只有用户点明了另一种意图(只要翻译、只要总结、只要提取文字、要改这份文件本身)才跳过。附件文本摘要不能替代抽取。
4
+ metadata:
5
+ display-names:
6
+ zh-CN: PPT 模板风格抽取
7
+ en-US: PPTX Style Extract
4
8
  ---
5
9
 
6
10
  # pptx-style-extract(PPT 模板风格抽取)
7
11
 
8
- 一份 PPTX → 一个风格包:`manifest.json`(机器清单)+ `design.md`(消费入口)+ `layouts.md` + `assets/` + `ref/`。**机械动作全部由脚本承担**(解析、普查、聚类、草案、落盘、门禁),你只做机器做不了的三件事:**认图、命名、写气质**。
12
+ 一份 PPTX → 一个风格包:`manifest.json`(机器清单)+ `design.md`(消费入口)+ `layouts.md` + `assets/` + `ref/`。
13
+
14
+ 三步依次跑完才有这个包:**抽取**(脚本出草案)→ **判断**(你改草案里的 TODO——认图、命名、写气质)→ **打包**(脚本产出 `design.md` + `layouts.md`)。deck 的版式坐标、资产路径、logo 出现在哪些页型,全部从包里读。
9
15
 
10
16
  脚本路径相对本 skill 根目录;不做环境检测,依赖缺失脚本自己降级并在输出里报。
11
17
 
12
18
  ## When To Use This Skill
13
19
 
14
- Use this skill before making a slide deck when a `.pptx` or `.potx` attachment is meant as a template, visual reference, brand/style source, or "make it like this" example.
20
+ 对话里出现 `.pptx` / `.potx` 附件,**默认它是要参考的模板**——先跑本 skill 抽风格,再动手做 deck。用户上传一份 PPT 却不说要干什么时,绝大多数情况就是「照着这个做」;等他明说才抽,等于把默认值设反了。
15
21
 
16
- Do not treat `SummarizeAttachmentOrFile` output as a substitute for this skill. Attachment text summaries lose theme colors, fonts, slide master geometry, layout coordinates, and reusable visual assets.
22
+ 只有用户点明了另一种意图才跳过:只要翻译、只要总结、只要提取文字、要修改这份文件本身。
17
23
 
18
- If the user only asks to summarize, translate, extract text, or review an existing PPTX, do not run this skill unless they also ask to reuse its visual style.
24
+ `SummarizeAttachmentOrFile` 的文本摘要不能替代抽取——它丢掉主题色、字体、母版几何、版式坐标和可复用的视觉资产,正是做 deck 最需要的那些。
19
25
 
20
26
  ## Runtime Contract
21
27
 
@@ -23,8 +29,8 @@ If the user only asks to summarize, translate, extract text, or review an existi
23
29
  - Pillow may be present and improves image hashing, palette extraction, contact sheets, and asset transcoding. If Pillow is missing or fails, continue with degraded extraction and record the degradation in outputs.
24
30
  - Do not run `pip install`, `uv pip install`, `npm install`, or any other dependency installation in the user session.
25
31
  - Run Python with bytecode disabled: `PYTHONDONTWRITEBYTECODE=1 python3 -B ...`.
26
- - Default working directory: `/tmp/pptx-style-extract/<unique-id>/`.
27
- - Do not write extraction intermediates into `/home/gem/workspace/code` or the user app source tree. Only the final deck assets copied by the deck authoring step should enter the project.
32
+ - Default working directory: `tmp/pptx-style-extract/<unique-id>/`, **relative to the project root**. The sandbox file tools (`read_file` / `write` / `multi_edit`) only accept project-relative paths — an absolute `/tmp/...` workdir makes every draft edit fall back to hand-written shell scripts.
33
+ - Intermediates stay under the project's `tmp/`; they are scratch, not deliverables. Only the final deck assets copied by the deck authoring step enter the app source tree.
28
34
  - `package.py` only runs v1 checks when `--check-v1 <path>` is passed or `DSM_V1_DIR` points to a sibling checker. It must not rely on developer-machine paths in the sandbox.
29
35
 
30
36
  ## Fast Path Contract
@@ -39,40 +45,69 @@ Generated packages must make the attachment-consumption path explicit in `design
39
45
 
40
46
  ## 1. 抽取
41
47
 
48
+ 附件的真实路径通常已经在用户消息的 `<<< 附件信息 >>>` 块里给出(`附件已保存到 <path>`)——直接用那个路径。**只有**消息里没给路径时才列目录,用列出来的名字,不要按附件标题猜文件名:
49
+
42
50
  ```bash
43
- mkdir -p /tmp/pptx-style-extract/<unique-id>
44
- PYTHONDONTWRITEBYTECODE=1 python3 -B scripts/extract.py <pptx_path> /tmp/pptx-style-extract/<unique-id>/stage1
51
+ ls -la .agent/<conversation_id>/attachments/
45
52
  ```
46
53
 
54
+ 一条命令,写成单行并带 `2>&1`:
55
+
56
+ ```bash
57
+ mkdir -p tmp/pptx-style-extract/<unique-id> && PYTHONDONTWRITEBYTECODE=1 python3 -B scripts/extract.py "<真实路径>" tmp/pptx-style-extract/<unique-id>/stage1 2>&1
58
+ ```
59
+
60
+ 输出最后一行必是这两个哨兵之一,照它走,不要用 `ls` 去探查目录——那会白花好几轮还是查不出原因:
61
+
62
+ - `EXTRACT_OK <outdir>/l-out` — 跑完了,进第 2 步
63
+ - `EXTRACT_PARTIAL ...` — 普查产物齐全、只有草案挂了,按它给的命令单独重跑 `draft.py`,不要重跑整条抽取
64
+ - 两个都没有 — 输出被截断(退出码可能仍是 0),原样重跑一次
65
+
66
+ **目录里没有 `.pptx` / `.potx`**(只有 `skills/` 之类)说明这次上传没有落成沙箱本地文件,本 skill 无法执行:如实告诉用户模板文件在沙箱里取不到、请重新上传或反馈平台,然后停下。此时**不要**用 `summarize_attachment_or_file` 的文本摘要当风格来源,也不要用 `generate_image` 自造配图充当模板视觉——那样产出的 deck 与模板无关,却看起来像成功了。
67
+
47
68
  一条命令出全部:`extract.json`(普查数值)、`media-out/`(候选图)、`ref/`(审计层)、`l-out/`(**四件判断单草案 + BRIEF.md + contact-sheet.png + layout-sheet.png**)。
48
69
 
49
70
  非 OOXML / 损坏 / 加密 → 报告调用方,不产半成品。
50
71
 
51
72
  ## 2. 判断
52
73
 
53
- 1. 读 `<outdir>/l-out/BRIEF.md` —— 事实、草案依据、待判断清单一页看完。
74
+ 1. 读 `<outdir>/l-out/BRIEF.md` —— 事实、草案依据、待判断清单一页看完。清单是实时扫出来的,**只列真正还有 TODO 的文件**,没列到的那份不用打开。
54
75
  2. 看 `<outdir>/l-out/contact-sheet.png` 与 `<outdir>/l-out/layout-sheet.png` —— 候选图和代表页型都已拼好,对应 BRIEF 的表。
55
- 3. 用一次批量编辑/patch 改掉四件草案里的每一处 `TODO`:风格命名与气质、页型中文名、logo 与封面归属、Colors 用途列、模板特有硬规则。
56
- 4. 对每张真实背景图补 `text_safe` / `avoid` 判断:标清背景视觉主体、强光斑、深色透明区等禁放区,并把每个 archetype 与适配背景绑定;标题、正文、关键数字、图表、卡片、时间线及其容器的外接矩形都不得压住背景主体,透明容器也不能跨进禁放区。
76
+ 3. `multi_edit` 一次批量改掉四件草案里的每一处 `TODO`:风格命名与气质、页型角色、`text_roles` 里的文本角色、`layout_modes` 里的布局模式、以及下面第 4 条的禁放区。文本角色只按模板中实际存在的文字判断为 `title|subtitle|header|footer|body`;判断不清就填 `body`,不补模板里没有的标题、页眉或页脚。草案就在项目内相对路径下,直接编辑,**不要写脚本去改 YAML**。看过重建图后真有这套模板特有的硬规则(例如主色只许用在哪类元素)再往 `## Hard Rules` 补一条;没有就不补,别为凑数编规则。
77
+ 4. `layouts.yaml` 顶部 `bg_rules:` 按**背景资产**分组(不是按页型),逐张背景填 `text_safe` / `avoid` / `pairing_rule`:看图标清视觉主体、强光斑、深色透明区,打包时自动并入用该背景的所有页型。标题、正文、关键数字、图表、卡片、时间线及其容器的外接矩形都不得压住背景主体,透明容器也不能跨进禁放区。
78
+
79
+ `layouts.yaml` 的 `layouts:` 段是普查数值——页型、坐标、字号、色值、对齐、以及各页型自带的 `role: logo` 资产槽全部直读,**一个字都不要动**。模板自己给版式起过名时 `names:` 已经填好,读一遍确认表意即可。
57
80
 
58
81
  判断口径:
59
82
 
60
83
  - **满屏图默认属背景族**——整幅替换底图的艺术图/摄影图就是背景(首页那张记 `role: cover`);「内容图不进包」只指内容区里的图表、截图、配图。
61
84
  - **logo 宁缺勿错**——看图确认有品牌文字或标志图形才留;拿不准就把该条从 `manifest.yaml` 删掉,在 `gaps` 写一句,候选图留在 `media-out/`。
85
+ - **anchors 是事实不是结论**——草案里每条只报测到的数(覆盖率、计数、占比),没有「这套风格的特征是 X」这种断言。这一段在 design.md 里读起来像总纲,消费端会照它建全局样式,所以脚本不敢替你下结论:一条 1/8 覆盖率的元素被描述成「跨页不动」,消费端就会每页都摆它。看过重建图后,把真正是这套模板特征的那几条改写成设计要点,不是特征的直接删掉。
62
86
  - **数值只改名不改值**——草案里的坐标/色值/字号来自普查;确需推导值(CJK 行高转译、投影尺度上抬)在 manifest 写 `derived:` 声明理由,机检认声明。
63
- - **页型按用途命名**——草案按背景+结构聚类,名字(`cover`/`section`/`content-dense`)只是占位,按 BRIEF 里的 slot 原文改成表意的中文名,role 跟着改。
64
- - **背景和版式要配对**——真实背景不是纯色底。看 `contact-sheet.png` 判断每张背景的视觉主体/禁放区,看 `layout-sheet.png` 判断文字与卡片落点;在 `layouts.yaml` 给每个页型写清 `background`、`text_safe`、`avoid`、`pairing_rule`,避免消费 Agent 把文字、图表、卡片、时间线、标题容器、正文容器或宽透明容器的外接矩形压到金字塔、人物、产品图、强光斑或深色区域上。
87
+ - **页型命名**——模板自带版式名时草案已填好;只有靠样张聚类的模板(`names` 里还是 TODO)才需要按 BRIEF slot 原文起中文名。
88
+ - **页型角色**——`roles:` 段有 TODO 时,看 `layout-sheet.png` 上对应的代表页定 `cover|section|content|quote|closing|blank|custom`。草案只把客观事实摆在注释里(代表页页码、页数、文字块数、字号序列、图片数、有无满屏底图),不替你下结论:字号多大算章节页、文字块多少算密集页,每套模板的答案都不一样。
89
+ - **文本角色**——`text_roles:` 段有 TODO 时,结合重建图和该行给出的来源、占位符、样例文字、box、字号与 CSS 判断标题、副标题、页眉、页脚或正文。这里仅改变已有文本槽的语义;所有文本槽、图片、容器与装饰仍由 `layouts:` 保留。标题、页眉或页脚已在背景中可见时,在 Hard Rules 写明对应页型只渲染已有 slots,不重复叠加文字。
90
+ - **资产位置不要写成全局规则**——同一个 logo 常按页型换位换尺寸(封面一个位置一个尺寸、内容页另一个)。位置只存在于 `layouts.md` 各页型的 `slots`,正文里只说「按该页型的 slot 摆放,没有就不放」。
91
+ - **背景和版式要配对**——真实背景不是纯色底。看 `contact-sheet.png` 判断每张背景的视觉主体/禁放区,看 `layout-sheet.png` 判断文字与卡片落点;`background` 草案已按版式直读填好,你只需在 `bg_rules` 里逐张背景补 `text_safe`、`avoid`、`pairing_rule`,避免消费 Agent 把文字、图表、卡片、时间线、标题容器、正文容器或宽透明容器的外接矩形压到金字塔、人物、产品图、强光斑或深色区域上。
65
92
  - 双主题只认 BRIEF 的 `themes`;单主题包不写 `theme` 字段。
66
93
 
94
+ 判断单里的**结构事实**(每个页型上方的注释)是判「这页该用绝对坐标还是流式」的依据:栅格几列、垂直间距序列(突变处即区带边界)、样张里每个文字槽的实际字数、命中哪个容器样式配方。文件头另有容器样式配方清单,带出现次数与跨页数——跨页多的是共性风格,只在一处出现的多半不是。
95
+
96
+ **页型给 `flow` 还是 `slots`,由你定**:内容长度会变的内容页(列表、表格、卡片组)优先用 `flow`,区带依次排,高度由实际内容决定;构图固定的页(封面、章节页)用 `slots` 绝对坐标。在扁平 `layout_modes:` 段为每个候选页型填 `flow` 或 `slots`,打包时按它落一份。判据就在该页型上方的结构事实里:规整多列栅格、等距重复的间距、成对出现的长文本槽都指向 `flow`。`flow` 中并列卡片以 `grid` 表达,每张卡片的容器、标题和正文放在同一个一层 `role: group` 中;logo、页码、页眉和页脚等固定锚点留在带 `box` 的 `free` 区带。模型结合实际内容判断纵向间距与留白,不机械照搬样张的 `y` 坐标。
97
+
98
+ 普查是有损的。判断不了时可以直接翻 `<outdir>/ref/source/` —— PPTX 解压后的原文都在那里(`ppt/slides/slideN.xml`、`ppt/slideLayouts/`、`ppt/theme/`)。它只在中间产物里,不进交付包。
99
+
67
100
  要更多依据时用 `PYTHONDONTWRITEBYTECODE=1 python3 -B scripts/query.py <outdir> <子命令>`(`shapes` / `colors` / `fonts` / `text-scale` / `images` / `clusters` / `media` / `slides` / `layouts` / `recipes` / `grids` / `get <点路径>`)。读数一律走它,**不写解析脚本、不读 XML、不开浏览器**。
68
101
 
69
102
  ## 3. 打包
70
103
 
71
104
  ```bash
72
- PYTHONDONTWRITEBYTECODE=1 python3 -B scripts/package.py <outdir> <outdir>/l-out <包输出目录>
105
+ PYTHONDONTWRITEBYTECODE=1 python3 -B scripts/package.py <outdir> <outdir>/l-out <包输出目录> --force
73
106
  ```
74
107
 
75
- exit 0 即交付。FAIL 会指名道姓(TODO 未改 / 数值不可追溯 / 枚举误用 / 引用断链 / 体量超标),改判断单重跑,不要去改产物;体量 FAIL 走降级链(审计出包 → sidecar 化 → archetype 收缩)。
108
+ exit 0 即交付。FAIL 会指名道姓(TODO 未改 / 数值不可追溯 / 枚举误用 / 引用断链 / 体量超标),改判断单后**用同一个包目录重跑**(`--force` 就是为回修准备的),不要去改产物;体量 FAIL 走降级链(审计出包 → sidecar 化 → archetype 收缩)。
109
+
110
+ 跑之前先确认判断单里 `TODO` 已经全部改完(`grep -rn TODO <outdir>/l-out`)——`body.md` 最容易漏。改完直接跑 package.py,不用先 `ls` 确认目录。
76
111
 
77
112
  ## 完成判据
78
113
 
@@ -82,7 +117,7 @@ exit 0 即交付。FAIL 会指名道姓(TODO 未改 / 数值不可追溯 / 枚
82
117
  - [ ] Agent Fast Path 写清 `assetRoot` / `assetPaths` 的不透明前缀协议,并禁止目录探索。
83
118
  - [ ] 抽不出的都在 `gaps`,无编造
84
119
 
85
- 配套 `v2-format-spec.md`(包形态与 schema)、`font-fallback.yaml`(商业字体降级表)只在你要偏离草案结构时才需要读——草案已经是最终格式。
120
+ 配套 `v2-format-spec.md`(包形态与 schema)、`font-fallback.yaml`(商业字体降级表)只在你要偏离草案结构时才需要读——草案的字段结构与包一致,`package.py` 按它落盘。
86
121
 
87
122
  ## Consume The Style Pack In A Deck
88
123
 
@@ -93,12 +128,13 @@ Read `<pack_dir>/design.md` first, especially `## Usage`, `## Hard Rules`, color
93
128
  When generating a deck:
94
129
 
95
130
  1. Call `copy_starter_component` with `kind: "deck-stage.js"`.
96
- 2. Build `<deck-stage width="1920" height="1080">`; each slide is one static `<section>`.
131
+ 2. Build `<deck-stage width="<canvas width>" height="<canvas height>">` using the `canvas` declared in `layouts.md` — source decks are not always 16:9, and a default-sized stage shifts every coordinate on the page. Each slide is one static `<section>`.
97
132
  3. Inline CSS variables from `design.md` into the HTML `<style>` block using a `--ppt-*` prefix.
98
- 4. Map `layouts.md` slots `[x,y,w,h]` to absolute-positioned elements inside each section. Do not reflow them as generic web grids.
99
- 5. Copy `<pack_dir>/assets/` into the project, for example `assets/pptx-style/<pack-name>/`, and reference those copied files with relative URLs. Never reference `/tmp/pptx-style-extract/...` from final HTML.
100
- 6. Use the font stacks and fallback rules from `design.md`; do not install fonts or dependencies at runtime.
101
- 7. Run the slide preflight checks: no resource failures, no section overflow, and sampled screenshots follow the package colors, typography, layouts, assets, and Hard Rules.
133
+ 4. **位移动画用独立的 `translate` 属性**:`@keyframes fadeUp { from{opacity:0; translate:0 24px} to{opacity:1; translate:0 0} }`。`transform` 是单一属性,动画里碰它会覆盖掉元素原有的那条(`left:50%; transform:translateX(-50%)` 的居中就此丢失);`translate` / `rotate` / `scale` 各自独立,与已有 `transform` 叠加。
134
+ 5. Map `layouts.md` slots to absolute-positioned elements inside each section: expand `box: [x,y,w,h]` mechanically to `left/top/width/height`, then apply the slot's `css` declaration string unchanged. `box` owns geometry; `css` owns all rendering style, including the template's text padding, typography, alignment, line height, letter spacing, and rotation. Do not reinterpret PPTX fields or replace slot CSS with your own type scale. A slot with `asset` is a fixed image element (logo, mark): place that asset at that box on that archetype only. Do not reflow any of this as generic web grids.
135
+ 6. Copy `<pack_dir>/assets/` into the project, for example `assets/pptx-style/<pack-name>/`, and reference those copied files with relative URLs. The scratch `tmp/pptx-style-extract/...` path must never appear in the final HTML.
136
+ 7. Use the font stacks and fallback rules from `design.md`; do not install fonts or dependencies at runtime.
137
+ 8. Run the slide preflight checks: no resource failures, no section overflow, and sampled screenshots follow the package colors, typography, layouts, assets, and Hard Rules.
102
138
 
103
139
  ## Export Consumer Attachments
104
140
 
@@ -10,10 +10,10 @@
10
10
 
11
11
  version: 2
12
12
 
13
- # fallback 用到的 web 字体实测状态(镜像 https://miaoda.feishu.cn/fonts/css2)。
14
- # verified: true = 该镜像已 curl 实测返回 @font-face(2026-08-06)。
13
+ # fallback 用到的 web 字体探测状态(镜像 https://miaoda.feishu.cn/fonts/css2)。
14
+ # verified: true = 该镜像已 curl 验证返回 @font-face(2026-08-06)。
15
15
  # 状态过期不影响正确性:L4 对 verified 为 false 或存疑的字体按 dsm-v1-spec 字体条款
16
- # 重新 curl 实测后再用,加载不到降到下一档。
16
+ # 重新 curl 验证后再用,加载不到降到下一档。
17
17
  web-fonts:
18
18
  "Noto Sans SC": {verified: true}
19
19
  "Noto Serif SC": {verified: true}
@@ -11,11 +11,16 @@ from ooxml import (NS, alias_group, family_of, local, luminance, raw_token, read
11
11
  # 1920px canvas; fullscreen detection has its own independent threshold.
12
12
  EPS_PX = 0.005 * 1920 # 9.6 px
13
13
  FULLSCREEN_MIN_PCT = 95.0 # 上不封顶(尺寸维度,兼容旧口径)
14
- FULLSCREEN_COVERAGE = 0.95 # 画布覆盖率判据(2026-08-05 用户 case:溢出/略未贴边都算背景,但偏移出画布的大图不算)
15
- REPEAT_MIN = 2
14
+ FULLSCREEN_COVERAGE = 0.95 # 画布覆盖率判据:溢出/略未贴边都算背景,偏移出画布的大图不算
15
+ REPEAT_MIN = 2 # 出现 <2 次谈不上「重复位」
16
16
 
17
+ # 下面三个跨模块共用,import 处不要再抄一份字面量
18
+ SMALL_IMG_W_PCT = 25.0 # 占画布宽小于此值算小图(图标/角标/logo),不是内容配图
19
+ LUM_MID = 0.5 # 深/浅分界,Rec.709 相对亮度
20
+ ASSET_WARN_SINGLE = 500 * 1024 # 单张压缩后体积的 WARN 线,对齐 v2-format-spec §5 V2-6
17
21
 
18
- def _canvas_coverage(box, cw=1920.0, ch=1080.0):
22
+
23
+ def canvas_coverage(box, cw, ch):
19
24
  """图片与画布交集面积 / 画布面积。溢出(bleed)交集封顶于画布,天然 ≤1。"""
20
25
  if not box:
21
26
  return 0.0
@@ -99,8 +104,10 @@ def image_census(shape_recs, bg_images, units):
99
104
  'in_group': any(o.get('in_group') for o in c['occs']),
100
105
  })
101
106
  clusters.sort(key=lambda c: -c['count'])
102
- fs = [o for o in occs if _canvas_coverage(o.get('box')) >= FULLSCREEN_COVERAGE]
103
- fs_clusters = [c for c in clusters if _canvas_coverage(c.get('box')) >= FULLSCREEN_COVERAGE]
107
+ cw, ch = float(units.w), float(units.h)
108
+ fs = [o for o in occs if canvas_coverage(o.get('box'), cw, ch) >= FULLSCREEN_COVERAGE]
109
+ fs_clusters = [c for c in clusters
110
+ if canvas_coverage(c.get('box'), cw, ch) >= FULLSCREEN_COVERAGE]
104
111
  crop_sigs = {_crop_sig(o.get('crop')) for o in occs} - {''}
105
112
  images.append({
106
113
  'media': media,
@@ -157,11 +164,10 @@ def _crop_sig(crop):
157
164
  # ------------------------------------------ S5b media content clustering (素材聚类)
158
165
  # Two levels: sha256 byte identity, then perceptual identity. The perceptual level
159
166
  # is a dHash *prefilter* followed by a pixel confirmation, because dHash alone does
160
- # not separate these samples: measured pairs sit at distance 0 with 29.5 mean
161
- # channel difference (pipelinepro image-1-1 vs image-4-1, genuinely different) and
162
- # at distance 7 with 0.54 (image-2-1 vs image-4-1, genuinely the same). No single
163
- # hamming threshold splits that, so the hash only narrows the candidate set and the
164
- # thumbnail difference decides. Calibration table is in reports/agent-extract-round2.md.
167
+ # not separate them on its own: distinct images can land at hamming distance 0
168
+ # while identical ones land several bits apart, so no single hamming threshold
169
+ # splits the two populations. The hash therefore only narrows the candidate set
170
+ # and the thumbnail pixel difference makes the call.
165
171
  DHASH_PREFILTER_MAX = 10 # hamming distance over the 64-bit dHash
166
172
  PIXDIFF_MAX = 5.0 # mean per-channel |Δ| over the 64x64 thumbnail
167
173
  THUMB_PX = 64
@@ -546,7 +552,7 @@ def font_census(shape_recs, txstyles_by_master, themes, scheme_by_part=None):
546
552
  # A paragraph with no runs at all (only <a:endParaRPr/>) declares a
547
553
  # default that renders no glyph, so it is counted under its own source
548
554
  # and kept out of `rendered_n` — otherwise a face backing zero visible
549
- # text can outrank the deck's actual typeface (volcano: 975 such).
555
+ # text can outrank the deck's actual typeface.
550
556
  dr = p.get('defRPr') or {}
551
557
  if dr:
552
558
  covered = {slot for slot in ('latin', 'ea', 'cs')
@@ -875,7 +881,7 @@ def fit_grids(shape_recs):
875
881
  """逐页把同类形状拟合成栅格:列数/列起点/列步距 + 行数/行步距。
876
882
 
877
883
  按**中心**分档而不是按左上角——一排 logo 尺寸各不相同却居中对齐于等宽格,
878
- 用左上角会看不出列(volcano 的 logo 墙就是这个形态,人工数错成 5 列)。
884
+ 用左上角看不出列。
879
885
  因此这里也不要求同尺寸,只要求同页同 kind 同层级。
880
886
  """
881
887
  buckets = defaultdict(list)
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """风格包 v2 机器门禁(镜像 ../v2-format-spec.md §5 十四行:V2-1..V2-13 + V2-R5)。
2
+ """风格包 v2 机器门禁(镜像 ../v2-format-spec.md §5V2-1..V2-16 + V2-R5/R6/R7)。
3
3
 
4
4
  用法: check_v2.py <包目录>
5
5
  只做 v2 追加校验;check_v1 的规则不重复实现,调用方须先跑 check_v1.py。
@@ -36,7 +36,7 @@ V2_SECTIONS = ['themes', 'default-theme', 'theme-mechanism', 'color-confidence',
36
36
  # —— V2-11 YAML 1.1 布尔字面量(PyYAML 会把这些键名解析成 True/False)——
37
37
  BOOL_LITERALS = {'y', 'yes', 'n', 'no', 'true', 'false', 'on', 'off'}
38
38
 
39
- # —— V2-6 体积上限(D5b 修订 2026-08-05;KB = 1024)——
39
+ # —— V2-6 体积上限(KB = 1024)——
40
40
  ASSET_WARN_SINGLE = 500 * 1024 # 压缩图单张(受检对象 = 条目 path 指向的包内文件)
41
41
  ASSET_MAX_TOTAL = 20 * 1024 * 1024 # 包内资产总量(assets/** ∪ 条目 path/full 并集)
42
42
 
@@ -305,7 +305,7 @@ class MdFile:
305
305
  self.keys += [(k, n + offset) for k, n in sub.keys]
306
306
 
307
307
  def section(self, key):
308
- """先取 frontmatter,再取正文围栏块(claude-design layouts 在围栏块里)。"""
308
+ """先取 frontmatter,再取正文围栏块——存量风格包有把 layouts 写在正文围栏块里的。"""
309
309
  if isinstance(self.data.get(key), (dict, list)):
310
310
  return self.data[key]
311
311
  for block in self.fenced:
@@ -632,7 +632,7 @@ def _pack_asset_files(pack):
632
632
 
633
633
 
634
634
  def rule_v2_6(pack):
635
- """V2-6 压缩图单张 >500KB WARN;包内资产总量 >20MB FAIL(D5b 修订)"""
635
+ """V2-6 压缩图单张 >500KB WARN;包内资产总量 >20MB FAIL"""
636
636
  res = Result('V2-6', '压缩图单张 >500KB WARN;包内资产总量 >20MB FAIL')
637
637
  # 500KB 只管压缩图 = 条目 path 指向的包内文件;
638
638
  # full(原图)天然大,豁免单张 WARN 但计入总量;url 条目包内无文件,不适用。
@@ -871,9 +871,147 @@ def rule_layouts_pointer(pack):
871
871
  return res
872
872
 
873
873
 
874
+ ENUM_REGION_KIND = ['grid', 'stack', 'free']
875
+
876
+
877
+ def _box4(v):
878
+ """[x, y, w, h] 四个数且 w/h 为正才算合法框,否则 None。
879
+
880
+ 门禁只该产出 PASS/WARN/FAIL。框里混进字符串会让下面的比大小直接抛 TypeError,
881
+ 脚本崩了比报 FAIL 更难查;静默跳过又会让畸形框冒充通过。
882
+ """
883
+ if not (isinstance(v, list) and len(v) == 4):
884
+ return None
885
+ if any(isinstance(x, bool) or not isinstance(x, (int, float)) for x in v):
886
+ return None
887
+ return v if v[2] > 0 and v[3] > 0 else None
888
+
889
+
890
+ def _box_overlap(a, b):
891
+ ax0, ay0, ax1, ay1 = a[0], a[1], a[0] + a[2], a[1] + a[3]
892
+ bx0, by0, bx1, by1 = b[0], b[1], b[0] + b[2], b[1] + b[3]
893
+ ox = min(ax1, bx1) - max(ax0, bx0)
894
+ oy = min(ay1, by1) - max(ay0, by0)
895
+ return (ox * oy) if ox > 0 and oy > 0 else 0
896
+
897
+
898
+ def rule_v2_15(pack):
899
+ """V2-15 同一段里的字段不能互相矛盾(text_safe 不得与 avoid 相交)
900
+
901
+ 包里两条规则打架时,消费端照哪条都是错——实测一张背景 text_safe 有 34% 压在
902
+ avoid 上,消费端按 text_safe 把标题放进了禁放区,正好压在背景主体上。
903
+ 这类矛盾出在「不同来源的字段拼在一起」:text_safe 是脚本算的,avoid 是看图的人
904
+ 填的,谁都没错,凑一起就错。
905
+ """
906
+ res = Result('V2-15', '同段字段自洽(text_safe 不与 avoid 相交)')
907
+ checked = 0
908
+ for md in pack.files:
909
+ for bg, entry in (md.data.get('backgrounds') or {}).items():
910
+ if not isinstance(entry, dict):
911
+ continue
912
+ raw_ts = entry.get('text_safe')
913
+ avoids = entry.get('avoid') or []
914
+ ts = _box4(raw_ts)
915
+ if ts is None:
916
+ if raw_ts is not None:
917
+ res.fails.append(
918
+ f'backgrounds.{bg}.text_safe 不是合法的 [x, y, w, h]'
919
+ f'(四个数、w/h 为正):{raw_ts!r}')
920
+ continue
921
+ checked += 1
922
+ for i, av in enumerate(avoids):
923
+ raw_box = av.get('box') if isinstance(av, dict) else av
924
+ box = _box4(raw_box)
925
+ if box is None:
926
+ res.fails.append(
927
+ f'backgrounds.{bg}.avoid[{i}] 不是合法的 [x, y, w, h]'
928
+ f'(四个数、w/h 为正):{raw_box!r}')
929
+ continue
930
+ ov = _box_overlap(ts, box)
931
+ if ov:
932
+ res.fails.append(
933
+ f'backgrounds.{bg} 的 text_safe {ts} 与 avoid[{i}] {box} '
934
+ f'相交 {ov} px²(占安全区 {round(100.0 * ov / (ts[2] * ts[3]))}%)'
935
+ f'——消费端按哪条都会违反另一条')
936
+ res.notes.append('校验 %d 段背景规则' % checked if checked else '无 backgrounds 段,无适用对象')
937
+ return res
938
+
939
+
940
+ LEGACY_SLOT_STYLE_KEYS = frozenset(('size', 'weight', 'color', 'align', 'valign', 'insets_px'))
941
+
942
+
943
+ def rule_v2_16(pack):
944
+ """V2-16 slot 的渲染样式只通过 CSS 承载,禁止泄漏 PPTX/旧契约字段。"""
945
+ res = Result('V2-16', 'slot 渲染样式统一使用 css(box 只负责几何)')
946
+ checked = 0
947
+ for name, (layout, _md) in sorted(pack.layouts.items()):
948
+ if not isinstance(layout, dict):
949
+ continue
950
+ groups = [('slots', layout.get('slots') or [])]
951
+ flow = layout.get('flow')
952
+ if isinstance(flow, dict):
953
+ for i, region in enumerate(flow.get('regions') or []):
954
+ if isinstance(region, dict):
955
+ groups.append((f'flow.regions[{i}].items', region.get('items') or []))
956
+ for group, items in groups:
957
+ for i, item in enumerate(items):
958
+ if not isinstance(item, dict):
959
+ continue
960
+ checked += 1
961
+ legacy = sorted(LEGACY_SLOT_STYLE_KEYS & set(item))
962
+ if legacy:
963
+ res.fails.append(
964
+ f'layouts.{name}.{group}[{i}] 含旧样式键 {"/".join(legacy)};'
965
+ '保留 box/role/type/asset,把渲染属性转换为可直接写入 style 的 css')
966
+ res.notes.append(f'校验 {checked} 个 slot/flow item')
967
+ return res
968
+
969
+
970
+ def rule_v2_14(pack):
971
+ """V2-14 flow 与 slots 二选一;flow 的区带类型合法、grid 必带 cols"""
972
+ res = Result('V2-14', 'flow 形态合法(与 slots 互斥,区带类型在枚举内)')
973
+ n_flow = 0
974
+ for name, (layout, _md) in sorted(pack.layouts.items()):
975
+ if True:
976
+ if not isinstance(layout, dict):
977
+ continue
978
+ flow = layout.get('flow')
979
+ if flow is None:
980
+ continue
981
+ n_flow += 1
982
+ if layout.get('slots'):
983
+ res.fails.append(f'layouts.{name} 同时有 flow 和 slots——'
984
+ f'两份坐标都在,消费端不知道该按哪份渲染')
985
+ if not isinstance(flow, dict):
986
+ res.fails.append(f'layouts.{name}.flow 不是映射')
987
+ continue
988
+ regions = flow.get('regions')
989
+ if not isinstance(regions, list) or not regions:
990
+ res.fails.append(f'layouts.{name}.flow 没有 regions')
991
+ continue
992
+ for i, r in enumerate(regions):
993
+ if not isinstance(r, dict):
994
+ continue
995
+ _check_enum(res, f'layouts.{name}.flow.regions[{i}].kind',
996
+ r.get('kind'), ENUM_REGION_KIND)
997
+ if r.get('kind') == 'grid' and not r.get('cols'):
998
+ res.fails.append(f'layouts.{name}.flow.regions[{i}] 是 grid 但没有 cols')
999
+ if r.get('kind') == 'free' and not layout.get('slots'):
1000
+ # free 区带要按坐标摆,但坐标在 slots 里——而 slots 与 flow 互斥,
1001
+ # 所以 free 的元素必须自带 box
1002
+ for j, it in enumerate(r.get('items') or []):
1003
+ if isinstance(it, dict) and not it.get('box'):
1004
+ res.fails.append(
1005
+ f'layouts.{name}.flow.regions[{i}].items[{j}] 在 free 区带里'
1006
+ f'但没有 box——free 要按坐标摆,坐标必须自带')
1007
+ res.notes.append('%d 个页型用 flow' % n_flow if n_flow else '无页型用 flow,无适用对象')
1008
+ return res
1009
+
1010
+
874
1011
  RULES = [rule_v2_1, rule_v2_2, rule_v2_3, rule_v2_4, rule_v2_5, rule_v2_6,
875
1012
  rule_v2_7, rule_v2_8, rule_v2_9, rule_v2_10, rule_v2_11, rule_v2_12,
876
- rule_v2_13, rule_sidecar_dup, rule_audit_fields, rule_layouts_pointer]
1013
+ rule_v2_13, rule_v2_14, rule_v2_15, rule_v2_16, rule_sidecar_dup,
1014
+ rule_audit_fields, rule_layouts_pointer]
877
1015
 
878
1016
 
879
1017
  def main():
@@ -883,9 +1021,10 @@ def main():
883
1021
  pack = Pack(sys.argv[1])
884
1022
  print(f'包: {pack.root}')
885
1023
  print('文件: ' + ', '.join(md.name for md in pack.files))
886
- for md in pack.files:
887
- for lineno, text in md.anomalies:
888
- print(f'PARSE-WARN: {md.name}:{lineno} 解析器跳过无法识别的行: {text.strip()[:60]}')
1024
+ # 这个解析器只服务于机检取数,它读不懂不代表内容有问题——产物是逐字节写出去的,
1025
+ # 消费模型读多行 CSS 之类毫无障碍。所以这里既不静默跳过,也不拦住流水线:
1026
+ # 把原文交出来,由模型看一眼确认内容没丢。
1027
+ unread = [(md.name, lineno, text) for md in pack.files for lineno, text in md.anomalies]
889
1028
  print('-' * 72)
890
1029
  failed = 0
891
1030
  for rule in RULES:
@@ -900,6 +1039,12 @@ def main():
900
1039
  failed += len(res.fails)
901
1040
  print('-' * 72)
902
1041
  print(f'结论: {"FAIL" if failed else "PASS"}({failed} 条 FAIL)')
1042
+ if unread:
1043
+ print('-' * 72)
1044
+ print(f'待你确认({len(unread)} 行机检解析器没读懂,产物里内容仍在,'
1045
+ f'请对照原文确认没丢东西):')
1046
+ for name, lineno, text in unread:
1047
+ print(f' {name}:{lineno} {text.strip()[:100]}')
903
1048
  sys.exit(1 if failed else 0)
904
1049
 
905
1050