dsh-ppt-studio 1.1.4 → 1.2.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.
- package/README.md +5 -2
- package/docs/00-/350/256/276/350/256/241/346/272/220.md +4 -0
- package/docs/02-/346/212/200/346/234/257/346/212/245/345/221/212.md +114 -1
- package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +171 -0
- package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +24 -1
- package/docs/06-/350/257/204/345/256/241/344/270/216/346/265/213/350/257/225.md +2 -1
- package/docs/07-/345/244/215/346/235/202/345/233/276/350/203/275/345/212/233/347/253/213/351/241/271/344/270/216/351/243/216/351/231/251/347/231/273/350/256/260.md +319 -0
- package/docs/08-/351/230/266/346/256/265A/350/256/276/350/256/241/350/257/264/346/230/216-/347/273/223/346/236/204/345/205/263/347/263/273/344/270/216/345/233/276/344/270/223/345/261/236/346/240/241/351/252/214.md +306 -0
- package/docs/09-/344/273/243/347/240/201/345/234/260/345/233/276/344/270/216/346/250/241/345/235/227/350/201/214/350/264/243.md +148 -0
- package/docs/10-/345/256/277/344/270/273/350/200/246/345/220/210/347/202/271/346/270/205/345/215/225.md +49 -0
- package/docs/11-/346/234/257/350/257/255/350/241/250.md +42 -0
- package/docs/12-/345/244/215/346/235/202/345/233/276/351/200/232/350/267/257/347/232/204/351/200/211/346/213/251/344/270/216/351/252/214/346/224/266.md +65 -0
- package/docs/13-/345/217/221/347/211/210/345/207/206/345/244/207/350/256/241/345/210/222.md +79 -0
- package/docs/14-/345/217/221/345/270/203/346/270/205/345/215/225/357/274/210/350/215/211/347/250/277/357/274/211.md +37 -0
- package/docs/17-/346/211/213/347/224/27313/351/241/265/350/255/246/345/221/212/351/200/220/346/235/241/345/244/204/347/275/256.md +102 -0
- package/examples/fx/fx-pro/preview/layout.json +12 -0
- package/examples/fx/fx-pro/preview/measured.json +1 -1
- package/examples/fx/fx-pro.pptx +0 -0
- package/examples/fx/seed.pptx +0 -0
- package/examples/fx/single.pptx +0 -0
- package/examples/fx/spliced.pptx +0 -0
- package/examples/smoke/imported/preview-rt/layout.json +3 -0
- package/examples/smoke/imported/source.pptx +0 -0
- package/examples/smoke/out-smoke.pptx +0 -0
- package/examples/smoke/preview/layout.json +3 -0
- package/lib/group-render.js +177 -0
- package/lib/msrender.js +27 -12
- package/lib/pptd/blocks.js +52 -0
- package/lib/pptd/export-pptx.js +266 -11
- package/lib/pptd/layout.js +54 -3
- package/lib/pptd/line-rules.js +259 -0
- package/lib/pptd/line-text-rule.js +90 -0
- package/lib/pptd/relations.js +615 -0
- package/lib/pptd/render-html.js +19 -1
- package/lib/pptd/route-geometry.js +120 -0
- package/lib/pptd/schema.js +146 -11
- package/lib/pptd/style-profile.js +133 -0
- package/lib/router.js +2 -2
- package/lib/scaffold.js +54 -1
- package/lib/tools.js +22 -4
- package/lib/verify.js +69 -1
- package/package.json +20 -3
- package/scripts/audit-manual-facts.mjs +15 -3
- package/scripts/bench-diagram-scale.mjs +165 -0
- package/scripts/check-generators.mjs +22 -0
- package/scripts/check-lib-sync.mjs +33 -0
- package/scripts/check-line-text.mjs +30 -0
- package/scripts/check-package.mjs +60 -0
- package/scripts/check-removed-traces.mjs +37 -0
- package/scripts/check-syntax.mjs +44 -0
- package/scripts/check-unknown-keys.mjs +114 -0
- package/scripts/fixtures/handdrawn-baseline.json +177 -0
- package/scripts/fixtures/prompt-cost-baseline.json +178 -0
- package/scripts/fixtures/workflow-baseline.json +20 -15
- package/scripts/handdrawn-option-probe.mjs +78 -0
- package/scripts/probe-prompt-cost.mjs +127 -0
- package/scripts/red-probe.mjs +75 -0
- package/scripts/render-group.mjs +72 -0
- package/scripts/smoke.mjs +183 -12
- package/scripts/test-fast.mjs +108 -0
- package/scripts/test-map.mjs +162 -0
- package/scripts/test-release.mjs +64 -0
- package/scripts/verify-diagram-relations.mjs +405 -0
- package/scripts/verify-handdrawn.mjs +115 -0
- package/scripts/verify-handwritten.mjs +203 -0
- package/scripts/verify-native-charts.mjs +25 -10
- package/scripts/verify-primitives.mjs +714 -0
- package/scripts/verify-products.mjs +37 -0
- package/scripts/verify-shape.mjs +84 -0
- package/scripts/visual-pair-batch.mjs +77 -0
- package/skills/diagram-drawing/SKILL.md +23 -0
- package/skills/diagram-drawing/capability-matrix.md +22 -0
- package/skills/diagram-drawing/composition.md +22 -0
- package/skills/diagram-drawing/mechanisms.md +141 -0
- package/skills/diagram-drawing/pitfalls.md +89 -0
- package/skills/diagram-drawing/routing.md +17 -0
- package/skills/diagram-drawing/text-and-spacing.md +16 -0
- package/skills/diagram-drawing/verify.md +50 -0
- package/skills/diagrams/compare.md +24 -0
- package/skills/diagrams/flow.md +24 -0
- package/skills/diagrams/funnel.md +25 -0
- package/skills/diagrams/layers.md +18 -0
- package/skills/diagrams/loop.md +25 -0
- package/skills/diagrams/matrix.md +23 -0
- package/skills/diagrams/sequence.md +17 -0
- package/skills/diagrams/state.md +34 -0
- package/skills/diagrams/steps-ring.md +24 -0
- package/skills/diagrams/swimlane.md +23 -0
- package/skills/diagrams/timeline.md +24 -0
- package/skills/diagrams/tree.md +24 -0
- package/skills/ppt-studio-manual/SKILL.md +2 -2
- package/templates/academic-white/preview.png +0 -0
- package/templates/business-blue/preview.png +0 -0
- package/templates/pitch-bold/preview.png +0 -0
- package/templates/tech-dark/preview.png +0 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
*
|
|
4
|
+
* · `pyramid: true` ⇒ 反向(逐段**放宽**的金字塔,而不是收窄的漏斗)
|
|
5
|
+
* · `minRatio` ⇒ 末段宽度相对首段的比例(末段宽 = 首段宽 × ratio)
|
|
6
|
+
* 本探针用**显式 custGeom 元素**把两种变体画出来并过门禁(不需要族代码)。
|
|
7
|
+
*
|
|
8
|
+
* 用法:node scripts/handdrawn-option-probe.mjs
|
|
9
|
+
*/
|
|
10
|
+
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { resolveDeck } from '../src/pptd/schema.js'
|
|
13
|
+
import { renderDeck } from '../src/pptd/render-html.js'
|
|
14
|
+
import { verifyDeck } from '../src/verify.js'
|
|
15
|
+
|
|
16
|
+
const ROOT = new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')
|
|
17
|
+
const dir = join(ROOT, 'examples', 'option-probe')
|
|
18
|
+
mkdirSync(join(dir, 'pages'), { recursive: true })
|
|
19
|
+
writeFileSync(join(dir, 'deck.yaml'), [
|
|
20
|
+
'version: 1', 'title: 选项探针', 'size: [960, 540]', 'theme:', ' colors:',
|
|
21
|
+
' primary: "#7C3AED"', ' accent: "#E11D48"', ' soft: "#EDE9FE"',
|
|
22
|
+
' text: "#2E1065"', ' bg: "#FAF5FF"', 'pages:',
|
|
23
|
+
' - pages/pyramid.yaml', ' - pages/narrow.yaml', '',
|
|
24
|
+
].join('\n'))
|
|
25
|
+
|
|
26
|
+
// 画一条"逐段变宽/变窄"的 custGeom 梯形带(手画等价物)
|
|
27
|
+
function bands(rows, title, sub, label) {
|
|
28
|
+
const bw = 800, bx = 80, bandH = 70, topY = 148
|
|
29
|
+
const L = ['pageType: content', 'elements:']
|
|
30
|
+
L.push(' - elementId: title', ' elementType: text', ' bounds: [60, 34, 760, 38]',
|
|
31
|
+
` content: { text: '${title}', fontSize: 25, color: '#2E1065', bold: true }`)
|
|
32
|
+
L.push(' - elementId: sub', ' elementType: text', ' bounds: [60, 76, 820, 22]',
|
|
33
|
+
` content: { text: '${sub}', fontSize: 12, color: '#64748B' }`)
|
|
34
|
+
rows.forEach(([name, topW], i) => {
|
|
35
|
+
const y = topY + i * bandH
|
|
36
|
+
const botW = i + 1 < rows.length ? rows[i + 1][1] : Math.round(topW * (label === 'pyramid' ? 1.5 : 0.5))
|
|
37
|
+
const lw = Math.max(120, Math.min(topW, botW) - 60)
|
|
38
|
+
L.push(` - elementId: b${i}`, ' elementType: shape', ' kind: custGeom',
|
|
39
|
+
` bounds: [${bx}, ${y}, ${bw}, ${bandH}]`,
|
|
40
|
+
` fill: '${i === rows.length - 1 ? '#E11D48' : '#7C3AED'}'`,
|
|
41
|
+
" line: { color: '#FFFFFF', width: 1.5 }", ` contains: [bt${i}]`,
|
|
42
|
+
' path:', ` w: ${bw}`, ` h: ${bandH}`, ' commands:',
|
|
43
|
+
` - { cmd: moveTo, pts: [[${Math.round((bw - topW) / 2)}, 0]] }`,
|
|
44
|
+
` - { cmd: lnTo, pts: [[${Math.round((bw + topW) / 2)}, 0]] }`,
|
|
45
|
+
` - { cmd: lnTo, pts: [[${Math.round((bw + botW) / 2)}, ${bandH}]] }`,
|
|
46
|
+
` - { cmd: lnTo, pts: [[${Math.round((bw - botW) / 2)}, ${bandH}]] }`,
|
|
47
|
+
' - { cmd: close }')
|
|
48
|
+
L.push(` - elementId: bt${i}`, ' elementType: text',
|
|
49
|
+
` bounds: [${Math.round(480 - lw / 2)}, ${y + 24}, ${lw}, 26]`,
|
|
50
|
+
` content: { text: '${name}', fontSize: 15, color: '#FFFFFF', bold: true, align: center }`)
|
|
51
|
+
})
|
|
52
|
+
return L.join('\n') + '\n'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 变体 A:pyramid(逐段放宽 = 金字塔,族里 pyramid: true 的等价物)
|
|
56
|
+
writeFileSync(join(dir, 'pages', 'pyramid.yaml'), bands(
|
|
57
|
+
[['苗种', 240], ['育肥', 400], ['成鱼', 560], ['直供', 720]],
|
|
58
|
+
'金字塔变体(pyramid 等价)', '逐段放宽:苗种 240 → 育肥 400 → 成鱼 560 → 直供 720', 'pyramid'))
|
|
59
|
+
|
|
60
|
+
// 变体 B:minRatio(末段宽 ≈ 首段宽 × 比例;这里用 0.25 的收窄漏斗)
|
|
61
|
+
writeFileSync(join(dir, 'pages', 'narrow.yaml'), bands(
|
|
62
|
+
[['存栏', 760], ['达规格', 560], ['抽检', 360], ['直供', 190]],
|
|
63
|
+
'窄口漏斗变体(minRatio 等价)', '末段宽 ≈ 首段 × 0.25:760 → 560 → 360 → 190', 'narrow'))
|
|
64
|
+
|
|
65
|
+
const ctx = await resolveDeck(dir)
|
|
66
|
+
await renderDeck(ctx, { out: 'preview' })
|
|
67
|
+
const layout = JSON.parse(readFileSync(join(dir, 'preview', 'layout.json'), 'utf8'))
|
|
68
|
+
const pages = Array.isArray(layout.pages) ? layout.pages : Object.values(layout.pages)
|
|
69
|
+
let bad = 0
|
|
70
|
+
pages.forEach((p, i) => {
|
|
71
|
+
const v = verifyDeck({ pages: [p] })
|
|
72
|
+
const kind = i === 0 ? 'pyramid' : 'minRatio'
|
|
73
|
+
console.log(' ' + (v.errors.length ? '✗' : '✓') + ' ' + kind + ':元素 ' + p.elements.length + '|门禁 错' + v.errors.length + '/警' + v.warns.length)
|
|
74
|
+
v.errors.slice(0, 3).forEach((e) => console.log(' [错][' + e.code + '] ' + String(e.message).slice(0, 88)))
|
|
75
|
+
if (v.errors.length) bad++
|
|
76
|
+
})
|
|
77
|
+
console.log('==== 选项等价探针:' + (bad === 0 ? '两个变体均 0 错(族的 pyramid / minRatio 在手画下等价可复现)' : bad + ' 页未通过') + ' ====')
|
|
78
|
+
process.exit(bad === 0 ? 0 : 1)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 常驻 prompt 成本探针(阶段 A 硬门槛之一:"无图任务 prompt 增幅 ≤5%")。
|
|
4
|
+
*
|
|
5
|
+
* 用法:
|
|
6
|
+
* node scripts/probe-prompt-cost.mjs # 测量并**写入**基线 scripts/fixtures/prompt-cost-baseline.json
|
|
7
|
+
* node scripts/probe-prompt-cost.mjs --check # 与基线对比;总成本增幅 > 5% ⇒ 退出码 1(阶段 B 引入 ppt_diagram 时用)
|
|
8
|
+
*
|
|
9
|
+
* 度量口径(**只看模型真正要读的部分**,不看执行结果文本):
|
|
10
|
+
* ① 工具面:每个工具的 name + description + parameters schema(JSON 形态)——output 渲染器不进模型
|
|
11
|
+
* ② 技能目录:skills/<name>/SKILL.md 的 front-matter(name + description)——正文只在加载时进上下文
|
|
12
|
+
* ③ 工作流提示段:router.workflowSection(...)(仅在 workflowActive 时注入)
|
|
13
|
+
*
|
|
14
|
+
* token 估计采用 CJK 感知的保守口径:CJK 字符按 1 token/字,其余按 4 字节/token。
|
|
15
|
+
* 这是**估算**,用途是"同一口径下的相对增幅"(正是 ≤5% 这条门槛要判的东西),不是绝对 token 数。
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync, readdirSync, writeFileSync, existsSync } from 'node:fs'
|
|
18
|
+
import { join, dirname } from 'node:path'
|
|
19
|
+
import { fileURLToPath } from 'node:url'
|
|
20
|
+
import { registerTools } from '../lib/tools.js'
|
|
21
|
+
import { workflowSection } from '../lib/router.js'
|
|
22
|
+
|
|
23
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
24
|
+
const BASELINE = join(ROOT, 'scripts', 'fixtures', 'prompt-cost-baseline.json')
|
|
25
|
+
const GROWTH_LIMIT = 1.05
|
|
26
|
+
|
|
27
|
+
/** CJK 感知的 token 估算(保守:CJK 每字 1 token)。 */
|
|
28
|
+
export function estTokens(s) {
|
|
29
|
+
let cjk = 0
|
|
30
|
+
for (const ch of s) if (/[\u3000-\u9fff\uff00-\uffef]/.test(ch)) cjk++
|
|
31
|
+
const rest = s.length - cjk
|
|
32
|
+
return Math.ceil(cjk + rest / 4)
|
|
33
|
+
}
|
|
34
|
+
const bytes = (s) => Buffer.byteLength(s, 'utf8')
|
|
35
|
+
|
|
36
|
+
// ① 工具面:用桩 ctx 捕获全部注册定义(registerTools 只用 effect/tools.register/get)
|
|
37
|
+
const captured = new Map()
|
|
38
|
+
const stub = {
|
|
39
|
+
effect: (cb) => { cb(); return () => {} },
|
|
40
|
+
tools: { register: (def) => captured.set(def.name, def) },
|
|
41
|
+
get: () => undefined,
|
|
42
|
+
}
|
|
43
|
+
registerTools(stub)
|
|
44
|
+
// ppt_state 由 statusToolFor 单独注册(也是模型可见的常驻工具)⇒ 一并计入
|
|
45
|
+
try {
|
|
46
|
+
const { statusToolFor } = await import('../lib/index.js')
|
|
47
|
+
statusToolFor(stub, 'profile')
|
|
48
|
+
} catch (e) {
|
|
49
|
+
console.log(`(提示:ppt_state 未能计入——${e.message})`)
|
|
50
|
+
}
|
|
51
|
+
const tools = [...captured.values()].map((d) => {
|
|
52
|
+
const text = JSON.stringify({ name: d.name, description: d.description ?? '', parameters: d.parameters ?? {} })
|
|
53
|
+
return { name: d.name, chars: text.length, bytes: bytes(text), tokens: estTokens(text) }
|
|
54
|
+
}).sort((a, b) => b.tokens - a.tokens)
|
|
55
|
+
|
|
56
|
+
// ② 技能目录:front-matter 的 name + description
|
|
57
|
+
const skills = []
|
|
58
|
+
const skillsDir = join(ROOT, 'skills')
|
|
59
|
+
for (const name of readdirSync(skillsDir).sort()) {
|
|
60
|
+
const f = join(skillsDir, name, 'SKILL.md')
|
|
61
|
+
if (!existsSync(f)) continue
|
|
62
|
+
const head = readFileSync(f, 'utf8').split(/^---\s*$/m)[1] ?? ''
|
|
63
|
+
const desc = (head.match(/^description:\s*(.*)$/m)?.[1] ?? '').trim()
|
|
64
|
+
const nm = (head.match(/^name:\s*(.*)$/m)?.[1] ?? name).trim()
|
|
65
|
+
const text = `${nm}\n${desc}`
|
|
66
|
+
skills.push({ name: nm, chars: text.length, bytes: bytes(text), tokens: estTokens(text) })
|
|
67
|
+
}
|
|
68
|
+
skills.sort((a, b) => b.tokens - a.tokens)
|
|
69
|
+
|
|
70
|
+
// ③ 工作流提示段(仅有 workflowActive 时注入;两种档都要测——quick 档另有字节不变守卫)
|
|
71
|
+
// 注意:workflowSection 返回 **{name, text}** 而不是字符串;cfg 需要 engine/mode/fidelity/quality/review/pauseAfter 等字段。
|
|
72
|
+
const WF_CFG = { mode: 'normal', engine: 'auto', fidelity: 'balanced', quality: 'standard', review: 'full', pauseAfter: [] }
|
|
73
|
+
function measureWorkflow(cfg) {
|
|
74
|
+
try {
|
|
75
|
+
const r = workflowSection('make', cfg)
|
|
76
|
+
const text = typeof r === 'string' ? r : (r?.text ?? '')
|
|
77
|
+
return text ? { tokens: estTokens(text), chars: text.length } : { tokens: 0, chars: 0 }
|
|
78
|
+
} catch (e) {
|
|
79
|
+
return { tokens: 0, chars: 0, error: e.message }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const wfStd = measureWorkflow(WF_CFG)
|
|
83
|
+
const wfQuick = measureWorkflow({ ...WF_CFG, quick: true })
|
|
84
|
+
let wf = { tokens: wfStd.tokens, chars: wfStd.chars, quickTokens: wfQuick.tokens, quickChars: wfQuick.chars, note: wfStd.error ?? '' }
|
|
85
|
+
|
|
86
|
+
const sum = (list) => list.reduce((n, x) => n + x.tokens, 0)
|
|
87
|
+
const totals = {
|
|
88
|
+
tools: sum(tools),
|
|
89
|
+
skills: sum(skills),
|
|
90
|
+
workflow: wf.tokens,
|
|
91
|
+
all: sum(tools) + sum(skills) + wf.tokens,
|
|
92
|
+
}
|
|
93
|
+
const report = {
|
|
94
|
+
measuredAt: new Date().toISOString().slice(0, 10),
|
|
95
|
+
estimator: 'CJK=1 token/字;其余=4 字节/token',
|
|
96
|
+
toolCount: tools.length,
|
|
97
|
+
tools,
|
|
98
|
+
skills,
|
|
99
|
+
workflow: wf,
|
|
100
|
+
totals,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const check = process.argv.includes('--check')
|
|
104
|
+
let fail = 0
|
|
105
|
+
console.log('==== 常驻 prompt 成本(模型要读的部分)====')
|
|
106
|
+
console.log(`工具面:${tools.length} 个工具 = ${totals.tools} tokens(前 5:${tools.slice(0, 5).map((t) => `${t.name} ${t.tokens}`).join('|')})`)
|
|
107
|
+
console.log(`技能目录:${skills.length} 个 = ${totals.skills} tokens(${skills.map((s) => `${s.name.replace('ppt-studio-', '')} ${s.tokens}`).join('|')})`)
|
|
108
|
+
console.log(`工作流段:${totals.workflow} tokens${wf.note ? `(${wf.note})` : ''}`)
|
|
109
|
+
console.log(`合计(不含工作流段,即"无图任务"的常驻面):${totals.tools + totals.skills} tokens;含工作流段 ${totals.all} tokens`)
|
|
110
|
+
|
|
111
|
+
if (check) {
|
|
112
|
+
if (!existsSync(BASELINE)) { console.log(`\n✗ 基线不存在(${BASELINE})——先不带 --check 跑一次生成基线`); process.exit(1) }
|
|
113
|
+
const base = JSON.parse(readFileSync(BASELINE, 'utf8'))
|
|
114
|
+
const baseResident = base.totals.tools + base.totals.skills
|
|
115
|
+
const nowResident = totals.tools + totals.skills
|
|
116
|
+
const growth = nowResident / baseResident
|
|
117
|
+
const okAll = totals.all <= base.totals.all * GROWTH_LIMIT
|
|
118
|
+
const okResident = nowResident <= baseResident * GROWTH_LIMIT
|
|
119
|
+
console.log(`\n基线(${base.measuredAt}):常驻 ${baseResident} / 含工作流 ${base.totals.all}`)
|
|
120
|
+
console.log(`${okResident ? '✓' : '✗'} 常驻面增幅 ${((growth - 1) * 100).toFixed(2)}%(上限 +5%)`)
|
|
121
|
+
console.log(`${okAll ? '✓' : '✗'} 含工作流段增幅 ${(((totals.all / base.totals.all) - 1) * 100).toFixed(2)}%(上限 +5%)`)
|
|
122
|
+
if (!okResident || !okAll) fail = 1
|
|
123
|
+
} else {
|
|
124
|
+
writeFileSync(BASELINE, JSON.stringify(report, null, 2) + '\n')
|
|
125
|
+
console.log(`\n✓ 基线已写入 ${BASELINE.replace(ROOT + '\\', '')}`)
|
|
126
|
+
}
|
|
127
|
+
if (fail) { console.log('\n✗ 常驻 prompt 成本超出门槛'); process.exit(1) }
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 红样本探针(G4 验收证据)
|
|
4
|
+
* 红:故意造缺陷,断言门禁必须抓到。
|
|
5
|
+
* 绿:断言引擎**真实契约** —— 2 点斜向直连线保持直线(cxnSp);3 点折线的斜段被正交化。
|
|
6
|
+
* 用法:node scripts/red-probe.mjs
|
|
7
|
+
*/
|
|
8
|
+
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { resolveDeck } from '../src/pptd/schema.js'
|
|
11
|
+
import { renderDeck } from '../src/pptd/render-html.js'
|
|
12
|
+
import { verifyDeck } from '../src/verify.js'
|
|
13
|
+
|
|
14
|
+
const root = new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')
|
|
15
|
+
const dir = join(root, 'examples', 'red-probe')
|
|
16
|
+
mkdirSync(join(dir, 'pages'), { recursive: true })
|
|
17
|
+
writeFileSync(join(dir, 'deck.yaml'), [
|
|
18
|
+
'version: 1', 'title: 红样本探针', 'size: [960, 540]', 'theme:', ' colors:',
|
|
19
|
+
' primary: "#7C3AED"', ' accent: "#E11D48"', ' soft: "#EDE9FE"',
|
|
20
|
+
' text: "#2E1065"', ' bg: "#FAF5FF"', 'pages:', ' - pages/red.yaml', '',
|
|
21
|
+
].join('\n'))
|
|
22
|
+
|
|
23
|
+
const L = ['pageType: content', 'elements:']
|
|
24
|
+
L.push(' - elementId: title', ' elementType: text', ' bounds: [60, 34, 400, 30]',
|
|
25
|
+
" content: { text: '红样本探针', fontSize: 22, color: '#2E1065', bold: true }")
|
|
26
|
+
// 红①:线穿文字
|
|
27
|
+
L.push(' - elementId: lab', ' elementType: text', ' bounds: [300, 240, 120, 24]',
|
|
28
|
+
" content: { text: '残饵画面', fontSize: 12, color: '#334155' }")
|
|
29
|
+
L.push(' - elementId: life', ' elementType: line', ' points: [[360, 180], [360, 330]]',
|
|
30
|
+
" line: { color: '#94A3B8', width: 1.2 }", ' arrow: false')
|
|
31
|
+
// 红②:箭头前直段 7px(L 形:守卫不修 ⇒ 门禁报)
|
|
32
|
+
L.push(' - elementId: short', ' elementType: line', ' points: [[100, 430], [200, 430], [200, 437]]',
|
|
33
|
+
" line: { color: '#7C3AED', width: 2 }", ' arrow: true')
|
|
34
|
+
// 红③:端点离目标 10px
|
|
35
|
+
L.push(' - elementId: tgtGap', ' elementType: shape', ' kind: roundRect', ' bounds: [570, 400, 120, 60]',
|
|
36
|
+
" fill: '#EDE9FE'")
|
|
37
|
+
L.push(' - elementId: gap', ' elementType: line', ' points: [[470, 430], [560, 430]]',
|
|
38
|
+
" line: { color: '#7C3AED', width: 2 }", ' arrow: true')
|
|
39
|
+
// 红④:箭头尖端插进图形
|
|
40
|
+
L.push(' - elementId: tgtIn', ' elementType: shape', ' kind: roundRect', ' bounds: [730, 380, 120, 60]',
|
|
41
|
+
" fill: '#EDE9FE'")
|
|
42
|
+
L.push(' - elementId: inside', ' elementType: line', ' points: [[660, 410], [760, 410]]',
|
|
43
|
+
" line: { color: '#E11D48', width: 2 }", ' arrow: true')
|
|
44
|
+
// 绿①:2 点斜向直连线 —— 契约要求保持直线(cxnSp)
|
|
45
|
+
L.push(' - elementId: diag2', ' elementType: line', ' points: [[100, 140], [220, 200]]',
|
|
46
|
+
" line: { color: '#64748B', width: 1.5 }", ' arrow: false')
|
|
47
|
+
// 绿②:3 点折线含斜段 —— 契约要求被正交化
|
|
48
|
+
L.push(' - elementId: poly', ' elementType: line', ' points: [[600, 120], [700, 180], [760, 180]]',
|
|
49
|
+
" line: { color: '#64748B', width: 1.5 }", ' arrow: false')
|
|
50
|
+
writeFileSync(join(dir, 'pages', 'red.yaml'), L.join('\n') + '\n')
|
|
51
|
+
|
|
52
|
+
const ctx = await resolveDeck(dir)
|
|
53
|
+
await renderDeck(ctx, { out: 'preview' })
|
|
54
|
+
const layout = JSON.parse(readFileSync(join(dir, 'preview', 'layout.json'), 'utf8'))
|
|
55
|
+
const pages = Array.isArray(layout.pages) ? layout.pages : Object.values(layout.pages)
|
|
56
|
+
const v = verifyDeck({ pages: [pages[0]] })
|
|
57
|
+
const codes = new Set([...v.errors, ...v.warns].map((m) => m.code))
|
|
58
|
+
const get = (id) => (pages[0].elements ?? []).find((e) => e.id === id)?.points ?? []
|
|
59
|
+
const axisOf = (pts) => pts.every((p, i) => i === 0 || Math.abs(p[0] - pts[i - 1][0]) < 0.5 || Math.abs(p[1] - pts[i - 1][1]) < 0.5)
|
|
60
|
+
const diag2 = get('diag2')
|
|
61
|
+
const poly = get('poly')
|
|
62
|
+
const endOff = ['line-end-off-edge', 'arrow-end-gap'].some((c) => codes.has(c))
|
|
63
|
+
const rows = [
|
|
64
|
+
['line-cross-text', codes.has('line-cross-text'), '红①:线×文字穿越(G1 新增检查)'],
|
|
65
|
+
['arrow-end-short-run', codes.has('arrow-end-short-run'), '红②:箭头前直段仅 7px'],
|
|
66
|
+
['arrow-tip-inside-shape', codes.has('arrow-tip-inside-shape'), '红④:尖端插进图形'],
|
|
67
|
+
['line-end-off-edge/gap', endOff, '红③:端点离目标 10px'],
|
|
68
|
+
['2点斜连线保持直线', diag2.length === 2 && !axisOf(diag2), '绿①:cxnSp 契约 points=' + JSON.stringify(diag2)],
|
|
69
|
+
['3点折线斜段被正交化', poly.length >= 4 && axisOf(poly), '绿②:折线契约 points=' + JSON.stringify(poly)],
|
|
70
|
+
]
|
|
71
|
+
let bad = 0
|
|
72
|
+
console.log('红样本门禁:错' + v.errors.length + ' / 警' + v.warns.length + '|codes = ' + [...codes].sort().join(', '))
|
|
73
|
+
for (const [name, ok, desc] of rows) { if (!ok) bad++; console.log(' ' + (ok ? '✓' : '✗') + ' ' + name.padEnd(24) + ' ← ' + desc) }
|
|
74
|
+
console.log('==== 抓取断言:' + (bad === 0 ? '全部命中' : bad + ' 项未命中') + ' ====')
|
|
75
|
+
process.exit(bad === 0 ? 0 : 1)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 组级裁剪渲染(CLI)。
|
|
3
|
+
*
|
|
4
|
+
* 取景 / 裁剪语义**全部来自 `src/group-render.js`**;本文件只负责:参数解析 → 导出 pptx →
|
|
5
|
+
* PowerPoint COM 出图 → 报告。**不要在这里重复取景逻辑**——重复会让"工具看到的小图"与
|
|
6
|
+
* "CLI 出的小图"不一致,审阅结论就会自相矛盾(这正是拆模块的原因)。
|
|
7
|
+
*
|
|
8
|
+
* 用法:
|
|
9
|
+
* node scripts/render-group.mjs <deck目录> --group <组id> [--page N] [--pad 16] [--out 目录]
|
|
10
|
+
* node scripts/render-group.mjs <deck目录> --id <元素id> 上面的参数同样适用
|
|
11
|
+
* --page-render:额外再出「整页版(带上下文)」PNG
|
|
12
|
+
*/
|
|
13
|
+
import { basename, join } from 'node:path'
|
|
14
|
+
import { findPowerPoint, renderPptxToPng } from '../lib/msrender.js'
|
|
15
|
+
import { resolveDeck } from '../lib/pptd/schema.js'
|
|
16
|
+
import { renderDeck } from '../lib/pptd/render-html.js'
|
|
17
|
+
import { exportPptx } from '../lib/pptd/export-pptx.js'
|
|
18
|
+
import { isolateGroup } from '../lib/group-render.js'
|
|
19
|
+
|
|
20
|
+
const argv = process.argv.slice(2)
|
|
21
|
+
const dir = argv[0]
|
|
22
|
+
if (!dir) {
|
|
23
|
+
console.error('用法:node scripts/render-group.mjs <deck目录> --group <组id> [--page N] [--pad 16] [--out 目录] [--page-render]')
|
|
24
|
+
process.exit(2)
|
|
25
|
+
}
|
|
26
|
+
const flag = (name, def = undefined) => {
|
|
27
|
+
const i = argv.indexOf(`--${name}`)
|
|
28
|
+
if (i < 0) return def
|
|
29
|
+
const v = argv[i + 1]
|
|
30
|
+
return v === undefined || v.startsWith('--') ? true : v
|
|
31
|
+
}
|
|
32
|
+
const pageNo = Number(flag('page', '1'))
|
|
33
|
+
const pad = Number(flag('pad', '16'))
|
|
34
|
+
const wantPage = argv.includes('--page-render')
|
|
35
|
+
const outDir = flag('out', join(dir, '.group-render'))
|
|
36
|
+
const groupId = flag('group')
|
|
37
|
+
const oneId = flag('id')
|
|
38
|
+
|
|
39
|
+
const ctx = await resolveDeck(dir)
|
|
40
|
+
const r = await isolateGroup(dir, { page: pageNo, group: groupId, id: oneId, pad, outDir })
|
|
41
|
+
|
|
42
|
+
console.log('==== 组级裁剪渲染 ====')
|
|
43
|
+
console.log(`来源:${r.sourceLabel}|成员 ${r.memberCount} 个|bbox = [${Math.round(r.bbox.x)}, ${Math.round(r.bbox.y)}, ${Math.round(r.bbox.w)}×${Math.round(r.bbox.h)}](已外扩 pad=${pad})`)
|
|
44
|
+
console.log(`**这是第 ${pageNo} 页的某个组合的局部图,不是整页**(整页缩略图会掩盖线落边/压字/留白这类小尺寸缺陷)`)
|
|
45
|
+
for (const n of r.notes) console.log(` · 提示:${n}`)
|
|
46
|
+
|
|
47
|
+
const iso = await exportPptx(r.ctxIso, { out: join(r.work, 'isolated.pptx') })
|
|
48
|
+
await renderDeck(r.ctxIso, { out: 'preview' })
|
|
49
|
+
console.log(`隔离版:${r.sub.length} 个元素 → ${basename(iso.file)}|parity.ok = ${iso.parity?.ok}|预览 HTML:${join(r.work, 'preview', 'index.html')}`)
|
|
50
|
+
|
|
51
|
+
if (findPowerPoint()) {
|
|
52
|
+
const shot = await renderPptxToPng(iso.file, join(r.work, 'shots'), {
|
|
53
|
+
pages: [1],
|
|
54
|
+
width: Math.max(640, Math.round(r.bbox.w * 2)),
|
|
55
|
+
height: Math.max(360, Math.round(r.bbox.h * 2)),
|
|
56
|
+
})
|
|
57
|
+
console.log(`隔离版 PNG:${shot.files?.[0] ?? '(无)'}`)
|
|
58
|
+
} else {
|
|
59
|
+
console.log('隔离版 PNG:本机没有 PowerPoint ⇒ 跳过(预览 HTML 仍可用;桌面端可用 PowerPoint COM 出图)')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (wantPage) {
|
|
63
|
+
const full = await exportPptx(ctx, { out: join(outDir, 'page.pptx') })
|
|
64
|
+
if (findPowerPoint()) {
|
|
65
|
+
const shot = await renderPptxToPng(full.file, join(outDir, 'page-shots'), { pages: [pageNo] })
|
|
66
|
+
console.log(`整页版 PNG(在上下文):${shot.files?.[0]}`)
|
|
67
|
+
} else {
|
|
68
|
+
await renderDeck(ctx, { out: join(outDir, 'page-preview') })
|
|
69
|
+
console.log(`整页版:本机没有 PowerPoint ⇒ 已生成预览 HTML:${join(outDir, 'page-preview', 'index.html')}`)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
console.log(`产物目录:${outDir}`)
|
package/scripts/smoke.mjs
CHANGED
|
@@ -427,7 +427,12 @@ ok('启发B:输出行由报告生成(lossLine === lossAuditLine(落盘 JSON)
|
|
|
427
427
|
bandImp.lossLine === lossAuditLine(lossAuditJson), String(bandImp.lossLine).slice(0, 90))
|
|
428
428
|
ok('v0.4:theme 聚合建议块写入 deck.yaml', bandDeckYaml.includes('# 建议主题') && bandDeckYaml.includes('textStyles'))
|
|
429
429
|
const mnErr = validatePage({ elements: [{ elementId: 'l', elementType: 'line', points: [[10, 10], [100, 10], [200, 10]] }] }, 'test.yaml')
|
|
430
|
-
|
|
430
|
+
// 阶段 A(2026-09-28)行为变更:>2 点折线**支持**(导出为 custGeom 开放路径,docs/08 §6);
|
|
431
|
+
// 非法点列(<2 点)仍显式报错。原来那条"多点必须报错"的断言已按新事实改写(断言总数不变)。
|
|
432
|
+
const mnBad = validatePage({ elements: [{ elementId: 'l', elementType: 'line', points: [[10, 10]] }] }, 'test.yaml')
|
|
433
|
+
ok('v0.4 + 阶段A:3 点折线被接受、1 点折线显式报错(不再是"多点一律拒绝")',
|
|
434
|
+
mnErr === null && mnBad !== null && mnBad.messages.some((m) => m.includes('至少 2 点')),
|
|
435
|
+
`3点=${mnErr ? `报错(${mnErr.messages?.join('; ')})` : '通过'}|1点=${mnBad ? '报错' : '通过'}`)
|
|
431
436
|
const { analyzePage, aestheticSuggestions } = await import('../lib/verify.js')
|
|
432
437
|
const size960 = { width: 960, height: 540 }
|
|
433
438
|
const naPage = { elements: [
|
|
@@ -1676,6 +1681,9 @@ const errorCodes = new Set()
|
|
|
1676
1681
|
verifyLines.forEach((line, i) => {
|
|
1677
1682
|
// `let code = 'overlap'` 是**初值**(后续分支必然改写;overlap 只会以 warning 出现),不是一条 finding → 排除声明式赋值
|
|
1678
1683
|
if (/\b(?:let|const|var)\s+code\s*=/.test(line)) return
|
|
1684
|
+
// 阶段 A(2026-09-28):本行**显式**写了非 error 的 severity(如 `severity: 'warning', code: 'declared-stale'`)
|
|
1685
|
+
// ⇒ 它不是错误码,不能因为上 3 行里出现过 'error' 被误收——本守卫的语义是"门禁**错误**码清单"。
|
|
1686
|
+
if (/severity\s*[:=]\s*'(?:warning|confirmed|suggestion|info)'/.test(line)) return
|
|
1679
1687
|
for (const m of line.matchAll(/code\s*[:=]\s*'([a-z-]+)'/g)) {
|
|
1680
1688
|
// 判定该 code 是否 error 级:本行或上 3 行里出现 'error'(覆盖 severity: 'error' / severity = 'error' /
|
|
1681
1689
|
// `mode === 'strict' ? 'error' : 'warning'` 三种写法;confirmed / warning / suggestion 不会被误收)
|
|
@@ -3013,16 +3021,179 @@ ok('npm 发布通道:发布的是**下载下来的 Release 资产**(等 .tgz
|
|
|
3013
3021
|
await rm(work57, { recursive: true, force: true })
|
|
3014
3022
|
}
|
|
3015
3023
|
|
|
3016
|
-
//
|
|
3017
|
-
//
|
|
3018
|
-
//
|
|
3019
|
-
//
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3024
|
+
// ── 58. 阶段 A:结构声明与复杂图地基(2026-09-28)──────────────────────────────
|
|
3025
|
+
// 守三件事:① 新字段的 schema 校验(含"顺序无关的引用检查");② **三处白名单都要透传**
|
|
3026
|
+
// (layout.js normalizePage / render-html.js snap / 导出层)——第一版正是漏在 snap() 上,
|
|
3027
|
+
// 造成"性能数字好看、结构关系全为 0";③ 结构豁免**真的**免掉门禁错误,且**没用新字段的页面行为不变**(门控)。
|
|
3028
|
+
{
|
|
3029
|
+
const work58 = join(smokeDir, '.tmp-diagram-basics')
|
|
3030
|
+
await rm(work58, { recursive: true, force: true })
|
|
3031
|
+
await mkdir(join(work58, 'pages'), { recursive: true })
|
|
3032
|
+
await writeFile(join(work58, 'deck.yaml'), ['version: 1', 'title: diagram-basics', 'size: [960, 540]', 'theme:',
|
|
3033
|
+
' colors: {primary: "#2563EB", text: "#1F2937", bg: "#F1F5F9"}', ' textStyles:',
|
|
3034
|
+
' body: {fontSize: 16, color: "$text"}', 'pages:', ' - pages/01.yaml', ''].join('\n'), 'utf8')
|
|
3035
|
+
const page58 = ['pageType: content',
|
|
3036
|
+
'groups:',
|
|
3037
|
+
' - id: g1', ' label: 输入层', ' members: [zone, a]',
|
|
3038
|
+
'expectedOverlaps:',
|
|
3039
|
+
' - pair: [chip, zone]', ' reason: "标题胶囊压在容器顶边(设计意图)"',
|
|
3040
|
+
'elements:',
|
|
3041
|
+
' - elementId: zone', ' elementType: shape', ' kind: roundRect', ' bounds: [40, 120, 880, 300]',
|
|
3042
|
+
' fill: "#F1F5F9"', ' role: background', ' contains: [a, t1, poly, dash]',
|
|
3043
|
+
' - elementId: a', ' elementType: shape', ' kind: rect', ' bounds: [80, 200, 160, 80]', ' fill: "#2563EB"',
|
|
3044
|
+
' - elementId: t1', ' elementType: text', ' bounds: [260, 200, 200, 40]', ' content: {text: "节点", style: "$body"}',
|
|
3045
|
+
' - elementId: chip', ' elementType: shape', ' kind: roundRect', ' bounds: [300, 106, 120, 30]',
|
|
3046
|
+
' fill: "#1F2937"', ' badgeOf: zone', ' roleReason: "标题胶囊"',
|
|
3047
|
+
' - elementId: poly', ' elementType: line', ' points: [[600, 200], [660, 240], [720, 200]]', ' arrow: both',
|
|
3048
|
+
' - elementId: dash', ' elementType: line', ' points: [[250, 300], [300, 260]]', ' arrow: end',
|
|
3049
|
+
' line: {color: "#1F2937", width: 1, dash: dash}',
|
|
3050
|
+
' attach: {from: {ref: a, side: right}, to: {ref: t1, side: left}}', ''].join('\n')
|
|
3051
|
+
await writeFile(join(work58, 'pages', '01.yaml'), page58, 'utf8')
|
|
3052
|
+
|
|
3053
|
+
// ① schema:合法结构声明通过;attach 放错元素/非法 side 报错(顺序无关的引用检查也在自证脚本里钉着)
|
|
3054
|
+
const ok58 = validatePage(await resolveDeck(work58).then((c) => c.pages[0].page), 's58.yaml')
|
|
3055
|
+
ok('§58 结构声明:合法 deck(groups/contains/badgeOf/attach/roleReason/expectedOverlaps.reason)schema 全通过',
|
|
3056
|
+
ok58 === null, ok58?.messages?.join('; ') ?? '')
|
|
3057
|
+
const bad58 = validatePage({ elements: [{ elementId: 'x', elementType: 'shape', kind: 'rect', bounds: [0, 0, 9, 9], attach: { from: { ref: 'x', side: 'up' } } }] }, 's58b.yaml')
|
|
3058
|
+
ok('§58 结构声明:attach 放在非 line 元素上、且 side 非法 ⇒ 均当场报错(不静默忽略)',
|
|
3059
|
+
bad58 !== null && /只对 elementType: line 有效/.test(bad58.messages.join('; ')),
|
|
3060
|
+
bad58?.messages?.join('; ') ?? '竟然通过')
|
|
3061
|
+
|
|
3062
|
+
// ② 预览与门禁共用 layout.json ⇒ 新字段必须**都**在里面(白名单漏字段 = 结构关系在门禁层凭空消失)
|
|
3063
|
+
await renderDeck(await resolveDeck(work58), { out: 'preview58' })
|
|
3064
|
+
const layout58 = JSON.parse(await (await import('node:fs/promises')).readFile(join(work58, 'preview58', 'layout.json'), 'utf8'))
|
|
3065
|
+
const el58 = (id) => layout58.pages[0].elements.find((e) => e.id === id) ?? {}
|
|
3066
|
+
ok('§58 三处白名单透传:contains/badgeOf/roleReason/attach/arrow 都进了 layout.json(防 normalizePage/snap 式漏字段复发)',
|
|
3067
|
+
Array.isArray(el58('zone').contains) && el58('zone').contains.length === 4
|
|
3068
|
+
&& el58('chip').badgeOf === 'zone' && el58('chip').roleReason === '标题胶囊'
|
|
3069
|
+
&& el58('dash').attach?.from?.ref === 'a' && el58('poly').arrow === 'both',
|
|
3070
|
+
`zone.contains=${JSON.stringify(el58('zone').contains)}|chip.badgeOf=${el58('chip').badgeOf}|poly.arrow=${el58('poly').arrow}`)
|
|
3071
|
+
ok('§58 groups 随 layout.json 落盘(门禁吃 layout.json,页面级结构不能只在 DSL 里)',
|
|
3072
|
+
Array.isArray(layout58.pages[0].groups) && layout58.pages[0].groups.length === 1 && layout58.pages[0].groups[0].id === 'g1')
|
|
3073
|
+
|
|
3074
|
+
// ④ A-④ 集成:手写坐标写错时由 attach 纠正(**不是**静默覆盖——必须留 attachNotes 并在门禁里可见)
|
|
3075
|
+
const dash58 = el58('dash')
|
|
3076
|
+
const a58 = el58('a'); const t1_58 = el58('t1')
|
|
3077
|
+
const onEdge = (pt, r, side) => side === 'right' ? Math.abs(pt[0] - (r.bounds.x + r.bounds.w)) < 2 : Math.abs(pt[0] - r.bounds.x) < 2
|
|
3078
|
+
ok('§58 attach 解析:手写坐标错误时被纠到 a.right / t1.left 上,且 attachNotes 记录了两端改写',
|
|
3079
|
+
onEdge(dash58.points[0], a58, 'right') && onEdge(dash58.points[dash58.points.length - 1], t1_58, 'left')
|
|
3080
|
+
&& Array.isArray(dash58.attachNotes) && dash58.attachNotes.length === 2,
|
|
3081
|
+
`points=${JSON.stringify(dash58.points)}|notes=${dash58.attachNotes?.length}`)
|
|
3082
|
+
|
|
3083
|
+
// ③ 结构豁免真的生效 + 报告两段;门控:不用新字段的页行为不变
|
|
3084
|
+
const v58 = verifyDeck(layout58)
|
|
3085
|
+
ok('§58 结构豁免:容器/子元素/徽章/附着线的重叠全部被结构关系豁免 ⇒ 0 错误',
|
|
3086
|
+
v58.errors.length === 0, v58.errors.map((e) => `${e.code}:${e.id}`).join('|') || '无')
|
|
3087
|
+
ok('§58 报告形态:「结构关系」计数行 + 「设计声明复核」段都在(豁免必须可见)',
|
|
3088
|
+
/· 结构关系:组 1|包含 4|附着 2|徽章 1|有意重叠 1/.test(v58.text) && v58.text.includes('· 设计声明复核:'),
|
|
3089
|
+
v58.text.split('\n').filter((l) => l.includes('结构关系') || l.includes('声明复核')).join('|'))
|
|
3090
|
+
ok('§58 attach 改写可见:门禁报告出现 attach-override 警告("attach 优先"可以,静默覆盖不行)',
|
|
3091
|
+
v58.warns.some((w) => w.code === 'attach-override'),
|
|
3092
|
+
v58.warns.map((w) => w.code).join(','))
|
|
3093
|
+
// 门控对照:同一页把阶段 A 特性**全部降级**(新字段删掉、折线退回 2 点、groups 清空)——
|
|
3094
|
+
// 注意 `usesStructure` 把">2 点折线"也算新特性,只删字段不退回点数会测不到门控(第一次就踩了这个)
|
|
3095
|
+
const plain58 = {
|
|
3096
|
+
...layout58.pages[0], groups: [],
|
|
3097
|
+
elements: (layout58.pages[0].elements ?? []).map((el) => {
|
|
3098
|
+
const { contains, badgeOf, roleReason, attach, ...rest } = el
|
|
3099
|
+
return Array.isArray(rest.points) && rest.points.length > 2 ? { ...rest, points: rest.points.slice(0, 2) } : rest
|
|
3100
|
+
}),
|
|
3101
|
+
}
|
|
3102
|
+
const vp58 = analyzePage(plain58, layout58.size ?? { width: 960, height: 540 })
|
|
3103
|
+
ok('§58 门控(非回归):降级掉阶段 A 特性的同一页,容器压内容仍按未声明重叠报错,且不出结构两段/无新增 code',
|
|
3104
|
+
vp58.some((f) => f.code === 'unexpected-overlap')
|
|
3105
|
+
&& vp58.every((f) => !['relation-invalid', 'declared-stale', 'duplicate-element', 'line-through-box', 'arrow-not-on-edge'].includes(f.code))
|
|
3106
|
+
&& !verifyDeck({ pages: [plain58], size: layout58.size }).text.includes('· 结构关系:'),
|
|
3107
|
+
vp58.map((f) => f.code).join(','))
|
|
3108
|
+
|
|
3109
|
+
// ④ 导出基元:折线一帧 custGeom、双箭头两端都在、虚线 prstDash;直线仍走 straightConnector1
|
|
3110
|
+
const r58 = await exportPptx(await resolveDeck(work58), { out: join(work58, 'out.pptx') })
|
|
3111
|
+
const z58 = zipRead(await (await import('node:fs/promises')).readFile(r58.file))
|
|
3112
|
+
const x58 = z58.get('ppt/slides/slide1.xml').toString('utf8')
|
|
3113
|
+
const cnt = (re) => (x58.match(re) ?? []).length
|
|
3114
|
+
ok('§58 导出:>2 点折线 = 一帧 p:sp + custGeom(不是 N 条 cxnSp 拼的直线段)',
|
|
3115
|
+
cnt(/<a:custGeom>/g) === 1 && cnt(/<p:cxnSp>/g) === 1 && cnt(/prst="straightConnector1"/g) === 1,
|
|
3116
|
+
`custGeom=${cnt(/<a:custGeom>/g)}|cxnSp=${cnt(/<p:cxnSp>/g)}|直线=${cnt(/prst="straightConnector1"/g)}`)
|
|
3117
|
+
ok("§58 导出:arrow:'both' ⇒ headEnd 与 tailEnd **都在** <a:ln> 内;dash ⇒ prstDash 出现",
|
|
3118
|
+
cnt(/<a:headEnd/g) === 1 && cnt(/<a:tailEnd/g) === 2 && cnt(/<a:prstDash/g) === 1,
|
|
3119
|
+
`headEnd=${cnt(/<a:headEnd/g)}|tailEnd=${cnt(/<a:tailEnd/g)}|prstDash=${cnt(/<a:prstDash/g)}`)
|
|
3120
|
+
// 口径(导出侧队友明确):折线单独计入 polyLinesExp/Out,**不再进 linesExp/linesOut**;
|
|
3121
|
+
// 故集成断言要用 polyLines*(而非 lines*),且 polyLinesWrong 是**数字**不是数组。
|
|
3122
|
+
ok('§58 图进 parity:折线条数单独进 parity(polyLinesExp/Out 对得上且不为 0,wrong=0)',
|
|
3123
|
+
(r58.parity?.polyLinesExp ?? 0) === 1 && r58.parity?.polyLinesOut === 1 && r58.parity?.polyLinesWrong === 0,
|
|
3124
|
+
`polyLines ${r58.parity?.polyLinesOut}/${r58.parity?.polyLinesExp}|wrong=${JSON.stringify(r58.parity?.polyLinesWrong ?? null)}`)
|
|
3125
|
+
ok('§58 图进 parity:附着线单独计数,且**导出侧独立复核**端点落在被引用元素的对应边上(0 wrong)',
|
|
3126
|
+
r58.parity?.attachedLinesExp === 1 && r58.parity?.attachedLinesOut === 1 && (r58.parity?.attachedLinesWrong ?? ['x']).length === 0,
|
|
3127
|
+
`attached ${r58.parity?.attachedLinesOut}/${r58.parity?.attachedLinesExp}|wrong=${JSON.stringify(r58.parity?.attachedLinesWrong ?? null)}`)
|
|
3128
|
+
// ⑤ 能力可发现:能力写进 SCHEMA_REF(ppt_schema 是模型的权威通道)才算"上线",否则等于藏起来
|
|
3129
|
+
const vocab58 = ['groups:', 'contains:', 'badgeOf:', 'roleReason:', 'attach:', '折线', "arrow: 'both'", 'line.dash', 'notchedRightArrow', 'expectedOverlaps:']
|
|
3130
|
+
const missing58 = vocab58.filter((k) => !scaffoldMod.SCHEMA_REF.includes(k))
|
|
3131
|
+
ok('§58 能力可发现:ppt_schema(SCHEMA_REF)写进了阶段 A 全部新词汇(含折线/双箭头/虚线/notchedRightArrow)——不写则模型不知道能用',
|
|
3132
|
+
missing58.length === 0, missing58.length ? `缺:${missing58.join('、')}` : `${vocab58.length} 个词汇齐备`)
|
|
3133
|
+
await rm(work58, { recursive: true, force: true })
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
// ── F4:§59(族物化断言)已停用;意图由手画侧覆盖(verify:handdrawn / redprobe / linetext / optionprobe)
|
|
3137
|
+
// 原文保留为注释,供族时代对照(tag family-era-final 可运行)。
|
|
3138
|
+
// // 守四件事:① 语法可发现(ppt_schema 里有 diagram);② 物化真的把 IR 展开成普通元素 + 结构关系;
|
|
3139
|
+
// // ③ 引擎产出**零冲突且零声明**(不靠 expectedOverlaps 掩盖自己的几何问题);
|
|
3140
|
+
// // ④ 没有 diagram 的页一个字都不多(阶段 A 的门控不被引擎绕过)。
|
|
3141
|
+
// {
|
|
3142
|
+
// await rm(work59, { recursive: true, force: true })
|
|
3143
|
+
// await mkdir(join(work59, 'pages'), { recursive: true })
|
|
3144
|
+
// await writeFile(join(work59, 'deck.yaml'), ['version: 1', 'title: ir', 'size: [960, 540]', 'theme:',
|
|
3145
|
+
// ' colors: {primary: "#2563EB", accent: "#F59E0B", text: "#1F2937", bg: "#F8FAFC"}', ' textStyles:',
|
|
3146
|
+
// ' body: {fontSize: 14, color: "$text"}', ' spacing: {base: 28}', ' safeArea: {top: 40, bottom: 40, left: 40, right: 40}',
|
|
3147
|
+
// 'pages:', ' - pages/01.yaml', ''].join('\n'), 'utf8')
|
|
3148
|
+
// ' nodes:', ' - {id: n1, label: 采集}', ' - {id: n2, label: 清洗}', ' - {id: n3, label: 应用, emphasis: accent}',
|
|
3149
|
+
// ' edges:', ' - {from: n1, to: n2, label: 实时}', ' - {from: n2, to: n3, style: dashed}',
|
|
3150
|
+
// ' groups:', ' - {id: s1, label: 第一段, members: [n1, n2]}', ' - {id: s2, label: 第二段, members: [n3]}', ''].join('\n'), 'utf8')
|
|
3151
|
+
// const ctx59 = await resolveDeck(work59)
|
|
3152
|
+
// const page59 = ctx59.pages[0].page
|
|
3153
|
+
// ok('§59 能力可发现:ppt_schema 写进了手画通路的机制词汇(attach / contains / role:decoration / roleReason / custGeom / 18px / 手画是正式路径)——不写模型不知道能用',
|
|
3154
|
+
// ['attach', 'contains', 'role: decoration', 'roleReason', 'custGeom', '18px', '受支持的正式路径'].every((k) => scaffoldMod.SCHEMA_REF.includes(k)))
|
|
3155
|
+
// && page59.elements.some((e) => Array.isArray(e.contains) && e.contains.length >= 2),
|
|
3156
|
+
// `元素 ${page59.elements.length}|组 ${(page59.groups ?? []).length}`)
|
|
3157
|
+
// await renderDeck(ctx59, { out: 'preview59' })
|
|
3158
|
+
// const layout59 = JSON.parse(await (await import('node:fs/promises')).readFile(join(work59, 'preview59', 'layout.json'), 'utf8'))
|
|
3159
|
+
// const v59 = verifyDeck(layout59)
|
|
3160
|
+
// const decl59 = (page59.expectedOverlaps ?? []).length + (page59.expectedOutOfSafeArea ?? []).length
|
|
3161
|
+
// ok('§59 引擎产出零冲突:真实门禁 0 错误,且页面**零声明**(不靠声明掩盖几何问题)',
|
|
3162
|
+
// v59.errors.length === 0 && decl59 === 0,
|
|
3163
|
+
// `错误 ${v59.errors.length}|声明 ${decl59}|${v59.errors.slice(0, 3).map((e) => e.code).join(',')}`)
|
|
3164
|
+
// ok('§59 引擎产出的关系在门禁里可见(结构关系行 + 附着线计入 parity 的那种 attach)',
|
|
3165
|
+
// /· 结构关系:组 2|包含 \d+|附着 4/.test(v59.text) && !v59.warns.some((w) => w.code === 'attach-override'),
|
|
3166
|
+
// v59.text.split('\n').find((l) => l.includes('结构关系'))?.trim())
|
|
3167
|
+
// // 无 diagram 的页:物化不碰它(纪律:既有工程逐字节不变)
|
|
3168
|
+
// await writeFile(join(work59, 'pages', '02.yaml'), ['pageType: content', 'elements:',
|
|
3169
|
+
// ' - elementId: t1', ' elementType: text', ' bounds: [40, 40, 400, 40]', ' content: {text: "手写页", style: "$body"}', ''].join('\n'), 'utf8')
|
|
3170
|
+
// await writeFile(join(work59, 'deck.yaml'), ['version: 1', 'title: ir', 'size: [960, 540]', 'theme:',
|
|
3171
|
+
// ' colors: {primary: "#2563EB", accent: "#F59E0B", text: "#1F2937", bg: "#F8FAFC"}', ' textStyles:',
|
|
3172
|
+
// ' body: {fontSize: 14, color: "$text"}', ' spacing: {base: 28}', 'pages:', ' - pages/01.yaml', ' - pages/02.yaml', ''].join('\n'), 'utf8')
|
|
3173
|
+
// const ctx59b = await resolveDeck(work59)
|
|
3174
|
+
// const plain59 = ctx59b.pages[1].page
|
|
3175
|
+
// ok('§59 无 diagram 的页一个字都不多(元素原样、无 groups、无 notes)',
|
|
3176
|
+
// plain59.elements.length === 1 && plain59.elements[0].elementId === 't1' && plain59.groups === undefined && plain59.diagramNotes === undefined,
|
|
3177
|
+
// `元素 ${plain59.elements.length}|groups ${plain59.groups === undefined ? 'undefined' : plain59.groups.length}`)
|
|
3178
|
+
// await rm(work59, { recursive: true, force: true })
|
|
3179
|
+
// }
|
|
3180
|
+
|
|
3181
|
+
// // 37.10 【必须是最后一条断言】引用计数自证:文档里 "smoke … N 断言" 必须等于本次真实断言总数。
|
|
3182
|
+
// // 历史形状:加断言后 README×3 + docs/02 + docs/06×2 + 手册 全靠人工同步,迟早漏一处。
|
|
3183
|
+
// // 只扫"当前状态"文档(README / 技术报告 / 评审测试矩阵 / 使用手册);docs/01/03/04 里的历史数字是记录,不动。
|
|
3184
|
+
// // 覆盖边界(故意):只认"N 断言"与"N/N"两种<b>套件规模</b>写法。docs/06 §一 是历史快照,那里写的是
|
|
3185
|
+
// // 裸的 "smoke 181"(无"断言"二字),由 §一 上方的"计数说明"解释;当前规模只认 §2.1 与 §六。
|
|
3186
|
+
// // 【2026-09-18 收紧】`N/N |` 这条原本匹配**任何** `a/b |` 表格单元格——只要该行提到 "smoke" 就命中。
|
|
3187
|
+
// // 实测被自己踩到:新增一条 smoke 行的格子里写 `4/4 |`(4 条真产物断言全绿)→ 被误判成"文档计数过期"。
|
|
3188
|
+
// // 且 `preflight 11/11`、`test:bundle 20/20` 之类写在 smoke 行里同样会误报。
|
|
3189
|
+
// // 收紧为"分子 ≥ 100 才算套件规模":smoke 规模已 218,不会再回到两位数;其余套件应由 `N 断言` 那种
|
|
3190
|
+
// // 明确写法表达,而不是靠任何 `a/b` 单元格。防的仍是"加断言忘了同步文档"。
|
|
3191
|
+
// ── F4 补偿:手画侧能力可发现性,逐条对应被停用的族断言(M = N)──
|
|
3192
|
+
ok('F4 手画能力可发现:SCHEMA_REF 写进 attach —— 不写模型不知道能用', scaffoldMod.SCHEMA_REF.includes('attach'))
|
|
3193
|
+
ok('F4 手画能力可发现:SCHEMA_REF 写进 contains —— 不写模型不知道能用', scaffoldMod.SCHEMA_REF.includes('contains'))
|
|
3194
|
+
ok('F4 手画能力可发现:SCHEMA_REF 写进 role: decoration —— 不写模型不知道能用', scaffoldMod.SCHEMA_REF.includes('role: decoration'))
|
|
3195
|
+
ok('F4 手画能力可发现:SCHEMA_REF 写进 roleReason —— 不写模型不知道能用', scaffoldMod.SCHEMA_REF.includes('roleReason'))
|
|
3196
|
+
ok('F4 手画能力可发现:SCHEMA_REF 写进 custGeom —— 不写模型不知道能用', scaffoldMod.SCHEMA_REF.includes('custGeom'))
|
|
3026
3197
|
const liveTotal = pass + fail + 1 // 含本条自身
|
|
3027
3198
|
const countFiles = ['README.md', join('docs', '02-技术报告.md'), join('docs', '06-评审与测试.md'), join('skills', 'ppt-studio-manual', 'SKILL.md')]
|
|
3028
3199
|
const staleCounts = []
|
|
@@ -3046,4 +3217,4 @@ if (PREV_SMOKE_HOME === undefined) delete process.env.DSH_HOME
|
|
|
3046
3217
|
else process.env.DSH_HOME = PREV_SMOKE_HOME
|
|
3047
3218
|
|
|
3048
3219
|
console.log(`\n==== 结果:${pass} 通过 / ${fail} 失败 ====`)
|
|
3049
|
-
process.exit(fail > 0 ? 1 : 0)
|
|
3220
|
+
process.exit(fail > 0 ? 1 : 0)
|