dsh-ppt-studio 1.1.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 (179) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +480 -0
  3. package/agent-presets/ppt/agent.cordis.yml +308 -0
  4. package/agent-presets/ppt/preset.yml +3 -0
  5. package/cordis.patch.yml +29 -0
  6. package/docs/00-/350/256/276/350/256/241/346/272/220.md +537 -0
  7. package/docs/01-/351/234/200/346/261/202/344/270/216/347/233/256/346/240/207.md +136 -0
  8. package/docs/02-/346/212/200/346/234/257/346/212/245/345/221/212.md +483 -0
  9. package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +2898 -0
  10. package/docs/04-/350/267/257/347/272/277/345/233/276/344/270/216/351/207/214/347/250/213/347/242/221.md +113 -0
  11. package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +210 -0
  12. package/docs/06-/350/257/204/345/256/241/344/270/216/346/265/213/350/257/225.md +296 -0
  13. package/docs/fidelity-report.md +225 -0
  14. package/examples/fx/fx-mini/deck.yaml +39 -0
  15. package/examples/fx/fx-mini/pages/01.yaml +6 -0
  16. package/examples/fx/fx-mini/pages/02.yaml +2 -0
  17. package/examples/fx/fx-mini/pages/03.yaml +6 -0
  18. package/examples/fx/fx-pro/deck.yaml +48 -0
  19. package/examples/fx/fx-pro/pages/01_cover.yaml +40 -0
  20. package/examples/fx/fx-pro/pages/02_arch.yaml +55 -0
  21. package/examples/fx/fx-pro/pages/03_table.yaml +20 -0
  22. package/examples/fx/fx-pro/pages/04_chart.yaml +40 -0
  23. package/examples/fx/fx-pro/pages/05_timeline.yaml +71 -0
  24. package/examples/fx/fx-pro/pages/06_longtext.yaml +13 -0
  25. package/examples/fx/fx-pro/pages/07_safearea.yaml +33 -0
  26. package/examples/fx/fx-pro/pages/08_contrast.yaml +22 -0
  27. package/examples/fx/fx-pro/pages/09_custgeom.yaml +32 -0
  28. package/examples/fx/fx-pro/pages/10_numbers.yaml +25 -0
  29. package/examples/fx/fx-pro/pages/11_section.yaml +20 -0
  30. package/examples/fx/fx-pro/pages/12_summary.yaml +57 -0
  31. package/examples/fx/fx-pro/preview/01_pages_01_cover.html +1 -0
  32. package/examples/fx/fx-pro/preview/02_pages_02_arch.html +3 -0
  33. package/examples/fx/fx-pro/preview/03_pages_03_table.html +1 -0
  34. package/examples/fx/fx-pro/preview/04_pages_04_chart.html +3 -0
  35. package/examples/fx/fx-pro/preview/05_pages_05_timeline.html +2 -0
  36. package/examples/fx/fx-pro/preview/06_pages_06_longtext.html +4 -0
  37. package/examples/fx/fx-pro/preview/07_pages_07_safearea.html +2 -0
  38. package/examples/fx/fx-pro/preview/08_pages_08_contrast.html +1 -0
  39. package/examples/fx/fx-pro/preview/09_pages_09_custgeom.html +1 -0
  40. package/examples/fx/fx-pro/preview/10_pages_10_numbers.html +1 -0
  41. package/examples/fx/fx-pro/preview/11_pages_11_section.html +1 -0
  42. package/examples/fx/fx-pro/preview/12_pages_12_summary.html +1 -0
  43. package/examples/fx/fx-pro/preview/deck.html +10 -0
  44. package/examples/fx/fx-pro/preview/layout.json +2325 -0
  45. package/examples/fx/fx-pro/preview/measured.json +814 -0
  46. package/examples/fx/fx-pro.pptx +0 -0
  47. package/examples/fx/ren-single/01.png +0 -0
  48. package/examples/fx/ren-spliced/01.png +0 -0
  49. package/examples/fx/ren-spliced/03.png +0 -0
  50. package/examples/fx/seed.pptx +0 -0
  51. package/examples/fx/single.pptx +0 -0
  52. package/examples/fx/spliced.pptx +0 -0
  53. package/examples/smoke/deck.yaml +26 -0
  54. package/examples/smoke/imported/deck.yaml +30 -0
  55. package/examples/smoke/imported/import-styles.json +126 -0
  56. package/examples/smoke/imported/pages/slide_01.yaml +31 -0
  57. package/examples/smoke/imported/pages/slide_02.yaml +38 -0
  58. package/examples/smoke/imported/pages/slide_03.yaml +55 -0
  59. package/examples/smoke/imported/preview-rt/01_pages_slide_01.html +1 -0
  60. package/examples/smoke/imported/preview-rt/02_pages_slide_02.html +1 -0
  61. package/examples/smoke/imported/preview-rt/03_pages_slide_03.html +1 -0
  62. package/examples/smoke/imported/preview-rt/deck.html +1 -0
  63. package/examples/smoke/imported/preview-rt/layout.json +434 -0
  64. package/examples/smoke/imported/reference/previews/01.png +0 -0
  65. package/examples/smoke/imported/reference/previews/02.png +0 -0
  66. package/examples/smoke/imported/reference/previews/03.png +0 -0
  67. package/examples/smoke/imported/source.pptx +0 -0
  68. package/examples/smoke/media-test.png +0 -0
  69. package/examples/smoke/out-smoke.pptx +0 -0
  70. package/examples/smoke/pages/01_cover.yaml +27 -0
  71. package/examples/smoke/pages/02_content.yaml +42 -0
  72. package/examples/smoke/pages/03_chart.yaml +35 -0
  73. package/examples/smoke/preview/01_pages_01_cover.html +1 -0
  74. package/examples/smoke/preview/02_pages_02_content.html +1 -0
  75. package/examples/smoke/preview/03_pages_03_chart.html +1 -0
  76. package/examples/smoke/preview/deck.html +1 -0
  77. package/examples/smoke/preview/layout.json +378 -0
  78. package/lib/autodeclare.js +40 -0
  79. package/lib/commands.js +161 -0
  80. package/lib/crosscheck.js +300 -0
  81. package/lib/imgmeta.js +55 -0
  82. package/lib/index.js +239 -0
  83. package/lib/measurement.js +209 -0
  84. package/lib/msrender.js +123 -0
  85. package/lib/pptd/export-pptx.js +686 -0
  86. package/lib/pptd/import-pptx.js +774 -0
  87. package/lib/pptd/layout.js +209 -0
  88. package/lib/pptd/render-html.js +254 -0
  89. package/lib/pptd/schema.js +518 -0
  90. package/lib/pptd/svgCharts.js +147 -0
  91. package/lib/pptxPy.js +205 -0
  92. package/lib/preset-delivery.js +195 -0
  93. package/lib/preview-server.js +164 -0
  94. package/lib/router.js +165 -0
  95. package/lib/scaffold.js +315 -0
  96. package/lib/skill.js +174 -0
  97. package/lib/splice.js +219 -0
  98. package/lib/state.js +88 -0
  99. package/lib/surgical.js +369 -0
  100. package/lib/templates.js +400 -0
  101. package/lib/tools.js +955 -0
  102. package/lib/verify.js +828 -0
  103. package/lib/xmljs.js +112 -0
  104. package/lib/zips.js +117 -0
  105. package/package.json +81 -0
  106. package/scripts/audit-manual-facts.mjs +294 -0
  107. package/scripts/audit-template-fidelity.mjs +162 -0
  108. package/scripts/bench-m2.mjs +48 -0
  109. package/scripts/build-templates.mjs +63 -0
  110. package/scripts/build.mjs +14 -0
  111. package/scripts/build.sh +7 -0
  112. package/scripts/check-lib-freshness.mjs +70 -0
  113. package/scripts/check-preset.mjs +189 -0
  114. package/scripts/check-tracked-lf.mjs +84 -0
  115. package/scripts/dbg-import.mjs +13 -0
  116. package/scripts/dbg-media.mjs +10 -0
  117. package/scripts/dbg-tpl-inventory.mjs +23 -0
  118. package/scripts/dump-workflow-baseline.mjs +38 -0
  119. package/scripts/e2e-1.0.mjs +129 -0
  120. package/scripts/eval-skills-blind.mjs +91 -0
  121. package/scripts/eval-skills-score.mjs +129 -0
  122. package/scripts/export-orig-templates.ps1 +17 -0
  123. package/scripts/fixtures/workflow-baseline.json +300 -0
  124. package/scripts/fixtures.mjs +501 -0
  125. package/scripts/import-user-templates.mjs +60 -0
  126. package/scripts/install-desktop.mjs +312 -0
  127. package/scripts/install.mjs +209 -0
  128. package/scripts/migrate-templates-dualtrack.mjs +71 -0
  129. package/scripts/preflight-1.0.mjs +198 -0
  130. package/scripts/probe-asset-freshness.mjs +57 -0
  131. package/scripts/probe-effect-semantics.mjs +69 -0
  132. package/scripts/probe-reparse-safety.mjs +32 -0
  133. package/scripts/rebuild-templates-c11.mjs +69 -0
  134. package/scripts/regression-real.mjs +84 -0
  135. package/scripts/release-sync.mjs +425 -0
  136. package/scripts/shot-templates.mjs +49 -0
  137. package/scripts/smoke.mjs +2512 -0
  138. package/scripts/verify-bundle-install.mjs +281 -0
  139. package/scripts/verify-fresh-install.mjs +260 -0
  140. package/skills/ppt-studio-copy/SKILL.md +139 -0
  141. package/skills/ppt-studio-craft/SKILL.md +171 -0
  142. package/skills/ppt-studio-data/SKILL.md +173 -0
  143. package/skills/ppt-studio-manual/SKILL.md +104 -0
  144. package/templates/academic-white/deck.yaml +24 -0
  145. package/templates/academic-white/pages/_chart.yaml +43 -0
  146. package/templates/academic-white/pages/_compare.yaml +37 -0
  147. package/templates/academic-white/pages/_content.yaml +35 -0
  148. package/templates/academic-white/pages/_cover.yaml +28 -0
  149. package/templates/academic-white/pages/_ending.yaml +28 -0
  150. package/templates/academic-white/pages/_flow.yaml +61 -0
  151. package/templates/academic-white/preview.png +0 -0
  152. package/templates/academic-white/template.yaml +14 -0
  153. package/templates/business-blue/deck.yaml +28 -0
  154. package/templates/business-blue/pages/_chart.yaml +36 -0
  155. package/templates/business-blue/pages/_compare.yaml +45 -0
  156. package/templates/business-blue/pages/_content.yaml +80 -0
  157. package/templates/business-blue/pages/_cover.yaml +39 -0
  158. package/templates/business-blue/pages/_ending.yaml +32 -0
  159. package/templates/business-blue/pages/_flow.yaml +87 -0
  160. package/templates/business-blue/preview.png +0 -0
  161. package/templates/business-blue/template.yaml +14 -0
  162. package/templates/pitch-bold/deck.yaml +23 -0
  163. package/templates/pitch-bold/pages/_chart.yaml +32 -0
  164. package/templates/pitch-bold/pages/_compare.yaml +36 -0
  165. package/templates/pitch-bold/pages/_content.yaml +58 -0
  166. package/templates/pitch-bold/pages/_cover.yaml +31 -0
  167. package/templates/pitch-bold/pages/_ending.yaml +27 -0
  168. package/templates/pitch-bold/pages/_flow.yaml +46 -0
  169. package/templates/pitch-bold/preview.png +0 -0
  170. package/templates/pitch-bold/template.yaml +14 -0
  171. package/templates/tech-dark/deck.yaml +24 -0
  172. package/templates/tech-dark/pages/_chart.yaml +27 -0
  173. package/templates/tech-dark/pages/_compare.yaml +38 -0
  174. package/templates/tech-dark/pages/_content.yaml +51 -0
  175. package/templates/tech-dark/pages/_cover.yaml +30 -0
  176. package/templates/tech-dark/pages/_ending.yaml +25 -0
  177. package/templates/tech-dark/pages/_flow.yaml +80 -0
  178. package/templates/tech-dark/preview.png +0 -0
  179. package/templates/tech-dark/template.yaml +14 -0
