dsh-ppt-studio 1.1.5 → 1.2.1
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 +4 -1
- 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 +159 -0
- package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +23 -0
- 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 +57 -0
- package/docs/15-1.2.0 /345/217/221/345/270/203/346/212/245/345/221/212.md" +46 -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/scaffold.js +54 -1
- package/lib/tools.js +20 -2
- 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/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 +191 -20
- 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-preset-scope.mjs +1 -1
- 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/ppt-studio-diagram/SKILL.md +30 -0
- package/skills/ppt-studio-diagram/capability-matrix.md +22 -0
- package/skills/ppt-studio-diagram/composition.md +22 -0
- package/skills/ppt-studio-diagram/mechanisms.md +141 -0
- package/skills/ppt-studio-diagram/pitfalls.md +97 -0
- package/skills/ppt-studio-diagram/routing.md +17 -0
- package/skills/ppt-studio-diagram/text-and-spacing.md +16 -0
- package/skills/ppt-studio-diagram/verify.md +50 -0
- package/skills/ppt-studio-diagram-compare/SKILL.md +38 -0
- package/skills/ppt-studio-diagram-flow/SKILL.md +38 -0
- package/skills/ppt-studio-diagram-funnel/SKILL.md +39 -0
- package/skills/ppt-studio-diagram-layers/SKILL.md +32 -0
- package/skills/ppt-studio-diagram-loop/SKILL.md +39 -0
- package/skills/ppt-studio-diagram-matrix/SKILL.md +37 -0
- package/skills/ppt-studio-diagram-sequence/SKILL.md +31 -0
- package/skills/ppt-studio-diagram-state/SKILL.md +48 -0
- package/skills/ppt-studio-diagram-steps-ring/SKILL.md +38 -0
- package/skills/ppt-studio-diagram-swimlane/SKILL.md +37 -0
- package/skills/ppt-studio-diagram-timeline/SKILL.md +38 -0
- package/skills/ppt-studio-diagram-tree/SKILL.md +38 -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,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 路由几何(G1:把族管线的两个后处理接到**显式 elements**(纯手画)通路)
|
|
3
|
+
*
|
|
4
|
+
* 背景:`orthogonalize`(斜段→直角折线)与 `arrowEndRuns`(箭头端紧邻段 ≥ ARROW_MIN_RUN)此前
|
|
5
|
+
* 图 04 折点离端点 7px、图 09 竖线穿字)。本模块把两者封装成**一个带守卫的入口**给手画用。
|
|
6
|
+
*
|
|
7
|
+
* 为什么不直接调用族的组合 `orthogonalize(arrowEndRuns(orthogonalize(pts), arrow))`:
|
|
8
|
+
* · **守卫 1(n < 3)**:`arrowEndRuns` 的 `fix(n-2, n-1)` 在 n=2 时退化为 `fix(0,1)` ——
|
|
9
|
+
* 它会去**挪起点**,把短直线改短、包围盒跟着变。实测效果:既有 smoke 断言
|
|
10
|
+
* "连线·水平线 包围盒 cy=0" 由绿转红。直线没有"拐点"可挪 ⇒ 直接原样返回。
|
|
11
|
+
* · **守卫 2(折返)**:族里这套组合是为"路由产生的折角"设计的。用在简单 L 形上会造出**折返尖刺**:
|
|
12
|
+
* 实测 [(200,300),(400,300),(400,307)](末段仅 7px)→ [(200,300),(400,300),(400,289),(400,307)]
|
|
13
|
+
* (先上 11px 再下 18px)。折返 100% 是缺陷 ⇒ 一旦结果含折返就**保留原样**,
|
|
14
|
+
* 把"箭头前直段过短"交给门禁检查去拦(而不是悄悄画出一根刺)。
|
|
15
|
+
*
|
|
16
|
+
* 例外(与族一致):本身就是"环/放射"语义的图形(cycle 外环弦、放射线)**豁免正交化** ——
|
|
17
|
+
* 这里由 `isAxisAligned()` 判定:折线中只要存在斜段,就视为有意为之,调用方跳过本模块。
|
|
18
|
+
*/
|
|
19
|
+
// ── 本模块**自持**这三个纯量(F1 解耦:原先族与手画共用,现由手画侧提供、族反向 import)──
|
|
20
|
+
/**
|
|
21
|
+
* 箭头端的"直线段最短长度"。
|
|
22
|
+
* 用户实测反馈:拐点离箭头太近时,箭头看起来像"断的"、也看不出指向(附图那根线就是在箭头处拐弯)。
|
|
23
|
+
* 规则:带箭头的端点,其**紧邻的那一段**必须 ≥ ARROW_MIN_RUN 像素;不够就把相邻的**拐点**沿该段反向挪远。
|
|
24
|
+
* 只挪拐点、不动端点(attach 语义不变),且挪动方向沿该段自身 ⇒ 拐点仍落在原来的走廊/间隙里。
|
|
25
|
+
*/
|
|
26
|
+
export const ARROW_MIN_RUN = 18
|
|
27
|
+
export function arrowEndRuns(pts, arrow) {
|
|
28
|
+
const p = (pts ?? []).map(([x, y]) => [x, y])
|
|
29
|
+
if (p.length < 2) return p
|
|
30
|
+
// 让 p[i]→p[j] 这一段至少有 MIN 长(挪 i,不动 j)
|
|
31
|
+
const fix = (i, j) => {
|
|
32
|
+
const d = Math.hypot(p[j][0] - p[i][0], p[j][1] - p[i][1])
|
|
33
|
+
if (d === 0 || d >= ARROW_MIN_RUN) return
|
|
34
|
+
const ux = (p[j][0] - p[i][0]) / d
|
|
35
|
+
const uy = (p[j][1] - p[i][1]) / d
|
|
36
|
+
let nx = p[j][0] - ux * ARROW_MIN_RUN
|
|
37
|
+
let ny = p[j][1] - uy * ARROW_MIN_RUN
|
|
38
|
+
// **夹紧(X1-b)**:挪动后的拐点不得越过相邻点 p[i-1] —— 越过就形成"折返尖刺"
|
|
39
|
+
//(用户实测图 10 顶端多出一根线:91.1 → 73.1 → 118.1 的折返就是这里挪出来的)。
|
|
40
|
+
// 宁可这一段略短于 18px,也不许出现折返(折返 100% 是缺陷,短直段只是观感弱一点)。
|
|
41
|
+
if (i - 1 >= 0) {
|
|
42
|
+
const px = p[i - 1][0]
|
|
43
|
+
const py = p[i - 1][1]
|
|
44
|
+
const sameX = Math.abs(px - p[i][0]) < 0.5 && Math.abs(p[i][0] - p[j][0]) < 0.5
|
|
45
|
+
const sameY = Math.abs(py - p[i][1]) < 0.5 && Math.abs(p[i][1] - p[j][1]) < 0.5
|
|
46
|
+
if (sameX) ny = uy > 0 ? Math.min(ny, py) : Math.max(ny, py)
|
|
47
|
+
else if (sameY) nx = ux > 0 ? Math.min(nx, px) : Math.max(nx, px)
|
|
48
|
+
}
|
|
49
|
+
p[i] = [nx, ny]
|
|
50
|
+
}
|
|
51
|
+
const n = p.length
|
|
52
|
+
if (arrow === true || arrow === 'end' || arrow === 'both') fix(n - 2, n - 1) // 箭头在末端
|
|
53
|
+
if (arrow === 'both') fix(1, 0) // 两端箭头:起点那一段
|
|
54
|
+
return p
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 斜段 → 直角折线:把每一段非轴对齐的线拆成"先横后竖"两段(插入一个拐点)。
|
|
59
|
+
*
|
|
60
|
+
* **这是设计定死的规则**(用户提出、2026-09-28 敲定):引擎产出的折线**永远轴对齐**。
|
|
61
|
+
* · 正交路由是这套图的"工程感"来源,也让"竖/横段 AABB 宽或高为 0"这条重叠判定前提成立;
|
|
62
|
+
* · 斜段会让**箭头方向不确定**(截图里那种"看不出指向"的线就是斜段+拐弯叠加);
|
|
63
|
+
* · **唯一例外**:本身就是"环/放射"语义的图形(`cycle` 的外环弦、`funnel` 的梯形斜边),
|
|
64
|
+
* 前者用 `orthogonal: false` 显式声明豁免,后者画的是 custGeom 形状而不是箭头折线。
|
|
65
|
+
* 用途:兜住 `arrowEndRuns` 之类的后处理——它们只挪拐点,可能把**前一段**拉斜(用户实测发现的回归)。
|
|
66
|
+
*/
|
|
67
|
+
export function orthogonalize(pts) {
|
|
68
|
+
if (!pts || pts.length < 2) return pts
|
|
69
|
+
const out = [pts[0]]
|
|
70
|
+
for (let i = 1; i < pts.length; i++) {
|
|
71
|
+
const [x0, y0] = out[out.length - 1]
|
|
72
|
+
const [x1, y1] = pts[i]
|
|
73
|
+
if (Math.abs(x1 - x0) < 0.5 || Math.abs(y1 - y0) < 0.5) { out.push([x1, y1]); continue }
|
|
74
|
+
out.push([x1, y0], [x1, y1]) // 先横后竖
|
|
75
|
+
}
|
|
76
|
+
return out
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 折线是否全部轴对齐(斜段 ⇒ 属"环/放射"语义,应豁免正交化) */
|
|
80
|
+
export function isAxisAligned(pts) {
|
|
81
|
+
const p = pts ?? []
|
|
82
|
+
return p.every((q, i) => i === 0
|
|
83
|
+
|| Math.abs(q[0] - p[i - 1][0]) < 0.5 || Math.abs(q[1] - p[i - 1][1]) < 0.5)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 折返检测:相邻两段**共线且反向**(折返 100% 是缺陷,不许被后处理造出来) */
|
|
87
|
+
export function hasFoldback(pts) {
|
|
88
|
+
const p = pts ?? []
|
|
89
|
+
for (let i = 2; i < p.length; i++) {
|
|
90
|
+
const ax = p[i - 1][0] - p[i - 2][0]
|
|
91
|
+
const ay = p[i - 1][1] - p[i - 2][1]
|
|
92
|
+
const bx = p[i][0] - p[i - 1][0]
|
|
93
|
+
const by = p[i][1] - p[i - 1][1]
|
|
94
|
+
const la = Math.hypot(ax, ay)
|
|
95
|
+
const lb = Math.hypot(bx, by)
|
|
96
|
+
if (!la || !lb) continue
|
|
97
|
+
const collinear = Math.abs(ax * by - ay * bx) < 1e-6 * la * lb
|
|
98
|
+
if (collinear && ax * bx + ay * by < 0) return true
|
|
99
|
+
}
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 手画折线的路由后处理(带两道守卫)。
|
|
105
|
+
* @param {Array<[number,number]>} pts 折线点列(绝对坐标)
|
|
106
|
+
* @param {boolean|'end'|'both'} arrow 箭头配置(原样透传,'both' 表示两端)
|
|
107
|
+
* @returns {Array<[number,number]>} 处理后的点列(守卫不通过时原样返回)
|
|
108
|
+
*/
|
|
109
|
+
export function routePolyline(pts, arrow) {
|
|
110
|
+
const raw = (pts ?? []).map(([x, y]) => [x, y])
|
|
111
|
+
// **契约(2026-09 实测确认,smoke 有 6 条专项断言守着)**:
|
|
112
|
+
// 2 点直连线是**受支持的连接符** —— 引擎导出为 <p:cxnSp>(带 flipH/flipV),斜向也完全合法。
|
|
113
|
+
// **不许正交化它**:否则 cxnSp 数归零、方向专项断言全崩(本轮实测)。
|
|
114
|
+
// 因此只有**折线(≥3 点)**才过正交化 + 箭头端最短直段。
|
|
115
|
+
if (raw.length < 3) return raw
|
|
116
|
+
const fixed = orthogonalize(arrowEndRuns(orthogonalize(raw), arrow))
|
|
117
|
+
// 折返守卫:族算法用在 L 形上会造"先上后下"尖刺 ⇒ 含折返就退回原样,
|
|
118
|
+
// 交给门禁 arrow-end-short-run 报出(引擎不造缺陷、门禁负责报缺陷)。
|
|
119
|
+
return hasFoldback(fixed) ? raw : fixed
|
|
120
|
+
}
|
package/lib/pptd/schema.js
CHANGED
|
@@ -44,11 +44,22 @@ const TEXT_STYLE_KEYS = ['fontSize', 'fontFamily', 'color', 'bold', 'italic', 'a
|
|
|
44
44
|
const ELEMENT_KEYS = {
|
|
45
45
|
text: ['content'],
|
|
46
46
|
shape: ['kind', 'fill', 'line'],
|
|
47
|
-
line: ['points', 'arrow', 'line'],
|
|
47
|
+
line: ['points', 'arrow', 'line', 'attach'],
|
|
48
48
|
image: ['src', 'fit'],
|
|
49
49
|
table: ['cols', 'rows', 'header'],
|
|
50
50
|
chart: ['chart'],
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* 阶段 A(docs/08 §1):**所有元素通用**的结构声明键。
|
|
54
|
+
* 语义:`contains` 视觉容器 / `badgeOf` 徽章归属 / `roleReason` 角色覆盖理由。
|
|
55
|
+
* 几何反验证在 `src/pptd/relations.js`(本文件只做类型与引用存在性等**结构**校验)。
|
|
56
|
+
*/
|
|
57
|
+
const STRUCT_KEYS = ['contains', 'badgeOf', 'roleReason']
|
|
58
|
+
/** attach 的边枚举:与 geometry 层的判定共用一份(relations.js),避免两处枚举漂移。 */
|
|
59
|
+
import { ATTACH_SIDES } from './relations.js'
|
|
60
|
+
import { profileToTheme } from './style-profile.js'
|
|
61
|
+
import { materializeBlock } from './blocks.js'
|
|
62
|
+
import { measureText } from './layout.js'
|
|
52
63
|
|
|
53
64
|
/**
|
|
54
65
|
* shape.kind 白名单(v0.9.1 候选 A:常见 prst 直通)。
|
|
@@ -58,6 +69,7 @@ export const SHAPE_KINDS = [
|
|
|
58
69
|
'rect', 'roundRect', 'ellipse', 'triangle', 'custGeom', // 原始 + 自定义几何(v0.11 候选 C)
|
|
59
70
|
'rightArrow', 'leftArrow', 'upArrow', 'downArrow', 'leftRightArrow', // 箭头
|
|
60
71
|
'pentagon', 'hexagon', 'chevron', 'parallelogram', 'diamond', 'octagon', 'star5', // 多边形/星
|
|
72
|
+
'notchedRightArrow', // 流程图常用(阶段 A 补齐:用户真实稿每页 4–8 个)
|
|
61
73
|
'flowchartProcess', 'flowchartDecision', 'flowchartData', 'flowchartTerminator', // 流程图
|
|
62
74
|
]
|
|
63
75
|
|
|
@@ -234,6 +246,8 @@ export function validatePage(page, file) {
|
|
|
234
246
|
if (page.version !== undefined && page.version !== 1) errors.push(`[${file}] page.version must be 1`)
|
|
235
247
|
const elements = Array.isArray(page.elements) ? page.elements : []
|
|
236
248
|
const ids = new Set()
|
|
249
|
+
// 声明引用的存在性统一**延后**校验:声明可能写在其引用的元素之前,边扫边查会误报。
|
|
250
|
+
const deferredRefs = []
|
|
237
251
|
elements.forEach((el, i) => {
|
|
238
252
|
const path = `[${file}] elements[${i}]`
|
|
239
253
|
if (!el || typeof el !== 'object') { errors.push(`${path}: expected object`); return }
|
|
@@ -251,6 +265,31 @@ export function validatePage(page, file) {
|
|
|
251
265
|
if (el.role !== undefined && !['background', 'content', 'decoration'].includes(el.role)) {
|
|
252
266
|
errors.push(`${path}.role: background|content|decoration(层叠语义:背景/内容/装饰;decoration 完全豁免重叠报告)`)
|
|
253
267
|
}
|
|
268
|
+
// ── 阶段 A:结构声明字段(docs/08 §1)──
|
|
269
|
+
// 类型/形状在这里查;引用存在性延后;几何反验证在 relations.js。
|
|
270
|
+
if (el.roleReason !== undefined && (typeof el.roleReason !== 'string' || el.roleReason.trim() === '')) {
|
|
271
|
+
errors.push(`${path}.roleReason: non-empty string(role 覆盖推断值时的理由;会进入门禁报告"声明复核"段)`)
|
|
272
|
+
}
|
|
273
|
+
if (el.contains !== undefined) {
|
|
274
|
+
if (!Array.isArray(el.contains) || el.contains.length === 0 || el.contains.some((x) => typeof x !== 'string' || x === '')) {
|
|
275
|
+
errors.push(`${path}.contains: non-empty array of element ids(视觉容器:声明"我包住这些元素")`)
|
|
276
|
+
} else {
|
|
277
|
+
const dup = [...new Set(el.contains.filter((x, i, a) => a.indexOf(x) !== i))]
|
|
278
|
+
if (dup.length) errors.push(`${path}.contains: 重复声明 ${dup.join('/')}`)
|
|
279
|
+
for (const cid of el.contains) {
|
|
280
|
+
if (cid === el.elementId) errors.push(`${path}.contains: 不能包含自己`)
|
|
281
|
+
else deferredRefs.push({ path: `${path}.contains`, field: 'id', ref: cid })
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (el.badgeOf !== undefined) {
|
|
286
|
+
if (typeof el.badgeOf !== 'string' || el.badgeOf === '') errors.push(`${path}.badgeOf: element id string(徽章/标签的归属对象)`)
|
|
287
|
+
else if (el.badgeOf === el.elementId) errors.push(`${path}.badgeOf: 不能归属自己`)
|
|
288
|
+
else deferredRefs.push({ path: `${path}.badgeOf`, field: 'id', ref: el.badgeOf })
|
|
289
|
+
}
|
|
290
|
+
if (el.attach !== undefined && type !== 'line') {
|
|
291
|
+
errors.push(`${path}.attach: 只对 elementType: line 有效(把线段两端锚到元素边上)`)
|
|
292
|
+
}
|
|
254
293
|
if (type === 'text') {
|
|
255
294
|
validateText(el, path, errors)
|
|
256
295
|
// B1 修复(反馈二 ★):元素级残留样式键 → 明确报错(此前静默忽略按默认 18pt 度量,
|
|
@@ -261,7 +300,7 @@ export function validatePage(page, file) {
|
|
|
261
300
|
}
|
|
262
301
|
}
|
|
263
302
|
if (type === 'shape') validateShape(el, path, errors)
|
|
264
|
-
if (type === 'line') validateLine(el, path, errors)
|
|
303
|
+
if (type === 'line') validateLine(el, path, errors, deferredRefs)
|
|
265
304
|
if (type === 'image') {
|
|
266
305
|
if (typeof el.src !== 'string') errors.push(`${path}.src: required string`)
|
|
267
306
|
if (el.fit !== undefined && !['cover', 'contain', 'fill'].includes(el.fit)) errors.push(`${path}.fit: cover|contain|fill`)
|
|
@@ -269,6 +308,53 @@ export function validatePage(page, file) {
|
|
|
269
308
|
if (type === 'table') validateTable(el, path, errors)
|
|
270
309
|
if (type === 'chart') validateChart(el, path, errors)
|
|
271
310
|
})
|
|
311
|
+
// ── 阶段 A:延后校验的结构声明引用(顺序无关) ──
|
|
312
|
+
for (const d of deferredRefs) {
|
|
313
|
+
if (!ids.has(d.ref)) errors.push(`${d.path}: "${d.ref}" 不是本页元素 id(防呆:结构声明必须指向真实元素)`)
|
|
314
|
+
}
|
|
315
|
+
// ── 阶段 A:groups(逻辑组合,可嵌套)──
|
|
316
|
+
// 这里做**结构**校验(类型/唯一性/存在性/唯一直接父组/无环);
|
|
317
|
+
// 几何反验证(包含、附着、徽章)在 src/pptd/relations.js。
|
|
318
|
+
if (page.groups !== undefined) {
|
|
319
|
+
if (!Array.isArray(page.groups)) {
|
|
320
|
+
errors.push(`[${file}] groups: array of {id, label?, members: [元素或组 id]}(逻辑组合:剪枝/审阅/声明作用域)`)
|
|
321
|
+
} else {
|
|
322
|
+
const gids = new Set()
|
|
323
|
+
page.groups.forEach((g, i) => {
|
|
324
|
+
const gp = `[${file}] groups[${i}]`
|
|
325
|
+
if (!g || typeof g !== 'object' || Array.isArray(g)) { errors.push(`${gp}: expected object {id, members}`); return }
|
|
326
|
+
if (typeof g.id !== 'string' || g.id === '') errors.push(`${gp}.id: required non-empty string`)
|
|
327
|
+
else if (gids.has(g.id)) errors.push(`${gp}.id: duplicate "${g.id}"`)
|
|
328
|
+
else if (ids.has(g.id)) errors.push(`${gp}.id: "${g.id}" 与元素 id 同名(组与元素共用同一命名空间)`)
|
|
329
|
+
else gids.add(g.id)
|
|
330
|
+
if (g.label !== undefined && typeof g.label !== 'string') errors.push(`${gp}.label: string(人类可读用途,审阅输出里显示)`)
|
|
331
|
+
if (!Array.isArray(g.members) || g.members.length === 0 || g.members.some((m) => typeof m !== 'string' || m === '')) {
|
|
332
|
+
errors.push(`${gp}.members: non-empty array of element/group ids`)
|
|
333
|
+
}
|
|
334
|
+
})
|
|
335
|
+
const parentOf = new Map()
|
|
336
|
+
page.groups.forEach((g, i) => {
|
|
337
|
+
if (!g || typeof g !== 'object' || typeof g.id !== 'string') return
|
|
338
|
+
for (const m of Array.isArray(g.members) ? g.members : []) {
|
|
339
|
+
if (typeof m !== 'string' || m === '') continue
|
|
340
|
+
if (!ids.has(m) && !gids.has(m)) errors.push(`[${file}] groups[${i}].members: "${m}" 既不是元素也不是组 id`)
|
|
341
|
+
else if (parentOf.has(m)) errors.push(`[${file}] groups[${i}].members: "${m}" 同时属于组 "${parentOf.get(m)}" 与 "${g.id}"(只允许一个直接父组)`)
|
|
342
|
+
else parentOf.set(m, g.id)
|
|
343
|
+
}
|
|
344
|
+
})
|
|
345
|
+
// 嵌套无环(DFS)
|
|
346
|
+
const byId = new Map(page.groups.filter((g) => g && typeof g.id === 'string').map((g) => [g.id, g]))
|
|
347
|
+
const state = new Map()
|
|
348
|
+
const dfs = (id, stack) => {
|
|
349
|
+
if (state.get(id) === 'done') return
|
|
350
|
+
if (state.get(id) === 'visiting') { errors.push(`[${file}] groups: 嵌套成环 ${[...stack, id].join(' → ')}`); return }
|
|
351
|
+
state.set(id, 'visiting')
|
|
352
|
+
for (const m of byId.get(id)?.members ?? []) if (byId.has(m)) dfs(m, [...stack, id])
|
|
353
|
+
state.set(id, 'done')
|
|
354
|
+
}
|
|
355
|
+
for (const id of [...byId.keys()].sort()) dfs(id, [])
|
|
356
|
+
}
|
|
357
|
+
}
|
|
272
358
|
if (page.rowBounds !== undefined) errors.push(`[${file}] rowBounds: unsupported in v1`)
|
|
273
359
|
if (page.expectedOverlaps !== undefined) {
|
|
274
360
|
if (!Array.isArray(page.expectedOverlaps)) {
|
|
@@ -283,6 +369,10 @@ export function validatePage(page, file) {
|
|
|
283
369
|
for (const idX of po.pair) {
|
|
284
370
|
if (!ids.has(idX)) errors.push(`[${file}] expectedOverlaps[${i}]: "${idX}" 不是本页元素 id(防呆:声明必须指向真实元素)`)
|
|
285
371
|
}
|
|
372
|
+
// 阶段 A:reason 缺失只算**警告**(docs/08 §1.1:缺失 ⇒ 警告,不破坏既有 deck),类型错则当场报错
|
|
373
|
+
if (po.reason !== undefined && (typeof po.reason !== 'string' || po.reason.trim() === '')) {
|
|
374
|
+
errors.push(`[${file}] expectedOverlaps[${i}].reason: non-empty string(机器可读的设计理由;会进入门禁报告"声明复核"段)`)
|
|
375
|
+
}
|
|
286
376
|
}
|
|
287
377
|
})
|
|
288
378
|
}
|
|
@@ -378,16 +468,39 @@ function validateShape(el, path, errors) {
|
|
|
378
468
|
}
|
|
379
469
|
}
|
|
380
470
|
|
|
381
|
-
function validateLine(el, path, errors) {
|
|
471
|
+
function validateLine(el, path, errors, deferredRefs = []) {
|
|
382
472
|
const pts = Array.isArray(el.points) ? el.points : null
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
errors.push(`${path}.points: [[
|
|
389
|
-
}
|
|
390
|
-
|
|
473
|
+
const ptsOk = pts !== null && pts.length >= 2
|
|
474
|
+
&& pts.every((p) => Array.isArray(p) && p.length === 2 && p.every((n) => typeof n === 'number' && Number.isFinite(n)))
|
|
475
|
+
const xyOk = typeof el.x1 === 'number' && typeof el.y1 === 'number' && typeof el.x2 === 'number' && typeof el.y2 === 'number'
|
|
476
|
+
// 阶段 A:>2 点折线已支持(导出为 custGeom 开放路径,见 docs/08 §6);非法点列仍显式报错
|
|
477
|
+
if (el.points !== undefined && !ptsOk) {
|
|
478
|
+
errors.push(`${path}.points: [[x,y],…] 至少 2 点、每点两个有限数字(>2 点为折线,阶段 A 起支持)`)
|
|
479
|
+
} else if (el.points === undefined && !xyOk) {
|
|
480
|
+
errors.push(`${path}.points: [[x,y],…] 至少 2 点,或 x1,y1,x2,y2 numbers`)
|
|
481
|
+
}
|
|
482
|
+
if (el.arrow !== undefined && !(typeof el.arrow === 'boolean' || el.arrow === 'end' || el.arrow === 'both')) {
|
|
483
|
+
errors.push(`${path}.arrow: true|false|'end'|'both'(true 等价 'end';'both' = 两端箭头)`)
|
|
484
|
+
}
|
|
485
|
+
if (el.line?.dash !== undefined && !['solid', 'dash', 'dot', 'dashDot'].includes(el.line.dash)) {
|
|
486
|
+
errors.push(`${path}.line.dash: solid|dash|dot|dashDot`)
|
|
487
|
+
}
|
|
488
|
+
// ── attach:把两端锚到元素边上(导出时解析成坐标;几何反验证在 relations.js)──
|
|
489
|
+
const a = el.attach
|
|
490
|
+
if (a === undefined) return
|
|
491
|
+
if (!a || typeof a !== 'object' || Array.isArray(a)) {
|
|
492
|
+
errors.push(`${path}.attach: {from?: {ref, side}, to?: {ref, side}}(side: ${ATTACH_SIDES.join('|')})`)
|
|
493
|
+
return
|
|
494
|
+
}
|
|
495
|
+
for (const end of ['from', 'to']) {
|
|
496
|
+
const spec = a[end]
|
|
497
|
+
if (spec === undefined) continue
|
|
498
|
+
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) { errors.push(`${path}.attach.${end}: {ref, side}`); continue }
|
|
499
|
+
if (typeof spec.ref !== 'string' || spec.ref === '') errors.push(`${path}.attach.${end}.ref: element id string`)
|
|
500
|
+
else deferredRefs.push({ path: `${path}.attach.${end}`, field: 'ref', ref: spec.ref })
|
|
501
|
+
if (!ATTACH_SIDES.includes(spec.side)) errors.push(`${path}.attach.${end}.side: ${ATTACH_SIDES.join('|')}`)
|
|
502
|
+
}
|
|
503
|
+
if (a.from === undefined && a.to === undefined) errors.push(`${path}.attach: 至少给 from 或 to 之一`)
|
|
391
504
|
}
|
|
392
505
|
|
|
393
506
|
function validateTable(el, path, errors) {
|
|
@@ -490,6 +603,7 @@ export async function resolveDeck(dir) {
|
|
|
490
603
|
const colors = theme.colors ?? {}
|
|
491
604
|
const textStyles = theme.textStyles ?? {}
|
|
492
605
|
const pages = []
|
|
606
|
+
const blocksExpanded = new Map() // 阶段 D-②:记录每页展开了哪些块(供报告/自证)
|
|
493
607
|
for (const ref of deck.pages) {
|
|
494
608
|
const file = join(dir, ref)
|
|
495
609
|
if (!existsSync(file)) throw fail(`page file missing: ${ref}`)
|
|
@@ -503,6 +617,27 @@ export async function resolveDeck(dir) {
|
|
|
503
617
|
if (perr) throw perr
|
|
504
618
|
const terr = themeRefCheck(page, theme, ref)
|
|
505
619
|
if (terr.length) throw fail(terr)
|
|
620
|
+
// ── 阶段 D-②:`blocks`(组合作为可复用块)物化 —— copy 语义、按 at 平移、id 加前缀并重映射内部引用 ──
|
|
621
|
+
if (page.blocks !== undefined) {
|
|
622
|
+
if (!Array.isArray(page.blocks)) throw fail(`[${ref}] blocks: 数组(每项 {name, at?: [x, y]})`)
|
|
623
|
+
page.blocks.forEach((use, i) => {
|
|
624
|
+
const name = use?.name
|
|
625
|
+
if (typeof name !== 'string' || !name) throw fail(`[${ref}] blocks[${i}].name: 必填字符串(deck.blocks 里的块名)`)
|
|
626
|
+
const block = deck.blocks?.[name]
|
|
627
|
+
if (!block) throw fail(`[${ref}] blocks[${i}]: deck.blocks 里没有块 "${name}"(现有:${Object.keys(deck.blocks ?? {}).join(', ') || '无'})`)
|
|
628
|
+
const at = Array.isArray(use.at) ? use.at : [0, 0]
|
|
629
|
+
if (!(at.length === 2 && at.every((n) => typeof n === 'number'))) throw fail(`[${ref}] blocks[${i}].at: [x, y] numbers`)
|
|
630
|
+
const out = materializeBlock(block, { at, prefix: `bk${i + 1}_` })
|
|
631
|
+
page.elements = [...(page.elements ?? []), ...out.elements]
|
|
632
|
+
if (out.groups.length) page.groups = [...(page.groups ?? []), ...out.groups]
|
|
633
|
+
const doc = blocksExpanded.get(ref) ?? []
|
|
634
|
+
doc.push({ name, at, elements: out.elements.length, groups: out.groups.length })
|
|
635
|
+
blocksExpanded.set(ref, doc)
|
|
636
|
+
})
|
|
637
|
+
}
|
|
638
|
+
// ── 阶段 B:`diagram` 物化(IR → 普通元素 + 结构关系)──
|
|
639
|
+
// 展开在这里 ⇒ normalizePage / 预览 / 导出 / verify **全部零改动**,预览与成品天然同源(docs/12 §5)。
|
|
640
|
+
// 没有 `diagram` 的页完全不进这段 ⇒ 既有工程逐字节不变。
|
|
506
641
|
pages.push({ file: join(dir, ref), ref, page, name: (page.title ?? ref.replace(/\.yaml$/, '')).toString(), index: pages.length })
|
|
507
642
|
}
|
|
508
643
|
const resolveColor = (v) => {
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 阶段 D-①:样式档案(从**参考稿**观测 → 令牌 → 喂回引擎)。
|
|
3
|
+
*
|
|
4
|
+
* 立场(docs/07 D2 铁律):**裁决优先级 = 用户口头要求 > 参考稿 > 其他**;引擎自身没有审美。
|
|
5
|
+
* 本模块只做"观测 + 归纳",把参考稿里**实际出现**的取色、线宽、字号阶梯、圆角归纳成令牌;
|
|
6
|
+
* 再由 `profileToTheme()` 折成一个普通 theme 对象交给既有的 `styleProfileFrom()`——
|
|
7
|
+
* 于是引擎侧**零改动**(它只是换了一份令牌来源)。
|
|
8
|
+
*
|
|
9
|
+
* 数据来源:`observeFromLayout(layout)` 从 layout.json(导入参考稿后渲染出的那份)观测;
|
|
10
|
+
* 也可由调用方直接给 `extractStyleProfile({fills, lineWidths, fontSizes, radii, bg})`。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** 中性色判定(与 verify.js 的口径一致:RGB 各分量差 ≤ 8)。 */
|
|
14
|
+
function isNeutral(hex) {
|
|
15
|
+
const m = /^#?([0-9a-fA-F]{6})$/.exec(String(hex ?? ''))
|
|
16
|
+
if (!m) return true
|
|
17
|
+
const v = parseInt(m[1], 16)
|
|
18
|
+
const r = (v >> 16) & 0xff
|
|
19
|
+
const g = (v >> 8) & 0xff
|
|
20
|
+
const b = v & 0xff
|
|
21
|
+
return Math.abs(r - g) <= 8 && Math.abs(g - b) <= 8 && Math.abs(r - b) <= 8
|
|
22
|
+
}
|
|
23
|
+
const norm = (hex) => {
|
|
24
|
+
const m = /^#?([0-9a-fA-F]{6})$/.exec(String(hex ?? ''))
|
|
25
|
+
return m ? `#${m[1].toUpperCase()}` : null
|
|
26
|
+
}
|
|
27
|
+
const median = (arr) => {
|
|
28
|
+
if (!arr.length) return null
|
|
29
|
+
const s = [...arr].sort((a, b) => a - b)
|
|
30
|
+
return s[Math.floor((s.length - 1) / 2)]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 从 **deck(原始 DSL 元素)** 观测——比 layout.json 准:原始元素带 `line.width` 与 `content.fontSize`,
|
|
35
|
+
* 而 layout.json 的形状快照只有 `{shape, fill, path, rotation}`(实测 lineWidths 会是空的)。
|
|
36
|
+
*/
|
|
37
|
+
export function observeFromDeck(ctx) {
|
|
38
|
+
const fills = []
|
|
39
|
+
const lineWidths = []
|
|
40
|
+
const fontSizes = []
|
|
41
|
+
const radii = []
|
|
42
|
+
const bg = norm(ctx?.deck?.theme?.colors?.bg) ?? null
|
|
43
|
+
// 关键:`$themeRef` 必须先**解析成实际色值**再统计。第一版直接读 `el.fill`,
|
|
44
|
+
// 于是 `$primary` 这种未解析字符串被归一化成中性色、还挤掉真正的调色板(实测 palette[0] 排错)。
|
|
45
|
+
const resolve = (v) => {
|
|
46
|
+
try { return norm(ctx?.resolveColor?.(v)) ?? norm(v) ?? null } catch { return norm(v) ?? null }
|
|
47
|
+
}
|
|
48
|
+
for (const p of ctx?.pages ?? []) {
|
|
49
|
+
for (const el of p.page?.elements ?? []) {
|
|
50
|
+
const f = typeof el.fill === 'string' ? resolve(el.fill) : (el.fill?.color ? resolve(el.fill.color) : null)
|
|
51
|
+
if (f) fills.push(f)
|
|
52
|
+
if (el.line?.width) lineWidths.push(Number(el.line.width))
|
|
53
|
+
if (el.content?.fontSize) fontSizes.push(Number(el.content.fontSize))
|
|
54
|
+
if (typeof el.kind === 'string' && /roundRect|ellipse/.test(el.kind)) radii.push(8)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { fills: fills.filter(Boolean), lineWidths, fontSizes, radii, bg }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 便捷入口:deck(resolveDeck 结果)一步到档案 / theme。 */
|
|
61
|
+
export function profileFromDeck(ctx) {
|
|
62
|
+
return extractStyleProfile(observeFromDeck(ctx))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 从 layout.json 观测"参考稿里实际出现了什么"。 */
|
|
66
|
+
export function observeFromLayout(layout) {
|
|
67
|
+
const fills = []
|
|
68
|
+
const lineWidths = []
|
|
69
|
+
const fontSizes = []
|
|
70
|
+
const radii = []
|
|
71
|
+
let bg = null
|
|
72
|
+
for (const page of layout?.pages ?? []) {
|
|
73
|
+
if (!bg && typeof page.background === 'string') bg = norm(page.background)
|
|
74
|
+
for (const el of page.elements ?? []) {
|
|
75
|
+
if (typeof el.fill === 'string') fills.push(norm(el.fill) ?? el.fill)
|
|
76
|
+
if (el.fill && typeof el.fill === 'object' && typeof el.fill.color === 'string') fills.push(norm(el.fill.color))
|
|
77
|
+
if (el.line?.width) lineWidths.push(Number(el.line.width))
|
|
78
|
+
if (el.style?.fontSize) fontSizes.push(Number(el.style.fontSize))
|
|
79
|
+
if (el.kind === 'roundRect') radii.push(8)
|
|
80
|
+
if (el.radius) radii.push(Number(el.radius))
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { fills: fills.filter(Boolean), lineWidths, fontSizes, radii, bg }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 归纳成样式档案(确定性:按**出现频次**排序,频次相同按色值字典序)。
|
|
88
|
+
* @returns {{palette: string[], ink: string, bg: string, neutral: string, lineWidth: number, fontSize: number, radius: number, observed: object}}
|
|
89
|
+
*/
|
|
90
|
+
export function extractStyleProfile(observed = {}) {
|
|
91
|
+
const counts = new Map()
|
|
92
|
+
for (const c of observed.fills ?? []) counts.set(c, (counts.get(c) ?? 0) + 1)
|
|
93
|
+
const all = [...counts.entries()].sort((a, b) => (b[1] - a[1]) || String(a[0]).localeCompare(String(b[0])))
|
|
94
|
+
const colorful = all.filter(([c]) => !isNeutral(c)).map(([c]) => c)
|
|
95
|
+
const neutrals = all.filter(([c]) => isNeutral(c)).map(([c]) => c)
|
|
96
|
+
const bg = norm(observed.bg) ?? neutrals[0] ?? '#FFFFFF'
|
|
97
|
+
// 墨色:中性色里**最深**的那个(参考稿里的文字/描边色)
|
|
98
|
+
const ink = [...neutrals].sort((a, b) => parseInt(a.slice(1), 16) - parseInt(b.slice(1), 16))[0] ?? '#1F2937'
|
|
99
|
+
const lineWidth = median((observed.lineWidths ?? []).filter((n) => n > 0)) ?? 1
|
|
100
|
+
const fontSize = median((observed.fontSizes ?? []).filter((n) => n >= 8)) ?? 14
|
|
101
|
+
const radius = median((observed.radii ?? []).filter((n) => n >= 0)) ?? 8
|
|
102
|
+
return {
|
|
103
|
+
palette: colorful.length ? colorful : ['#2563EB'],
|
|
104
|
+
ink,
|
|
105
|
+
bg,
|
|
106
|
+
neutral: neutrals.find((c) => c !== bg) ?? '#F1F5F9',
|
|
107
|
+
lineWidth: Number(lineWidth),
|
|
108
|
+
fontSize: Number(fontSize),
|
|
109
|
+
radius: Number(radius),
|
|
110
|
+
observed: { fills: [...counts.keys()].sort(), counts: Object.fromEntries(all) },
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 档案 → 普通 theme 对象(交给既有的 styleProfileFrom,引擎零改动)。 */
|
|
115
|
+
export function profileToTheme(profile) {
|
|
116
|
+
const colors = {}
|
|
117
|
+
colors.primary = profile.palette?.[0] ?? '#2563EB'
|
|
118
|
+
if (profile.palette?.[1]) colors.accent = profile.palette[1]
|
|
119
|
+
if (profile.palette?.[2]) colors.soft = profile.palette[2]
|
|
120
|
+
colors.text = profile.ink ?? '#1F2937'
|
|
121
|
+
colors.bg = profile.bg ?? '#F8FAFC'
|
|
122
|
+
return {
|
|
123
|
+
colors,
|
|
124
|
+
textStyles: { body: { fontSize: profile.fontSize ?? 14, color: '$text' } },
|
|
125
|
+
line: { width: profile.lineWidth ?? 1 },
|
|
126
|
+
radius: profile.radius ?? 8,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 便捷入口:layout.json 一步到 theme。 */
|
|
131
|
+
export function themeFromLayout(layout) {
|
|
132
|
+
return profileToTheme(extractStyleProfile(observeFromLayout(layout)))
|
|
133
|
+
}
|
package/lib/scaffold.js
CHANGED
|
@@ -69,7 +69,7 @@ elements:
|
|
|
69
69
|
# # commands: moveTo|lnTo|quadBezTo|cubicBezTo|arcTo(wR/hR/stAng/swAng)|close;坐标 = 路径空间抽象单位
|
|
70
70
|
\`\`\`
|
|
71
71
|
|
|
72
|
-
### line(bounds
|
|
72
|
+
### line(bounds 可省略;≥2 点,>2 点为折线;arrow: true 或 'both';line.dash 虚线;attach 可把两端锚到元素边)
|
|
73
73
|
\`\`\`yaml
|
|
74
74
|
- elementId: arrow
|
|
75
75
|
elementType: line
|
|
@@ -119,6 +119,59 @@ contrastExempt: # P1-1:已确认对比度正常但启发式误报
|
|
|
119
119
|
overlapMode: declared # declared(默认)| lenient(草稿缓冲:未声明仅提示)
|
|
120
120
|
\`\`\`
|
|
121
121
|
|
|
122
|
+
## 结构声明(复杂图:写"结构",不要手算坐标)
|
|
123
|
+
一页里有 ≥3 个盒子 + 连线/容器/徽章时,**优先用结构声明**——引擎推导关系并做几何反验证,
|
|
124
|
+
比你逐对写 expectedOverlaps 更省、更不易漏。规则:**声明 + 通过几何反验证 = 豁免;验证不过 ⇒ 声明作废并按普通冲突报错**(不自证豁免)。推导出的豁免**不落盘**(每次现算,文档里看不到、也不用维护)。
|
|
125
|
+
\`\`\`yaml
|
|
126
|
+
groups: # 逻辑组合(可嵌套 ≤3 层):审计/剪枝的单位,也是"当成一个整体"的块
|
|
127
|
+
- id: g_input
|
|
128
|
+
label: 输入层
|
|
129
|
+
members: [box_a, box_b] # 成员 = 元素 id 或**组 id**;一个成员只能有一个直接父组
|
|
130
|
+
elements:
|
|
131
|
+
- elementId: zone
|
|
132
|
+
elementType: shape
|
|
133
|
+
kind: roundRect
|
|
134
|
+
contains: [box_a, box_b, ln1] # 视觉容器:我真包住这些(几何必须严格包含,否则按冲突报错)
|
|
135
|
+
- elementId: chip
|
|
136
|
+
elementType: shape
|
|
137
|
+
badgeOf: zone # 徽章/标签归属:必须相交、面积 ≤ 0.5×被归属者、不得盖满
|
|
138
|
+
roleReason: "标题胶囊压在容器顶边" # role 覆盖推断值时的理由(进"声明复核"段)
|
|
139
|
+
- elementId: ln1
|
|
140
|
+
elementType: line
|
|
141
|
+
points: [[240, 240], [260, 200]] # 可省略:两端都写 attach 时由引擎算
|
|
142
|
+
attach: # 锚到元素边(**优先于**手写 points)
|
|
143
|
+
from: {ref: box_a, side: right} # side: top|right|bottom|left
|
|
144
|
+
to: {ref: box_b, side: left} # 手写坐标被改写时,门禁报告出 attach-override 警告(不静默覆盖)
|
|
145
|
+
expectedOverlaps:
|
|
146
|
+
- pair: [chip, zone]
|
|
147
|
+
reason: "标题胶囊压在容器顶边" # 缺失只算警告(不破坏既有工程)
|
|
148
|
+
\`\`\`
|
|
149
|
+
- **传递闭包**:A ⊇ B ⊇ C ⇒ A×C 自动豁免——外层容器**不必枚举深后代**(嵌套组合的关键)。
|
|
150
|
+
- **图专属机械检查**(用了结构声明的页才启用):重合元素 duplicate-element、文本互压 text-over-text、线穿盒 line-through-box、箭头脱靶 arrow-not-on-edge。
|
|
151
|
+
- **没用到结构声明的页面行为与从前完全一致**(门控)——你可以只在新页上逐步采用。
|
|
152
|
+
- 复杂图导出基元:points >2 点 = 折线(一帧 custGeom 开放路径,不是多条拼的);arrow: 'both' = 两端箭头(arrow: true 等价 'end');line.dash: solid|dash|dot|dashDot;kind: notchedRightArrow(流程图常用)。
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
不要再指望引擎替你算几何 —— 按需读 skills/ppt-studio-diagram/(调度手册 mechanisms.md + 12 份子 skill 配方),用显式 elements 把图拼出来。
|
|
156
|
+
这是受支持的正式路径,不是退路(13 页实测:零族依赖、门禁 0 错、与旧族产物同内容对照 10 更好 / 1 持平 / 2 持平偏旧 / 0 变差)。
|
|
157
|
+
|
|
158
|
+
关键机制(都在手画侧):
|
|
159
|
+
- 连边优先 attach: {from: {ref, side}, to: {ref, side}} —— 引擎锚到真实边缘(斜边图形也不例外),不要手算像素;
|
|
160
|
+
- 折线(≥3 点)自动正交化 + 箭头端自动保证 ≥18px 直段;2 点直连线是连接符(导出 cxnSp),保持直线、不要正交化它;
|
|
161
|
+
- 容器/分组:contains 声明相邻层;语义底板用 role: decoration;刻意穿过用 roleReason 说明;
|
|
162
|
+
- 收工前逐条读门禁警告(line-cross-text / arrow-end-short-run / line-end-off-edge / arrow-tip-inside-shape / line-crossing / line-diagonal-segment);
|
|
163
|
+
- 历史档案:族机制原始设计见 docs/07 / docs/12 / docs/13(已废弃,仅对照)。
|
|
164
|
+
- **组合作为可复用块(阶段 D)**:deck.blocks.<名> = {elements, groups?} 定义块,页面里 blocks: [{name: <名>, at: [x, y]}] 复用它(**copy 语义**:id 自动加 bk<n>_ 前缀、内部 contains / attach / badgeOf / groups 引用同步重映射、按 at 平移;改一份不影响另一份)。
|
|
165
|
+
- **手写折线/箭头(不属于任何族、完全自由发挥时)同样要守这几条**(引擎内部已经这么做,手写时请照做,否则会出现"箭头像断了/看不出指向"这类观感问题):
|
|
166
|
+
1. 折线**只用轴对齐段**(横段/竖段)——环形、放射、漏斗这类**本身就是斜的**图形除外(那类斜线是设计意图);
|
|
167
|
+
2. **箭头端紧邻的那一段必须 ≥ 18px 直线**,拐点不要贴着箭头(附着线用 attach 锚边即可,不必手算像素点);
|
|
168
|
+
3. 连线端点点在元素边上时优先用 attach: {from: {ref, side}, to: {ref, side}} —— 引擎会把它解析到真实边缘,比手写坐标稳;
|
|
169
|
+
4. **边标签贴着它标注的那条线**放(线上方或线下方 6px 左右,或竖线旁边 8px),不要放在两条线中间;标签文字短就用小框(框宽≈文字宽+8),别用大框占位;
|
|
170
|
+
5. 语义性的背景带 / 引导环 / 分组底板用 role: decoration(完全豁免重叠判定),**不要**用 expectedOverlaps 去掩盖"压字/压盒";
|
|
171
|
+
6. 一页可以**既写结构声明又手写元素**(缝合页);assistant 想要家族库没有的图,就按上面几条手写 elements 拼出来——这是被支持的路径,不是退路。
|
|
172
|
+
7. **斜边图形**(parallelogram / triangle / diamond)直接用 attach 就行:引擎会接到**真实斜边**上(不是包围盒边)——不要自己算斜边坐标;按包围盒边取点会出现"箭头插进图形里"或"离图形差一截"。
|
|
173
|
+
8. 门禁对"箭头插进图形里"(arrow-tip-inside-shape)与"端点离目标 2–10px 没接上"(arrow-end-gap)只给**警告**(不是错误)——因为这两种**都可能是刻意设计**:确实要插进去 / 刻意留空时,给该线加 roleReason: "…" 声明即可不再提示。
|
|
174
|
+
|
|
122
175
|
## 层叠角色推断(不在 expectedOverlaps 的规则)
|
|
123
176
|
- text/table/chart = content;shape/image = background(承载);line = line(引脚线/箭头)。
|
|
124
177
|
- content×content 重叠 → ERROR content-collision(不可声明豁免)。
|
package/lib/tools.js
CHANGED
|
@@ -468,9 +468,27 @@ export function registerTools(ctx) {
|
|
|
468
468
|
reg({
|
|
469
469
|
name: 'ppt_render',
|
|
470
470
|
description: '渲染 deck 项目 → preview/*.html 与 layout.json(数字审阅的数据源)。每页制作后运行',
|
|
471
|
-
parameters: { dir: dirSchema, debug: { type: 'boolean', description: '文本框描边调试(默认 false)' } },
|
|
471
|
+
parameters: { dir: dirSchema, debug: { type: 'boolean', description: '文本框描边调试(默认 false)' }, group: { type: 'string', description: '只对某个**组合**出隔离裁剪图(组 id,见 layout.json 或页面 YAML 的 groups):渲染到 <dir>/.group-render/isolated/preview。整页缩略图会掩盖"线落边/压字/留白"这类小尺寸缺陷,审阅组内细节时用它;省略则渲染整册' }, page: { type: 'number', description: '配合 group:组所在页码(1 起,缺省 1)' } },
|
|
472
472
|
output: markdownResult(),
|
|
473
|
-
async execute({ dir, debug }) {
|
|
473
|
+
async execute({ dir, debug, group, page }) {
|
|
474
|
+
if (group) {
|
|
475
|
+
// 组级裁剪渲染(C2):与 CLI `npm run render:group` **共用同一套取景规则**(src/group-render.js)
|
|
476
|
+
const { renderGroupIsolated } = await import('./group-render.js')
|
|
477
|
+
try {
|
|
478
|
+
const r = await renderGroupIsolated(dir, { page: page ?? 1, group })
|
|
479
|
+
return [
|
|
480
|
+
`✓ 组级隔离渲染完成:组 ${r.groupId}${r.label ? `(${r.label})` : ''}|取景 ${r.members.length} 个元素|bbox ${Math.round(r.bbox.x)},${Math.round(r.bbox.y)} ${Math.round(r.bbox.w)}×${Math.round(r.bbox.h)}(含 pad)`,
|
|
481
|
+
'',
|
|
482
|
+
`**这是第 ${page ?? 1} 页某个组合的局部图,不是整页**(整页缩略图会掩盖线落边/压字/留白这类小尺寸缺陷)。`,
|
|
483
|
+
'',
|
|
484
|
+
...r.htmlFiles.map((f) => ` - ${f}`),
|
|
485
|
+
'',
|
|
486
|
+
'下一步:read_image 导出的 PNG,或指向该隔离目录做截图复核组内细节。',
|
|
487
|
+
].join('\n')
|
|
488
|
+
} catch (error) {
|
|
489
|
+
return `✗ 组级隔离渲染失败:\n${errText(error)}`
|
|
490
|
+
}
|
|
491
|
+
}
|
|
474
492
|
try {
|
|
475
493
|
const ctx0 = await loadCtx(dir)
|
|
476
494
|
const r = await renderDeck(ctx0, { debug: !!debug })
|