package/lib/verify.js ADDED
@@ -0,0 +1,828 @@
1
+ /**
2
+ * 布局断言引擎(数字审阅核心):输入 layout.json(renderDeck 或引擎快照),
3
+ * 输出机器结果 + markdown 报告。
4
+ *
5
+ * 断言(v1):
6
+ * - ERROR out-of-page 元素超出**页面边界**(永远不可声明,放映不可见)
7
+ * - ERROR out-of-safe-area 元素超出**安全区**但仍在页面内(声明制:expectedOutOfSafeArea 命中 → confirmed)
8
+ * ——2026-09-18 起与 out-of-page 分码:两者处置完全不同(改布局 vs 补声明),
9
+ * 共用一个 code 会让消费方与模型都分不清要做什么
10
+ * - ERROR overlap AABB 相交(容差 1px;排除 id 前缀同源组)
11
+ * - ERROR text-overflow 文本估算高度/宽度超出容器(wrap=false 时宽度计入)
12
+ * - ERROR table-overflow 表格单元格内容按列宽换行后超出表格高度(2026-09-18 新增:表格此前是内容门禁的盲区)
13
+ * - WARN near-align 疑似未对齐:同缘差 ∈ (1, 6]px
14
+ * - WARN hotspot 网格聚类密集区(单个网格 ≥ 5 个元素)
15
+ * - WARN density 单页内容元素(text/table/chart)≥ 12(信息密度警示)
16
+ * - ERROR theme-conformance 元素颜色不在 theme.colors 且非中性灰(themeConformance: strict 默认 / suggest 降为 warning / off 跳过)
17
+ * - ERROR measured-overflow M2 实测档:实测溢出而估算漏报(实测=终审);估算也报 = warning
18
+ * - ERROR measured-unpaired M2 实测档:两档页号契约不一致(缺 pageNo / 找不到对应页)——**契约破坏必须报错**,
19
+ * 不得静默跳过(静默返空与"真没问题"在输出上不可区分,2026-09-18 假绿灯事故的根因)
20
+ */
21
+
22
+ const TOL = 1 // px
23
+
24
+ const NEUTRAL_GRAY_RE = /^#(?:[0-9a-fA-F]{6})$/ // 黑白灰中性色宽判:RGB 各分量近等
25
+ function isNeutral(color) {
26
+ if (!color || typeof color !== 'string') return true
27
+ const m = color.match(/^#([0-9a-fA-F]{6})$/)
28
+ if (!m) return true
29
+ const v = parseInt(m[1], 16)
30
+ const r = (v >> 16) & 0xff
31
+ const g = (v >> 8) & 0xff
32
+ const b = v & 0xff
33
+ if (Math.abs(r - g) <= 8 && Math.abs(g - b) <= 8 && Math.abs(r - b) <= 8) return true // 灰度/中性
34
+ return false
35
+ }
36
+
37
+ /** 页面美学建议(severity: 'suggestion',永不作为门禁;冲突断言不受影响)。 */
38
+ export function aestheticSuggestions(page, size, theme) {
39
+ const out = []
40
+ const els = page.elements ?? []
41
+ const pw = size.width
42
+ const ph = size.height
43
+ if (els.length < 2) return out
44
+ const themeColors = new Set(Object.values(theme?.colors ?? {}).filter(Boolean))
45
+ const themeFontSizes = new Set(Object.values(theme?.textStyles ?? {}).map((s) => s?.fontSize).filter(Boolean))
46
+
47
+ // 1. 元素集重心 vs 页面中心(防"下方留大空白"构图失衡)
48
+ {
49
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
50
+ for (const el of els) {
51
+ minX = Math.min(minX, el.bounds.x); minY = Math.min(minY, el.bounds.y)
52
+ maxX = Math.max(maxX, el.bounds.x + el.bounds.w); maxY = Math.max(maxY, el.bounds.y + el.bounds.h)
53
+ }
54
+ const cx = (minX + maxX) / 2
55
+ const cy = (minY + maxY) / 2
56
+ const dx = Math.abs(cx - pw / 2) / pw
57
+ const dy = Math.abs(cy - ph / 2) / ph
58
+ if (dx > 0.08) out.push({ severity: 'suggestion', code: 'aesthetic-center', id: 'balance', message: `构图重心水平偏移 ${Math.round(dx * 100)}%(请检查左右留白/元素摆放)` })
59
+ if (dy > 0.08) out.push({ severity: 'suggestion', code: 'aesthetic-center', id: 'balance', message: `构图重心垂直偏移 ${Math.round(dy * 100)}%(请检查上下留白/是否有空白带)` })
60
+ }
61
+
62
+ // 2. 外边界一致性(左右边距差)
63
+ {
64
+ const leftM = Math.min(...els.map((e) => e.bounds.x))
65
+ const rightM = Math.min(...els.map((e) => pw - (e.bounds.x + e.bounds.w)))
66
+ if (Math.abs(leftM - rightM) > 20) {
67
+ out.push({ severity: 'suggestion', code: 'aesthetic-margin', id: 'margin', message: `左右外边界不一致(左 ${Math.round(leftM)}px / 右 ${Math.round(rightM)}px),建议对齐` })
68
+ }
69
+ }
70
+
71
+ // 3. 左缘列数(散落排版)
72
+ {
73
+ const xs = [...new Set(els.map((e) => Math.round(e.bounds.x / 8) * 8))]
74
+ if (xs.length > 5) out.push({ severity: 'suggestion', code: 'aesthetic-columns', id: 'columns', message: `左缘落在 ${xs.length} 个不同列位,建议收敛到 3-5 列网格` })
75
+ }
76
+
77
+ // 4. 同排相邻间距节奏(变异系数)
78
+ {
79
+ const gaps = []
80
+ for (let i = 0; i < els.length; i++) {
81
+ for (let j = i + 1; j < els.length; j++) {
82
+ const a = els[i].bounds, b = els[j].bounds
83
+ const vOverlap = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)
84
+ if (vOverlap < Math.min(2, a.h * 0.3, b.h * 0.3)) continue
85
+ const g = b.x - (a.x + a.w)
86
+ if (g > 0 && g < 200) gaps.push(g)
87
+ }
88
+ }
89
+ if (gaps.length >= 4) {
90
+ const mean = gaps.reduce((s, g) => s + g, 0) / gaps.length
91
+ const vars = gaps.reduce((s, g) => s + (g - mean) ** 2, 0) / gaps.length
92
+ const cv = Math.sqrt(vars) / (mean || 1)
93
+ if (cv > 0.9) out.push({ severity: 'suggestion', code: 'aesthetic-rhythm', id: 'spacing', message: `同排间距节奏不统一(${gaps.length} 处间距变异系数 ${cv.toFixed(2)}),建议统一 8/16/24px 节奏` })
94
+ }
95
+ }
96
+
97
+ // 5. 网格节奏(坐标/尺寸偏好 8px 基)
98
+ {
99
+ const base = theme?.grid?.base ?? 8
100
+ const off = els.filter((e) => {
101
+ const vals = [e.bounds.x, e.bounds.y, e.bounds.w, e.bounds.h]
102
+ return vals.some((v) => Math.abs(v % base) > 4)
103
+ })
104
+ if (off.length > Math.max(2, els.length * 0.3)) {
105
+ out.push({ severity: 'suggestion', code: 'aesthetic-grid', id: 'grid', message: `${off.length}/${els.length} 个元素偏离 ${base}px 网格节奏(>4px),建议对齐到网格` })
106
+ }
107
+ }
108
+
109
+ // 6. 字号层级 + 最小字号
110
+ {
111
+ const fsSet = new Set()
112
+ for (const el of els) {
113
+ if (el.kind === 'text' && el.style?.fontSize) fsSet.add(el.style.fontSize)
114
+ }
115
+ if (fsSet.size > 4) out.push({ severity: 'suggestion', code: 'aesthetic-fonts', id: 'hierarchy', message: `页面字号有 ${fsSet.size} 种(${[...fsSet].join('/')}),建议精简为 3-4 级层级` })
116
+ const minFs = Math.min(...fsSet)
117
+ if (minFs < 12) out.push({ severity: 'suggestion', code: 'aesthetic-fonts', id: 'minimum', message: `最小字号 ${minFs}pt 低于通读建议值 12pt——若用户给出下限按用户指令执行;未给则确认设计意图(建议级,非门禁)` })
118
+ const stray = [...fsSet].filter((f) => !themeFontSizes.has(f))
119
+ if (themeFontSizes.size > 0 && stray.length > 0) {
120
+ out.push({ severity: 'suggestion', code: 'aesthetic-theme', id: 'fontStyle', message: `字号 ${stray.join('/')} 不在 theme.textStyles 中,建议纳入主题样式(页面内可能漂移)` })
121
+ }
122
+ }
123
+
124
+ // 7. 主题颜色一致性
125
+ {
126
+ const stray = []
127
+ let fromChart = false
128
+ for (const el of els) {
129
+ // 图表**显式**配色(2026-09-14):此前 fillColorsOf/这里都不看 chart,
130
+ // 导致"成品带主题外颜色"既无门禁也无提示。语义边界(刻意):
131
+ // · 不写 colors → 用内置调色板(既有行为,不打扰、不新增噪声);
132
+ // · 写了非主题色 → 作者主动选择,值得一条 [·] 建议。
133
+ // 仍是**建议级**:把图表配色门禁化会给既有工程/模板凭空新增错误(= 让老用户变差),不做。
134
+ // 注意:必须放在下面的 `continue` 之前——chart 没有 fill,会先被 continue 掉。
135
+ if (el.kind === 'chart' && Array.isArray(el.colors)) {
136
+ for (const cc of el.colors) {
137
+ if (typeof cc !== 'string' || isNeutral(cc) || !themeColors || themeColors.size === 0) continue
138
+ if (!themeColors.has(cc)) {
139
+ fromChart = true
140
+ if (!stray.includes(cc)) stray.push(cc)
141
+ }
142
+ }
143
+ }
144
+ const c = el.kind === 'text' ? el.style?.color : el.fill
145
+ if (!c || typeof c !== 'string' || isNeutral(c)) continue
146
+ if (themeColors && themeColors.size > 0 && !themeColors.has(c) && !stray.includes(c)) stray.push(c)
147
+ }
148
+ if (stray.length > 0) {
149
+ out.push({ severity: 'suggestion', code: 'aesthetic-theme', id: 'color', message: `颜色 ${stray.join(' ')} 不在 theme.colors 中,建议改为主题色(风格统一)${fromChart ? '——含图表显式配色(chart.colors),可用 $ref 引用主题色' : ''}` })
150
+ }
151
+ }
152
+
153
+ // 8. 文本信息密度 / 长句
154
+ {
155
+ for (const el of els) {
156
+ if (el.kind !== 'text' || !el.text?.length) continue
157
+ const text = el.text ?? ''
158
+ if (text.length > 90) out.push({ severity: 'suggestion', code: 'aesthetic-text', id: el.id, message: `文本块 "${el.id}" 达 ${text.length} 字,建议拆点/精简(每块 ≤60 字更易读)` })
159
+ const longest = text.split(/[。;!?\n]/).reduce((a, b) => (a.length >= b.length ? a : b), '')
160
+ if (longest.length > 45) out.push({ severity: 'suggestion', code: 'aesthetic-text', id: el.id, message: `出现 ${longest.length} 字长句("${longest.slice(0, 24)}…"),建议断句/分点表达` })
161
+ const lineLong = text.split(/\n/).some((l) => l.replace(/\s/g, '').length > 34)
162
+ if (lineLong) out.push({ severity: 'suggestion', code: 'aesthetic-text', id: el.id, message: `文本 "${el.id}" 存在单行超 34 字的长行,建议显式断行` })
163
+ }
164
+ }
165
+
166
+ // 9. 孤独字/破句(D7 反馈):自动换行 ≥2 行且末行估算宽 < 1.5×fontSize → 提示显式断行
167
+ {
168
+ const fs = (el) => el.style?.fontSize ?? 18
169
+ for (const el of els) {
170
+ if (el.kind !== 'text') continue
171
+ const text = el.text ?? ''
172
+ const m = el.metrics ?? {}
173
+ const lines = m.lineWidths ?? []
174
+ if (text.includes('\n') || lines.length < 2 || m.overflowY > 0) continue
175
+ const last = lines[lines.length - 1]
176
+ if (last < fs(el) * 1.5) {
177
+ out.push({ severity: 'suggestion', code: 'aesthetic-text', id: el.id, message: `文本 "${el.id}" 自动换行后末行仅余 ${Math.round(last)}px(约 ${Math.round(last / fs(el))} 字),建议在语义断点显式断行避免孤字` })
178
+ }
179
+ }
180
+ }
181
+
182
+ // 10. 前景/背景对比度(D6 反馈,P1-1 修正):文字 vs **最上层(z-order)承载色块**/页面实底
183
+ {
184
+ const contrastExempt = new Set(page.contrastExempt ?? []) // P1-1:已确认承载层深色,豁免对比度建议
185
+ const bgColor = typeof page.background?.color === 'string' && /^#[0-9a-fA-F]{6}$/.test(page.background?.color)
186
+ ? page.background.color : null
187
+ for (const el of els) {
188
+ if (el.kind !== 'text' || !el.text?.length) continue
189
+ if (contrastExempt.has(el.id)) continue // P1-1:已确认承载层深色,豁免
190
+ const fg = el.style?.color
191
+ if (typeof fg !== 'string' || !/^#[0-9a-fA-F]{6}$/.test(fg)) continue
192
+ const cx = el.bounds.x + el.bounds.w / 2
193
+ const cy = el.bounds.y + el.bounds.h / 2
194
+ // A8 修复(反馈二):载体不再只认"字符串实色 + kind=shape"——渐变/alpha 对象与
195
+ // 任意承载元素(非 text/line)都计入;渐变取最接近中心的 stop 做对比色,alpha<0.85 视为不承载
196
+ let holder = null
197
+ for (let k = els.length - 1; k >= 0; k--) {
198
+ const other = els[k]
199
+ if (other.kind === 'text' || other.kind === 'line') continue
200
+ if (!contains(other.bounds, { x: cx, y: cy, w: 1, h: 1 })) continue
201
+ const c = carrierColorOf(other)
202
+ if (c) { holder = c; break }
203
+ }
204
+ const base = holder ?? bgColor
205
+ if (!base) continue
206
+ const ratio = contrastRatio(fg, base)
207
+ if (ratio !== null && ratio < 3) {
208
+ const tone = ratio < 1.8 ? '极低(接近不可读)' : '偏低'
209
+ out.push({ severity: 'suggestion', code: 'aesthetic-contrast', id: el.id, message: `文本 "${el.id}" 颜色 ${fg} 与${holder ? '承载形状(最上层实底)' : '页面背景'} ${base} 对比度 ${ratio.toFixed(2)}(${tone}),建议深字浅底或浅字深底(若已确认承载层深色,可加入本页 contrastExempt 豁免)` })
210
+ }
211
+ }
212
+ }
213
+
214
+ // 11. 自定义模板背景未配置安全区(D5 反馈):背景图为模板(含 logo/页眉页脚带)时提示
215
+ {
216
+ if (page.background?.type === 'image' && !theme?.safeArea && !page.safeArea) {
217
+ out.push({ severity: 'suggestion', code: 'aesthetic-safearea', id: 'safeArea', message: '页面使用图片背景(可能是带 logo/页眉页脚带的模板):建议在 theme.safeArea(或本页 safeArea)配置非内容区边距,verify 会把安全区外的元素判为出界' })
218
+ }
219
+ }
220
+
221
+ // 12. 相邻贴边清单(P2-4 反馈):非重叠但间隙 < 4px("安全相切"与"真重叠"分开提示)
222
+ {
223
+ const isContent = (e) => ['text', 'table', 'chart'].includes(e.kind)
224
+ let n = 0
225
+ for (let i = 0; i < els.length && n < 15; i++) {
226
+ for (let j = i + 1; j < els.length && n < 15; j++) {
227
+ const a = els[i].bounds
228
+ const b = els[j].bounds
229
+ if (els[i].kind === 'line' || els[j].kind === 'line') continue
230
+ if (!isContent(els[i]) && !isContent(els[j])) continue // 纯形状贴边(芯片网格缝隙)常见合法,不报
231
+ const vOverlap = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)
232
+ const hOverlap = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)
233
+ const hGap = Math.max(a.x, b.x) - Math.min(a.x + a.w, b.x + b.w)
234
+ const vGap = Math.max(a.y, b.y) - Math.min(a.y + a.h, b.y + b.h)
235
+ if (vOverlap > TOL && hGap > TOL && hGap <= 4) {
236
+ out.push({ severity: 'suggestion', code: 'aesthetic-spacing', id: `${els[i].id} × ${els[j].id}`, message: `相邻贴边:"${els[i].id}" 与 "${els[j].id}" 水平间隙仅 ${Math.round(hGap)}px(建议 ≥8px 呼吸空间或确认有意)` })
237
+ n++
238
+ } else if (hOverlap > TOL && vGap > TOL && vGap <= 4) {
239
+ out.push({ severity: 'suggestion', code: 'aesthetic-spacing', id: `${els[i].id} × ${els[j].id}`, message: `相邻贴边:"${els[i].id}" 与 "${els[j].id}" 垂直间隙仅 ${Math.round(vGap)}px(建议 ≥8px 呼吸空间或确认有意)` })
240
+ n++
241
+ }
242
+ }
243
+ }
244
+ }
245
+
246
+ return out
247
+ }
248
+
249
+ export function analyzePage(page, size) {
250
+ const findings = []
251
+ const els = page.elements ?? []
252
+ const pw = size.width
253
+ const ph = size.height
254
+ const sa = page.safeArea ?? null // {top,bottom,left,right};安全区外视为出界
255
+ const minX = sa?.left ?? 0
256
+ const minY = sa?.top ?? 0
257
+ const maxX = pw - (sa?.right ?? 0)
258
+ const maxY = ph - (sa?.bottom ?? 0)
259
+ const declared = declaredClosure(page) // P0-2:声明闭包(嵌套承载相邻层声明 → 隔层自动通过)
260
+ const outOfSafe = new Set(page.expectedOutOfSafeArea ?? []) // 出界分级声明制(C3 修订)
261
+ const lenient = page.overlapMode === 'lenient'
262
+ const pairKey = (a, b) => [a, b].sort().join(' × ')
263
+
264
+ for (const el of els) {
265
+ const b = el.bounds
266
+ // 出界分级:超页面边界 = 永远 ERROR(不可声明);超安全区(仍在页面内)= 声明制
267
+ const outOfPage = b.x < -TOL || b.y < -TOL || b.x + b.w > pw + TOL || b.y + b.h > ph + TOL
268
+ const outOfSafeArea = !outOfPage && sa && (b.x < minX - TOL || b.y < minY - TOL || b.x + b.w > maxX + TOL || b.y + b.h > maxY + TOL)
269
+ if (outOfPage) {
270
+ findings.push({
271
+ severity: 'error', code: 'out-of-page', id: el.id,
272
+ message: `元素 "${el.id}" 超出页面边界:${fmtRect(b)}(页面 ${pw}×${ph})—— 超页面边界不可声明,请修正`,
273
+ area: b,
274
+ })
275
+ } else if (outOfSafeArea) {
276
+ if (outOfSafe.has(el.id)) {
277
+ // 设计声明确认:有意落在模板页眉页脚带(✓ 预期出界)
278
+ findings.push({
279
+ severity: 'confirmed', code: 'expected-out-of-safe', id: el.id,
280
+ message: `预期出界(安全区外,设计声明确认):"${el.id}"(安全区 ${fmtSa(sa)})`,
281
+ area: b,
282
+ })
283
+ } else {
284
+ // 【2026-09-18 修】安全区出界有**自己的** code。此前它与"超页面边界"共用 `out-of-page`,
285
+ // 于是"分级"这个设计概念在 code 维度上丢失:消费方(templates 的清理统计、按 code 的诊断/统计)
286
+ // 无法区分"必须改布局、不可声明"与"补个 expectedOutOfSafeArea 声明就行"这两种完全不同的处置。
287
+ //(两者 severity 都是 error,所以这是**诊断精度**问题,不是门禁强度问题——改动对既有工程零影响。)
288
+ findings.push({
289
+ severity: 'error', code: 'out-of-safe-area', id: el.id,
290
+ message: `元素 "${el.id}" 超出页面安全区:${fmtRect(b)}(页面 ${pw}×${ph},安全区 ${fmtSa(sa)})—— 如属有意设计(logo/角标等),请将 "${el.id}" 加入本页 expectedOutOfSafeArea 后重验\n P7 建议坐标:${safeAdvice(b, sa, pw, ph)}`,
291
+ area: b,
292
+ })
293
+ }
294
+ }
295
+ // 溢出判定(wrap=true 只判垂直;wrap=false 判水平)
296
+ // 【2026-09-18】表格并入此处:表格是 role:content 的内容元素,其单元格文字溢出此前**完全不受门禁**——
297
+ // 快照里只有 cols/rows 的**长度**,而本判定只认 kind==='text' ⇒ "该严的地方没严"。
298
+ // 现在 layout.js 按列宽换行给出表格的 overflowY(与正文同一保守度量),这里用**独立错误码**
299
+ // `table-overflow`(与 ⑥ 同一条理由:处置不同就该分码——表格的杠杆是行高/列宽/文案,不是文本框)。
300
+ // 影响面已实测:仓库全部夹具 2 张表、0 张被判溢出(见 docs/03 本轮记录),故对既有工程零影响。
301
+ if (el.kind === 'text' || el.kind === 'table') {
302
+ const m = el.metrics ?? {}
303
+ const isTable = el.kind === 'table'
304
+ const wrap = isTable ? true : el.style?.wrap !== false
305
+ const ox = m.overflowX ?? 0
306
+ const oy = m.overflowY ?? 0
307
+ if (wrap && oy > TOL) {
308
+ if (isTable) {
309
+ // 独立分支而非三元:错误码提取器(smoke §37.2/§37.7)是**文本启发式**,只认 `code: '字面量'`——
310
+ // 写成 `code: a ? 'x' : 'y'` 会让两个码同时从守卫视野里消失(本轮实际踩到)。
311
+ findings.push({
312
+ severity: 'error', code: 'table-overflow', id: el.id,
313
+ message: `表格 "${el.id}" 内容溢出 ${oy}px(按列宽估算内容高 ${Math.round(m.textH ?? 0)}px / 表格高 ${b.h}px,共 ${m.lines ?? 0} 行)——扩大表格高度或精简单元格文案`,
314
+ area: b,
315
+ })
316
+ } else {
317
+ findings.push({
318
+ severity: 'error', code: 'text-overflow', id: el.id,
319
+ message: `文本 "${el.id}" 垂直溢出 ${oy}px(估算高 ${Math.round(m.textH ?? 0)}px / 容器高 ${b.h}px)`,
320
+ area: b,
321
+ })
322
+ }
323
+ } else if (!wrap && ox > TOL) {
324
+ findings.push({
325
+ severity: 'error', code: 'text-overflow', id: el.id,
326
+ message: `文本 "${el.id}" 水平溢出 ${ox}px(估算宽 ${Math.round(m.textW ?? 0)}px / 容器宽 ${b.w}px,wrap=false)`,
327
+ area: b,
328
+ })
329
+ }
330
+ }
331
+ }
332
+
333
+ // overlap(层叠意图语义模型;按 z-order:数组序 = 绘制序,后者在上层)
334
+ // - 内容面×内容面(text/table/chart 互压)→ ERROR("元素区块冲突"真正要防的)
335
+ // - 内容面×承载面(shape/image 作底板/图片上标注/图表内注释)→ warning(合法设计)
336
+ // - 承载面×承载面(装饰叠加)→ warning
337
+ // - line 参与(引线/箭头跨越图案)→ warning
338
+ // - 显式 role(background/content/decoration)覆盖推断,且可完全豁免
339
+ for (let i = 0; i < els.length; i++) {
340
+ for (let j = i + 1; j < els.length; j++) {
341
+ const a = els[i].bounds
342
+ const b = els[j].bounds
343
+ const ox = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)
344
+ const oy = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)
345
+ if (ox > TOL && oy > TOL) {
346
+ // P6 修复(测试反馈 ★):线元素按真实几何判定——AABB 相交 ≠ 线段相交
347
+ // (手绘矢量战图/箭头的 AABB 假阳性曾产生约 30 对"模糊意图"声明)
348
+ if (!realOverlapGeom(els[i], els[j])) continue
349
+ const ki = els[i].kind
350
+ const kj = els[j].kind
351
+ const ri = roleOf(els[i])
352
+ const rj = roleOf(els[j])
353
+ if (ri === 'decoration' || rj === 'decoration') continue // 显式声明装饰层:完全豁免(记录在元素 role 中)
354
+ let severity = 'error'
355
+ let code = 'overlap'
356
+ let note = ''
357
+ if (ki === 'line' || kj === 'line') {
358
+ severity = 'warning'
359
+ note = '—— 连线/箭头跨越,视为引导或标注线'
360
+ } else if (ri === 'content' && rj === 'content') {
361
+ severity = 'error'
362
+ code = 'content-collision'
363
+ note = '—— 内容互压(文字/表格/图表相互遮挡),不可声明豁免,请调整布局'
364
+ } else {
365
+ severity = 'warning'
366
+ note = '—— 层叠承载/装饰模式(底色块、图片标注等)'
367
+ }
368
+ // 设计意图对照:warning 类重叠逐对与 expectedOverlaps 比对
369
+ if (severity === 'warning') {
370
+ const key = pairKey(els[i].id, els[j].id)
371
+ if (declared.has(key)) {
372
+ findings.push({ severity: 'confirmed', code: 'expected-overlap', id: key, message: `预期重叠(设计声明确认):${key}`, area: { x: Math.max(a.x, b.x), y: Math.max(a.y, b.y), w: ox, h: oy } })
373
+ continue
374
+ }
375
+ if (!lenient) {
376
+ severity = 'error'
377
+ code = 'unexpected-overlap'
378
+ note = '—— 设计预期外重叠:请修正布局,或若该重叠是有意设计请将元素对加入页面 expectedOverlaps 后重验'
379
+ } else {
380
+ note += '(lenient 模式:未声明重叠仅提示,建议补声明或修正)'
381
+ }
382
+ }
383
+ findings.push({
384
+ severity, code, id: `${els[i].id} × ${els[j].id}`,
385
+ message: `元素 "${els[i].id}" 与 "${els[j].id}" 重叠(交叠 ${ox}×${oy}px)${note}`,
386
+ area: { x: Math.max(a.x, b.x), y: Math.max(a.y, b.y), w: ox, h: oy },
387
+ })
388
+ }
389
+ }
390
+ }
391
+
392
+ // near-align:左缘/右缘/中线两两差 ∈ (1,6]px(P1-2 修正:同区块才比 + 线元素豁免)
393
+ for (const mode of ['x', 'xr', 'cx']) {
394
+ const keys = els.map((el) => ({
395
+ el, v: mode === 'x' ? el.bounds.x : mode === 'xr' ? el.bounds.x + el.bounds.w : el.bounds.x + el.bounds.w / 2,
396
+ }))
397
+ for (let i = 0; i < keys.length; i++) {
398
+ for (let j = i + 1; j < keys.length; j++) {
399
+ if (keys[i].el.kind === 'line' || keys[j].el.kind === 'line') continue // 引线/箭头天然不对齐
400
+ const a = keys[i].el.bounds
401
+ const b = keys[j].el.bounds
402
+ const vOverlap = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)
403
+ // 同区块:垂直方向实质相邻(同排);跨区块(不同行/上下分离)不比
404
+ if (vOverlap < Math.min(12, Math.min(a.h, b.h) * 0.5)) continue
405
+ const d = Math.abs(keys[i].v - keys[j].v)
406
+ if (d > TOL && d <= 6) {
407
+ // C3 降噪(反馈二):文字在形体内(内边距 ≤6px)属设计常态——包含关系成对即视为有意内边距,不判"疑似未对齐"
408
+ if (contains(a, b) || contains(b, a)) continue
409
+ findings.push({
410
+ severity: 'warning', code: 'near-align', id: `${keys[i].el.id} × ${keys[j].el.id}`,
411
+ message: `疑似未对齐: "${keys[i].el.id}" 与 "${keys[j].el.id}" 的${mode === 'x' ? '左缘' : mode === 'xr' ? '右缘' : '中线'}差 ${Math.round(d * 10) / 10}px`,
412
+ area: union(keys[i].el.bounds, keys[j].el.bounds),
413
+ })
414
+ }
415
+ }
416
+ }
417
+ }
418
+
419
+ // hotspot:6×4 网格元素密度
420
+ const COLS = 6
421
+ const ROWS = 4
422
+ const grid = new Map()
423
+ for (const el of els) {
424
+ const cx = Math.min(COLS - 1, Math.floor((el.bounds.x + el.bounds.w / 2) / (pw / COLS)))
425
+ const cy = Math.min(ROWS - 1, Math.floor((el.bounds.y + el.bounds.h / 2) / (ph / ROWS)))
426
+ const k = `${cx},${cy}`
427
+ grid.set(k, (grid.get(k) ?? 0) + 1)
428
+ }
429
+ for (const [k, count] of grid) {
430
+ if (count >= 5) {
431
+ const [cx, cy] = k.split(',').map(Number)
432
+ const gx = cx * (pw / COLS)
433
+ const gy = cy * (ph / ROWS)
434
+ findings.push({
435
+ severity: 'warning', code: 'hotspot', id: 'density',
436
+ message: `密集区(${count} 个元素)位于网格 (${k}),建议重点审阅(线框/架构页常见,可按元素类型比例判断)`,
437
+ area: { x: Math.round(gx), y: Math.round(gy), w: Math.round(pw / COLS), h: Math.round(ph / ROWS) },
438
+ })
439
+ }
440
+ }
441
+ // P2-5:density 分层——按内容元素(text/table/chart)计数,线框图/纯图形页不再误报
442
+ const contentCount = els.filter((e) => ['text', 'table', 'chart'].includes(e.kind)).length
443
+ if (contentCount >= 12) {
444
+ findings.push({ severity: 'warning', code: 'density', id: 'page', message: `页面含 ${contentCount} 个内容元素(文本/表格/图表),信息密度可能过高`, area: { x: 0, y: 0, w: pw, h: ph } })
445
+ }
446
+
447
+ return findings
448
+ }
449
+
450
+ export function verifyDeck(layout) {
451
+ const out = []
452
+ const size = layout.size ?? { width: 960, height: 540 }
453
+ const theme = layout.theme ?? null
454
+ let total = 0
455
+ for (const page of layout.pages ?? []) {
456
+ const findings = analyzePage(page, size)
457
+ const suggestions = theme ? aestheticSuggestions(page, size, theme) : []
458
+ // 主题一致性(需求 4:统一模板生成基础样式保持的机器保证)——strict=颜色门禁 + 字号建议
459
+ const conf = theme ? themeConformance(page, theme) : []
460
+ findings.push(...conf)
461
+ const confirmed = findings.filter((f) => f.severity === 'confirmed')
462
+ const errors = findings.filter((f) => f.severity === 'error')
463
+ const warns = findings.filter((f) => f.severity === 'warning')
464
+ total += findings.length + suggestions.length
465
+ const confOverlap = confirmed.filter((f) => f.code === 'expected-overlap').length
466
+ const confOut = confirmed.filter((f) => f.code === 'expected-out-of-safe').length
467
+ const confirmNote = [confOverlap ? `${confOverlap} 预期重叠✓` : '', confOut ? `${confOut} 预期出界✓` : ''].filter(Boolean).join(' / ')
468
+ out.push(`## 第 ${page.index + 1} 页(${page.name}) | ${errors.length} 错误 / ${warns.length} 警告 / ${suggestions.length} 建议${confirmNote}`)
469
+ if (findings.length === 0) out.push(' ✓ 核心断言无问题')
470
+ for (const f of findings) {
471
+ if (f.severity === 'confirmed') continue
472
+ out.push(` - [${f.severity === 'error' ? '✗' : '~'}] ${f.code}|${f.message}`)
473
+ }
474
+ for (const s of suggestions) {
475
+ out.push(` - [·] ${s.code}|${s.message}`)
476
+ }
477
+ }
478
+ return { text: out.join('\n'), total, size }
479
+ }
480
+
481
+ /**
482
+ * 主题一致性断言(需求 4「统一模板生成基础样式保持」的机器保证;v0.5.0):
483
+ * - 元素颜色(text color / shape fill / line color)必须是 theme.colors 成员或中性灰阶;
484
+ * strict 模式 => ERROR(门禁);suggest 模式 => warning;off => 跳过。
485
+ * - 字号不在 theme.textStyles => [·] 建议(3-4 级字号阶梯的软约束)。
486
+ * theme.themeConformance: 'strict' | 'suggest' | 'off'(缺省 strict;无 theme.colors 自动跳过)。
487
+ */
488
+ /**
489
+ * M2 文本实测档交叉(D3:实测=终审、估算=预检)。
490
+ * @param layout 估算档(layout.json:pages[].pageNo 1 基 + elements[].metrics)
491
+ * @param measured 实测档(preview/measured.json:pages[].pageNo 1 基 + elements[])
492
+ * **两档按 1 基 `pageNo` 配对**(`index` 只是各自文件的内部数组下标,两侧都是 0 基)——
493
+ * 这是 2026-09-18 明文化的契约:此前按 `index` 配对,而两侧底基不同(0 基 vs 1 基)⇒ 永不配对。
494
+ * 语义:实测报错(估算没报)= M2 核心捕获(error,估算漏报);实测复现(估算也报)= warning 佐证;
495
+ * 估算报错但实测通过 = warning(字体差异,人工确认);行数差异 = suggestion(断行差异目检)。
496
+ * 契约破坏(缺 pageNo / 找不到对应页)= error `measured-unpaired`,响亮报错而非静默跳过。
497
+ */
498
+ export function measuredCrossCheck(layout, measured) {
499
+ const out = []
500
+ if (!measured?.pages) return out
501
+ for (const mp of measured.pages ?? []) {
502
+ // 跨档配对键 = **1 基 pageNo**(2026-09-18 修)。
503
+ // 背景(真 bug,藏了整轮 1.0.0):此前这里是 `p.index === mp.index`,而两个生产者底基不同——
504
+ // layout.json 的 index 是 **0 基数组下标**(render-html 取 schema 的 pages.length),
505
+ // measured.json 的 index 曾是 **1 基页号**(measurement 取 i+1)⇒ 永远配不上,
506
+ // 整条 M2 通道恒返空结果,界面上显示"✓ 全部一致或实测通过"——**假绿灯**。
507
+ // 更隐蔽的是 offset 恰好为 1:一旦两侧元素 id 跨页复用,它会拿第 N 页的实测去比第 N+1 页的估算。
508
+ // 现在两侧都写 1 基 pageNo,index 退回"各自文件的内部下标"。
509
+ // 契约破坏**必须响亮**(这是本 bug 藏这么久的根因):配不上就报 measured-unpaired,
510
+ // 绝不静默 continue——静默返空与"真的没问题"在输出上不可区分。
511
+ if (mp.pageNo == null) {
512
+ out.push({ severity: 'error', code: 'measured-unpaired', page: mp.pageNo ?? null,
513
+ message: `实测档第 ${(mp.index ?? 0) + 1} 个条目缺 pageNo:preview/measured.json 是旧版产物,`
514
+ + '与 layout.json 的页号契约不一致——请重新运行 ppt_measure 后再用 measured=true 复核' })
515
+ continue
516
+ }
517
+ const lp = (layout.pages ?? []).find((p) => p.pageNo === mp.pageNo)
518
+ if (!lp) {
519
+ out.push({ severity: 'error', code: 'measured-unpaired', page: mp.pageNo,
520
+ message: `实测档 pageNo=${mp.pageNo} 在 layout.json 里找不到对应页(两档页号契约不一致)——`
521
+ + '常见于 ppt_measure 之后又增删过页面;请重新运行 ppt_render 与 ppt_measure 后复核' })
522
+ continue
523
+ }
524
+ const est = new Map((lp.elements ?? []).map((e) => [e.id, e]))
525
+ for (const el of mp.elements ?? []) {
526
+ const snap = est.get(el.id)
527
+ if (!snap) continue
528
+ if (snap.kind === 'text') {
529
+ const estOv = Number(snap.metrics?.overflowY ?? 0)
530
+ const meaOv = Number(el.overflowY ?? 0)
531
+ if (meaOv > 1 && estOv <= 1) {
532
+ out.push({ severity: 'error', code: 'measured-overflow', id: el.id,
533
+ message: `文本 "${el.id}" 实测溢出(实测 ${meaOv.toFixed(1)}px / 估算 ${estOv.toFixed(1)}px——估算漏报,M2 捕获):扩大容器或精简文案` })
534
+ } else if (meaOv > 1 && estOv > 1) {
535
+ out.push({ severity: 'warning', code: 'measured-overflow', id: el.id,
536
+ message: `文本 "${el.id}" 实测复现溢出(实测 ${meaOv.toFixed(1)}px / 估算 ${estOv.toFixed(1)}px):按上方 error 修正` })
537
+ } else if (estOv > 1 && meaOv <= 1) {
538
+ out.push({ severity: 'warning', code: 'measured-relief', id: el.id,
539
+ message: `文本 "${el.id}" 估算报溢出但实测通过(${estOv.toFixed(1)}px / ${meaOv.toFixed(1)}px)——字体渲染差异,人工确认后可忽略估算提示` })
540
+ }
541
+ const estLines = Number(snap.metrics?.lines ?? 0)
542
+ const meaLines = Number(el.lines ?? 0)
543
+ if (estLines > 0 && meaLines > estLines + 1) {
544
+ out.push({ severity: 'suggestion', code: 'measured-lines', id: el.id,
545
+ message: `文本 "${el.id}" 实测 ${meaLines} 行 vs 估算 ${estLines} 行(差 ${meaLines - estLines} 行)——断行差异,重点目检` })
546
+ }
547
+ } else {
548
+ // 非文本几何对照(建议级);rotation 元素跳过(旋转后 AABB 位移是真实物理,非漂移)
549
+ if (snap.rotation) continue
550
+ const sx = Number(snap.bounds?.x ?? 0)
551
+ const sy = Number(snap.bounds?.y ?? 0)
552
+ const d = Math.hypot(sx - Number(el.x), sy - Number(el.y))
553
+ if (d > 2) {
554
+ out.push({ severity: 'suggestion', code: 'measured-geometry', id: el.id,
555
+ message: `元素 "${el.id}" 实测位置 (${Math.round(el.x)},${Math.round(el.y)}) vs 声明 (${Math.round(sx)},${Math.round(sy)})——漂移 ${Math.round(d)}px` })
556
+ }
557
+ }
558
+ }
559
+ }
560
+ return out
561
+ }
562
+
563
+ export function themeConformance(page, theme) {
564
+ const out = []
565
+ const colors = new Set(Object.values(theme?.colors ?? {}).filter((c) => typeof c === 'string').map((c) => c.toUpperCase()))
566
+ if (!colors.size) return out
567
+ const mode = theme.themeConformance ?? 'strict'
568
+ if (mode === 'off') return out
569
+ const fsSet = new Set(Object.values(theme?.textStyles ?? {}).map((s) => s?.fontSize).filter(Boolean))
570
+ const strayFont = new Map() // fontSize -> [ids](v0.14.6 降噪:逐元素建议在 >3 处时聚合)
571
+ for (const el of page.elements ?? []) {
572
+ // v0.9.1:fill 为渐变对象时逐个 stop 颜色校验(不再逃过主题色门禁)
573
+ const cs = fillColorsOf(el)
574
+ for (const c of cs) {
575
+ if (typeof c === 'string' && /^#[0-9a-fA-F]{6}$/.test(c) && !colors.has(c.toUpperCase()) && !isNeutral(c)) {
576
+ out.push({
577
+ severity: mode === 'strict' ? 'error' : 'warning',
578
+ code: 'theme-conformance', id: el.id,
579
+ message: `元素 "${el.id}" 颜色 ${c} 不在主题色板(theme.colors)中,请改用主题色或中性色(模板一致性)`,
580
+ })
581
+ }
582
+ }
583
+ if (el.kind === 'text' && el.style?.fontSize && fsSet.size && !fsSet.has(el.style.fontSize)) {
584
+ if (!strayFont.has(el.style.fontSize)) strayFont.set(el.style.fontSize, [])
585
+ strayFont.get(el.style.fontSize).push(el.id)
586
+ }
587
+ }
588
+ if (strayFont.size) {
589
+ const total = [...strayFont.values()].reduce((n, ids) => n + ids.length, 0)
590
+ const sizes = [...strayFont.keys()].sort((a, b) => a - b)
591
+ if (total > 3) {
592
+ // 降噪(反馈 F):字号建议 >3 处 → 聚合为一条(列出档位与处数),避免淹没门禁级条目
593
+ out.push({ severity: 'suggestion', code: 'aesthetic-theme', id: 'fontStyle',
594
+ message: `字号 ${sizes.join('/')}pt 不在 theme.textStyles 中(本页 ${total} 处文本),建议将已用档位纳入主题样式(页面内可能漂移)` })
595
+ } else {
596
+ for (const [fs, ids] of strayFont) {
597
+ out.push({ severity: 'suggestion', code: 'aesthetic-theme', id: ids[0], message: `字号 ${fs}pt 不在 theme.textStyles 中,建议纳入主题样式(页面内可能漂移)` })
598
+ }
599
+ }
600
+ }
601
+ return out
602
+ }
603
+
604
+ /** 颜色集合(v0.9.1/0.11):文本色 / 形状 fill(渐变 → stops 颜色;{color, alpha} → color / line 色)。 */
605
+ function fillColorsOf(el) {
606
+ if (el.kind === 'text') return [el.style?.color].filter(Boolean)
607
+ if (el.kind === 'shape') {
608
+ const f = el.fill
609
+ if (typeof f === 'string') return [f]
610
+ if (f && Array.isArray(f.stops)) return f.stops.map((s) => s.color).filter(Boolean)
611
+ if (f && f.color) return [f.color]
612
+ return []
613
+ }
614
+ if (el.kind === 'line') return [el.line?.color].filter(Boolean)
615
+ return []
616
+ }
617
+
618
+ /**
619
+ * A8 修复(反馈二):承载面颜色提取——统一三种 fill 形态:
620
+ * 字符串 hex → 本身;{color, alpha} → alpha≥0.85 才算承载(半透明回退背景);渐变 → 取最接近中心的 stop。
621
+ * 返回 hex 字符串或 null(不承载)。
622
+ */
623
+ function carrierColorOf(el) {
624
+ const f = el.fill
625
+ if (typeof f === 'string' && /^#[0-9a-fA-F]{6}$/.test(f)) return f
626
+ if (f && typeof f === 'object') {
627
+ if (typeof f.color === 'string' && /^#[0-9a-fA-F]{6}$/.test(f.color) && (f.alpha ?? 1) >= 0.85) return f.color
628
+ if (f.type === 'gradient' && Array.isArray(f.stops) && f.stops.length) {
629
+ const poss = f.stops.map((s) => Number(s.pos ?? 0))
630
+ const mid = Math.max(...poss) > 1 ? 50 : 0.5 // 渐变 pos:百分比(0-100)或 0-1
631
+ let best = null
632
+ let bestD = Infinity
633
+ for (const s of f.stops) {
634
+ const d = Math.abs(Number(s.pos ?? 0) - mid)
635
+ if (d < bestD) { bestD = d; best = s }
636
+ }
637
+ if (best && typeof best.color === 'string' && /^#[0-9a-fA-F]{6}$/.test(best.color) && (best.alpha ?? 1) >= 0.85) return best.color
638
+ }
639
+ }
640
+ return null
641
+ }
642
+
643
+ function fmtRect(b) { return `[${Math.round(b.x)}, ${Math.round(b.y)}, ${Math.round(b.w)}, ${Math.round(b.h)}]` }
644
+
645
+ function fmtSa(sa) {
646
+ return `上${sa.top ?? 0} 下${sa.bottom ?? 0} 左${sa.left ?? 0} 右${sa.right ?? 0}`
647
+ }
648
+
649
+ /** P7(测试反馈):给出"一步到位"的合格区间建议,省一圈试错。 */
650
+ function safeAdvice(b, sa, pw, ph) {
651
+ const sx0 = sa.left ?? 0, sx1 = pw - (sa.right ?? 0)
652
+ const sy0 = sa.top ?? 0, sy1 = ph - (sa.bottom ?? 0)
653
+ const xs = []
654
+ const ys = []
655
+ if (b.x < sx0) xs.push(`x ≥ ${Math.floor(sx0)}`)
656
+ if (b.x + b.w > sx1) xs.push(`x ≤ ${Math.floor(sx1 - b.w)}`)
657
+ if (b.y < sy0) ys.push(`y ≥ ${Math.floor(sy0)}`)
658
+ if (b.y + b.h > sy1) ys.push(`y ≤ ${Math.floor(sy1 - b.h)}`)
659
+ const parts = []
660
+ if (xs.length) parts.push('x:' + xs.join(' 且 '))
661
+ if (ys.length) parts.push('y:' + ys.join(' 且 '))
662
+ return parts.length ? parts.join(';') + `(当前尺寸 ${Math.round(b.w)}×${Math.round(b.h)}pt 不变)` : '(边界内)'
663
+ }
664
+
665
+ /**
666
+ * 一键声明数据源(反馈 D2 ★★):收集页面上所有"警告级未声明重叠对"——
667
+ * 承载/装饰模式(shape×text、image×text、line×任意),不含内容互压(content-collision
668
+ * 不可声明)、不含 decoration(已豁免)、不含已声明对。返回 [[idA, idB], ...]。
669
+ */
670
+ export function collectDeclarable(page, size) {
671
+ const out = []
672
+ const els = page.elements ?? []
673
+ const declared = declaredClosure(page) // P0-2:闭包后已覆盖的隔层对不再建议
674
+ for (let i = 0; i < els.length; i++) {
675
+ for (let j = i + 1; j < els.length; j++) {
676
+ const a = els[i].bounds
677
+ const b = els[j].bounds
678
+ const ox = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)
679
+ const oy = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)
680
+ if (ox <= TOL || oy <= TOL) continue
681
+ if (!realOverlapGeom(els[i], els[j])) continue // P6:线元素 AABB 假阳性不进声明
682
+ const ri = roleOf(els[i])
683
+ const rj = roleOf(els[j])
684
+ if (ri === 'decoration' || rj === 'decoration') continue // 已完全豁免,无需声明
685
+ if (ri === 'content' && rj === 'content') continue // 内容互压:不可声明豁免
686
+ const key = pairKeyOf(els[i].id, els[j].id)
687
+ if (declared.has(key)) continue
688
+ out.push([els[i].id, els[j].id])
689
+ }
690
+ }
691
+ return out
692
+ }
693
+
694
+ function pairKeyOf(a, b) { return [a, b].sort().join(' × ') }
695
+
696
+ /** WCAG 相对亮度(支持 #rrggbb;其余返回 null)。 */
697
+ function relLum(hex) {
698
+ const m = /^#([0-9a-fA-F]{6})$/.exec(String(hex ?? ''))
699
+ if (!m) return null
700
+ const v = parseInt(m[1], 16)
701
+ const conv = (c) => {
702
+ const s = c / 255
703
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
704
+ }
705
+ return 0.2126 * conv((v >> 16) & 0xff) + 0.7152 * conv((v >> 8) & 0xff) + 0.0722 * conv(v & 0xff)
706
+ }
707
+
708
+ function contrastRatio(a, b) {
709
+ const la = relLum(a)
710
+ const lb = relLum(b)
711
+ if (la === null || lb === null) return null
712
+ const hi = Math.max(la, lb)
713
+ const lo = Math.min(la, lb)
714
+ return (hi + 0.05) / (lo + 0.05)
715
+ }
716
+
717
+ function contains(outer, inner) {
718
+ const T = 1
719
+ return inner.x >= outer.x - T && inner.y >= outer.y - T
720
+ && inner.x + inner.w <= outer.x + outer.w + T && inner.y + inner.h <= outer.y + outer.h + T
721
+ }
722
+
723
+ /** 线段 vs 矩形真实相交(Liang-Barsky 裁剪,pad = 线宽/2 + 容差)。 */
724
+ function segRectHit(p1, p2, rect, pad = 1) {
725
+ const x0 = rect.x - pad, y0 = rect.y - pad, x1 = rect.x + rect.w + pad, y1 = rect.y + rect.h + pad
726
+ const dx = p2[0] - p1[0], dy = p2[1] - p1[1]
727
+ let t0 = 0, t1 = 1
728
+ const p = [-dx, dx, -dy, dy]
729
+ const q = [p1[0] - x0, x1 - p1[0], p1[1] - y0, y1 - p1[1]]
730
+ for (let i = 0; i < 4; i++) {
731
+ if (p[i] === 0) { if (q[i] < 0) return false }
732
+ else {
733
+ const t = q[i] / p[i]
734
+ if (p[i] < 0) { if (t > t1) return false; if (t > t0) t0 = t }
735
+ else { if (t < t0) return false; if (t < t1) t1 = t }
736
+ }
737
+ }
738
+ return true
739
+ }
740
+
741
+ /** 线段互交(跨立测试:异号 = 相交)。 */
742
+ function segsHit(a1, a2, b1, b2) {
743
+ const cross = (ox, oy, px, py) => ox * py - oy * px
744
+ const d1 = cross(b2[0] - b1[0], b2[1] - b1[1], a1[0] - b1[0], a1[1] - b1[1])
745
+ const d2 = cross(b2[0] - b1[0], b2[1] - b1[1], a2[0] - b1[0], a2[1] - b1[1])
746
+ const d3 = cross(a2[0] - a1[0], a2[1] - a1[1], b1[0] - a1[0], b1[1] - a1[1])
747
+ const d4 = cross(a2[0] - a1[0], a2[1] - a1[1], b2[0] - a1[0], b2[1] - a1[1])
748
+ return ((d1 > 0) !== (d2 > 0)) && ((d3 > 0) !== (d4 > 0))
749
+ }
750
+
751
+ /**
752
+ * P6 修复(测试反馈 ★):元素对真实重叠判定——线元素用线段真实几何(线段×矩形 / 线段×线段),
753
+ * 非线元素沿用 AABB。返回 false = AABB 假阳性(不报重叠、不进声明、不进 collectDeclarable)。
754
+ */
755
+ function realOverlapGeom(a, b) {
756
+ const isLine = (e) => e.kind === 'line' && Array.isArray(e.points) && e.points.length >= 2
757
+ if (isLine(a) && isLine(b)) return segsHit(a.points[0], a.points[1], b.points[0], b.points[1])
758
+ if (isLine(a)) return segRectHit(a.points[0], a.points[1], b.bounds, (a.line?.width ?? 1) / 2 + 1)
759
+ if (isLine(b)) return segRectHit(b.points[0], b.points[1], a.bounds, (b.line?.width ?? 1) / 2 + 1)
760
+ return true
761
+ }
762
+
763
+ /** 设计声明集合:expectedOverlaps: [{pair: [idA, idB]}] → Set("A × B" 排序键)。 */
764
+ function buildDeclaredSet(list) {
765
+ const set = new Set()
766
+ for (const po of list ?? []) {
767
+ if (po && Array.isArray(po.pair) && po.pair.length === 2) {
768
+ set.add([po.pair[0], po.pair[1]].sort().join(' × '))
769
+ }
770
+ }
771
+ return set
772
+ }
773
+
774
+ /**
775
+ * 声明闭包(P0-2 反馈 ★★):嵌套承载只需声明**相邻层**(如 card×inBox、inBox×inText),
776
+ * 隔层组合(card×inText)由"包含关系传递"自动通过——声明 (a,b)(b 在 a 内)且 c 在 b 内
777
+ * ⇒ (a,c) 视为已声明(迭代至不动点)。语义:承载沿嵌套链传递,避免声明清单膨胀 1/3 的传递对。
778
+ */
779
+ function declaredClosure(page) {
780
+ const els = page.elements ?? []
781
+ const decl = buildDeclaredSet(page.expectedOverlaps ?? [])
782
+ const T = 1
783
+ const byId = new Map(els.map((e) => [e.id, e]))
784
+ const inside = (innerBounds, outerBounds) => innerBounds.x >= outerBounds.x - T && innerBounds.y >= outerBounds.y - T
785
+ && innerBounds.x + innerBounds.w <= outerBounds.x + outerBounds.w + T
786
+ && innerBounds.y + innerBounds.h <= outerBounds.y + outerBounds.h + T
787
+ const within = (outerId) => {
788
+ const o = byId.get(outerId)
789
+ if (!o) return []
790
+ return els.filter((e) => e.id !== outerId && inside(e.bounds, o.bounds))
791
+ }
792
+ let changed = true
793
+ let guard = 0
794
+ while (changed && guard++ < 24) {
795
+ changed = false
796
+ for (const key of [...decl]) {
797
+ let [a, b] = key.split(' × ')
798
+ // 定向:pairKey 排序丢失"容器"信息——若 b 包含 a,则交换使 a 为容器、b 为被承载
799
+ const A = byId.get(a)
800
+ const B = byId.get(b)
801
+ if (A && B && inside(A.bounds, B.bounds)) { const t = a; a = b; b = t }
802
+ for (const c of within(b)) {
803
+ const k = [a, c.id].sort().join(' × ')
804
+ if (!decl.has(k)) { decl.add(k); changed = true }
805
+ }
806
+ }
807
+ }
808
+ return decl
809
+ }
810
+
811
+ /** 层叠角色:显式 role(background/content/decoration)覆盖推断;
812
+ * 默认推断:text/table/chart=content;shape/image=background;line=line(任意参与告警)。
813
+ * decoration = 完全豁免(声明为纯装饰层,不参与重叠报告)。 */
814
+ function roleOf(el) {
815
+ const r = el.role
816
+ if (r === 'background' || r === 'content' || r === 'decoration') return r
817
+ if (el.kind === 'line') return 'line'
818
+ if (el.kind === 'text' || el.kind === 'table' || el.kind === 'chart') return 'content'
819
+ return 'background' // shape/image
820
+ }
821
+
822
+ function union(a, b) {
823
+ return {
824
+ x: Math.min(a.x, b.x), y: Math.min(a.y, b.y),
825
+ w: Math.max(a.x + a.w, b.x + b.w) - Math.min(a.x, b.x),
826
+ h: Math.max(a.y + a.h, b.y + b.h) - Math.min(a.y, b.y),
827
+ }
828
+ }