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,615 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 结构关系推导与几何反验证(阶段 A 地基,2026-09-28)。设计依据:docs/08 §2/§4。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要:复杂图靠"逐对声明"是不可能维护的(一页 5 容器 × 10 子元素 = 50 对)。
|
|
5
|
+
* 但**声明本身不能当豁免**——否则引擎的一次 bug 会被固化成永久豁免。所以本模块的原则是:
|
|
6
|
+
*
|
|
7
|
+
* **声明 + 通过几何反验证 = 豁免;验证不过 ⇒ 声明作废,按普通冲突处理。**
|
|
8
|
+
*
|
|
9
|
+
* 三条硬纪律(都会在自证脚本里被断言):
|
|
10
|
+
* ① 推导结果**不落盘**:每次运行现算,不写回 deck.yaml(避免 bug 固化成数据)。
|
|
11
|
+
* ② **确定性**:所有输出按 id 字典序;不用随机、不用时间;同输入两次调用深度相等。
|
|
12
|
+
* ③ **不改输入**:纯函数,绝不原地修改 page。
|
|
13
|
+
*
|
|
14
|
+
* 关系类型与严格谓词见 docs/08 §2;容差默认值集中在 RELATION_DEFAULTS。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** attach 的边枚举(schema 与导出/验证共用同一份,避免两处枚举漂移)。 */
|
|
18
|
+
export const ATTACH_SIDES = ['top', 'right', 'bottom', 'left']
|
|
19
|
+
|
|
20
|
+
/** 容差默认值(docs/08 §2 表;真实稿校准后统一调整这里)。 */
|
|
21
|
+
export const RELATION_DEFAULTS = {
|
|
22
|
+
pad: 0, // contains:子元素距父边的最小内缩
|
|
23
|
+
tol: 1.5, // attach:端点与目标边的最大距离
|
|
24
|
+
duplicateTol: 2, // duplicate-element:包围盒各边差的最大值
|
|
25
|
+
badgeMaxRatio: 0.5, // badgeOf:chip 面积 / owner 面积上限
|
|
26
|
+
overlapTol: 1, // 相交判定容差(与 verify 既有 1px 一致)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 元素包围盒 → {x, y, w, h, right, bottom}(兼容数组与对象两种写法)。 */
|
|
30
|
+
export function rectOf(el) {
|
|
31
|
+
const b = el?.bounds
|
|
32
|
+
let x = 0
|
|
33
|
+
let y = 0
|
|
34
|
+
let w = 0
|
|
35
|
+
let h = 0
|
|
36
|
+
if (Array.isArray(b)) { [x, y, w, h] = b }
|
|
37
|
+
else if (b && typeof b === 'object') { ({ x = 0, y = 0, w = 0, h = 0 } = b) }
|
|
38
|
+
return { x, y, w, h, right: x + w, bottom: y + h }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const area = (r) => Math.max(0, r.w) * Math.max(0, r.h)
|
|
42
|
+
// 豁免键格式必须与 verify.js 的 pairKey 一致(`A × B`,两侧带空格)——否则豁免注入声明集后匹配不上。
|
|
43
|
+
const key2 = (a, b) => [String(a), String(b)].sort().join(' × ')
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 本页是否**使用了阶段 A 的结构声明**(groups / contains / attach / badgeOf / >2 点折线)。
|
|
47
|
+
* 用途:verify 只在"用了新特性"的页面上启用结构豁免与图专属检查 ⇒
|
|
48
|
+
* **没有用新特性的页面行为与从前完全一致**(非回归门)。
|
|
49
|
+
*/
|
|
50
|
+
export function usesStructure(page) {
|
|
51
|
+
if (Array.isArray(page?.groups) && page.groups.length > 0) return true
|
|
52
|
+
for (const el of page?.elements ?? []) {
|
|
53
|
+
if (el.contains !== undefined || el.badgeOf !== undefined || el.attach !== undefined) return true
|
|
54
|
+
if (Array.isArray(el.points) && el.points.length > 2) return true
|
|
55
|
+
}
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 相交(容差 tol:小于 tol 的贴合不算相交)。 */
|
|
60
|
+
export function intersects(a, b, tol = RELATION_DEFAULTS.overlapTol) {
|
|
61
|
+
const vo = Math.min(a.bottom, b.bottom) - Math.max(a.y, b.y)
|
|
62
|
+
const ho = Math.min(a.right, b.right) - Math.max(a.x, b.x)
|
|
63
|
+
return vo > tol && ho > tol
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** a 是否**严格包含** b(内缩 pad)。 */
|
|
67
|
+
export function contains(a, b, pad = RELATION_DEFAULTS.pad) {
|
|
68
|
+
return b.x >= a.x + pad - 0.001 && b.y >= a.y + pad - 0.001
|
|
69
|
+
&& b.right <= a.right - pad + 0.001 && b.bottom <= a.bottom - pad + 0.001
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 斜边预设的**真实轮廓**:返回指定 side 上、靠近点 p 的锚点坐标。
|
|
74
|
+
* 目前覆盖 `parallelogram` / `triangle` / `diamond`(其余 kind 返回 null ⇒ 退回包围盒边语义)。
|
|
75
|
+
* 两种命名都要认:DSL 用 kind;preview/layout 快照用 kind='shape' + `shape`=<预设名>(实测踩过)。
|
|
76
|
+
* 用户实测:暖色架构图的输出层箭头原本"插进图形"、输入层连线"没接上",都是按包围盒取点造成的。
|
|
77
|
+
*/
|
|
78
|
+
export function outlineAnchor(ref, side, p) {
|
|
79
|
+
const b = ref?.bounds
|
|
80
|
+
if (!b) return null
|
|
81
|
+
const prst = ref?.shape ?? ref?.kind
|
|
82
|
+
const w = Array.isArray(b) ? b[2] : b.w
|
|
83
|
+
const h = Array.isArray(b) ? b[3] : b.h
|
|
84
|
+
const x0 = Array.isArray(b) ? b[0] : b.x
|
|
85
|
+
const y0 = Array.isArray(b) ? b[1] : b.y
|
|
86
|
+
const y = p?.[1] ?? y0 + h / 2
|
|
87
|
+
const k = 1 - (y - y0) / h // 顶部 1 → 底部 0
|
|
88
|
+
if (prst === 'parallelogram') {
|
|
89
|
+
// 顶点 (x0+off,y0)、(x0+w,y0)、(x0+w−off,y0+h)、(x0,y0+h);off = 0.25×min(w,h)
|
|
90
|
+
const off = 0.25 * Math.min(w, h)
|
|
91
|
+
if (side === 'left') return [x0 + off * k, y]
|
|
92
|
+
if (side === 'right') return [x0 + w - off * k, y]
|
|
93
|
+
if (side === 'top') return [Math.min(Math.max(p?.[0] ?? x0 + w / 2, x0 + off), x0 + w), y0]
|
|
94
|
+
if (side === 'bottom') return [Math.min(Math.max(p?.[0] ?? x0 + w / 2, x0), x0 + w - off), y0 + h]
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
if (prst === 'triangle') {
|
|
98
|
+
// 顶点 (w/2,0)、(w,h)、(0,h):左右边斜;底边整条;顶边退化为顶点
|
|
99
|
+
if (side === 'left') return [x0 + (w / 2) * k, y]
|
|
100
|
+
if (side === 'right') return [x0 + w - (w / 2) * k, y]
|
|
101
|
+
if (side === 'bottom') return [Math.min(Math.max(p?.[0] ?? x0 + w / 2, x0), x0 + w), y0 + h]
|
|
102
|
+
if (side === 'top') return [x0 + w / 2, y0]
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
if (prst === 'diamond') {
|
|
106
|
+
// 顶点 (x0+w/2,y0)、(x0+w,y0+h/2)、(x0+w/2,y0+h)、(x0,y0+h/2):
|
|
107
|
+
// 左右边是斜的:left(y) = x0 + |y−mid|×(w/h),right(y) = x0+w − |y−mid|×(w/h)
|
|
108
|
+
// (第一版写成 w/2 ± dy 是错的——那描述的是"从中心张开的锥形",单测当场抓住)
|
|
109
|
+
const dy = Math.abs(y - (y0 + h / 2)) * (w / h)
|
|
110
|
+
if (side === 'left') return [x0 + dy, y]
|
|
111
|
+
if (side === 'right') return [x0 + w - dy, y]
|
|
112
|
+
if (side === 'top') return [x0 + w / 2, y0]
|
|
113
|
+
if (side === 'bottom') return [x0 + w / 2, y0 + h]
|
|
114
|
+
return null
|
|
115
|
+
}
|
|
116
|
+
if (prst === 'trapezoid') {
|
|
117
|
+
// 顶点 (off,0)、(w−off,0)、(w,h)、(0,h):左右边自下而上外张(与平行四边形左右边同形)
|
|
118
|
+
const off = 0.25 * Math.min(w, h)
|
|
119
|
+
const kk = 1 - (y - y0) / h
|
|
120
|
+
if (side === 'left') return [x0 + off * kk, y]
|
|
121
|
+
if (side === 'right') return [x0 + w - off * kk, y]
|
|
122
|
+
if (side === 'top') return [Math.min(Math.max(p?.[0] ?? x0 + w / 2, x0 + off), x0 + w - off), y0]
|
|
123
|
+
if (side === 'bottom') return [Math.min(Math.max(p?.[0] ?? x0 + w / 2, x0), x0 + w), y0 + h]
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
if (prst === 'chevron') {
|
|
127
|
+
// 顶点 (0,0)、(w−x1,0)、(w,mid)、(w−x1,h)、(0,h)、(x1,mid):左侧是**向内的 V 形缺口**
|
|
128
|
+
const x1 = 0.5 * Math.min(w, h)
|
|
129
|
+
const kk = 1 - Math.abs(y - (y0 + h / 2)) / (h / 2) // 中点 1 → 上下边 0
|
|
130
|
+
if (side === 'left') return [x0 + x1 * kk, y]
|
|
131
|
+
if (side === 'right') return [x0 + w - x1 + x1 * kk, y]
|
|
132
|
+
if (side === 'top') return [Math.min(Math.max(p?.[0] ?? x0 + w / 2, x0), x0 + w - x1), y0]
|
|
133
|
+
if (side === 'bottom') return [Math.min(Math.max(p?.[0] ?? x0 + w / 2, x0), x0 + w - x1), y0 + h]
|
|
134
|
+
return null
|
|
135
|
+
}
|
|
136
|
+
if (prst === 'hexagon') {
|
|
137
|
+
// 顶点 (x1,0)、(w−x1,0)、(w,mid)、(w−x1,h)、(x1,h)、(0,mid):与 chevron 相反,中点处最宽
|
|
138
|
+
const x1 = 0.25 * Math.min(w, h)
|
|
139
|
+
const kk = 1 - Math.abs(y - (y0 + h / 2)) / (h / 2)
|
|
140
|
+
if (side === 'left') return [x0 + x1 * (1 - kk), y]
|
|
141
|
+
if (side === 'right') return [x0 + w - x1 * (1 - kk), y]
|
|
142
|
+
if (side === 'top') return [Math.min(Math.max(p?.[0] ?? x0 + w / 2, x0 + x1), x0 + w - x1), y0]
|
|
143
|
+
if (side === 'bottom') return [Math.min(Math.max(p?.[0] ?? x0 + w / 2, x0 + x1), x0 + w - x1), y0 + h]
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
return null
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 把"真实轮廓锚点"表达成**等效矩形**,让 `onSide` 的判定落在真实边上。
|
|
151
|
+
* 注意必须带上 right/bottom —— `onSide` 用它们做区间判定(漏了会一律判 false,实测踩过)。
|
|
152
|
+
*/
|
|
153
|
+
export function slantRect(ref, side, pt) {
|
|
154
|
+
const b = ref?.bounds
|
|
155
|
+
if (!b) return null
|
|
156
|
+
const w = Array.isArray(b) ? b[2] : b.w
|
|
157
|
+
const h = Array.isArray(b) ? b[3] : b.h
|
|
158
|
+
const x0 = Array.isArray(b) ? b[0] : b.x
|
|
159
|
+
const y0 = Array.isArray(b) ? b[1] : b.y
|
|
160
|
+
const r = { x: x0, y: y0, w, h, right: x0 + w, bottom: y0 + h }
|
|
161
|
+
const a = outlineAnchor(ref, side, pt)
|
|
162
|
+
if (!a) return null
|
|
163
|
+
if (side === 'left') return { ...r, x: a[0] }
|
|
164
|
+
if (side === 'right') return { ...r, right: a[0] }
|
|
165
|
+
if (side === 'top') return { ...r, y: a[1] }
|
|
166
|
+
if (side === 'bottom') return { ...r, bottom: a[1] }
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* attach 判定的**统一入口**:先按包围盒边判,再按斜边预设的**真实轮廓**判。
|
|
172
|
+
* 必须三处共用(否则会出现"解析对了、校验却报 attach 不成立"的自相矛盾,实测踩过):
|
|
173
|
+
* · `relations.js` 的 deriveRelations 反验证;· `export-pptx.js` 的 parity attach 计数;· 将来新增的判定。
|
|
174
|
+
*/
|
|
175
|
+
export function onAttachSide(ref, side, pt, tol = RELATION_DEFAULTS.tol) {
|
|
176
|
+
if (onSide(side, pt, rectOf(ref), tol)) return true
|
|
177
|
+
const sr = slantRect(ref, side, pt)
|
|
178
|
+
return sr ? onSide(side, pt, sr, tol) : false
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 点是否落在 rect 的某条边上(容差 tol)——用于 attach 判定。 */
|
|
182
|
+
export function onSide(side, point, r, tol = RELATION_DEFAULTS.tol) {
|
|
183
|
+
const [x, y] = point
|
|
184
|
+
if (side === 'left') return Math.abs(x - r.x) <= tol && y >= r.y - tol && y <= r.bottom + tol
|
|
185
|
+
if (side === 'right') return Math.abs(x - r.right) <= tol && y >= r.y - tol && y <= r.bottom + tol
|
|
186
|
+
if (side === 'top') return Math.abs(y - r.y) <= tol && x >= r.x - tol && x <= r.right + tol
|
|
187
|
+
if (side === 'bottom') return Math.abs(y - r.bottom) <= tol && x >= r.x - tol && x <= r.right + tol
|
|
188
|
+
return false
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 线段是否穿过 rect 的**内部**(内缩 tol;贴边不算穿过)——用于 line-through-box。 */
|
|
192
|
+
export function segmentCrossesRect(p1, p2, r, tol = RELATION_DEFAULTS.tol) {
|
|
193
|
+
const inner = { x: r.x + tol, y: r.y + tol, right: r.right - tol, bottom: r.bottom - tol }
|
|
194
|
+
if (inner.right <= inner.x || inner.bottom <= inner.y) return false
|
|
195
|
+
// 端点落在内部 ⇒ 穿过
|
|
196
|
+
const inside = (p) => p[0] > inner.x && p[0] < inner.right && p[1] > inner.y && p[1] < inner.bottom
|
|
197
|
+
if (inside(p1) || inside(p2)) return true
|
|
198
|
+
// Liang–Barsky 裁剪:线段与内矩形是否有交集
|
|
199
|
+
let t0 = 0
|
|
200
|
+
let t1 = 1
|
|
201
|
+
const dx = p2[0] - p1[0]
|
|
202
|
+
const dy = p2[1] - p1[1]
|
|
203
|
+
const clip = (p, q) => {
|
|
204
|
+
if (p === 0) return q >= 0
|
|
205
|
+
const t = q / p
|
|
206
|
+
if (p < 0) { if (t > t1) return false; if (t > t0) t0 = t }
|
|
207
|
+
else { if (t < t0) return false; if (t < t1) t1 = t }
|
|
208
|
+
return true
|
|
209
|
+
}
|
|
210
|
+
if (!clip(-dx, p1[0] - inner.x)) return false
|
|
211
|
+
if (!clip(dx, inner.right - p1[0])) return false
|
|
212
|
+
if (!clip(-dy, p1[1] - inner.y)) return false
|
|
213
|
+
if (!clip(dy, inner.bottom - p1[1])) return false
|
|
214
|
+
return t0 <= t1
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 归一化后的元素表:id → el(并给出 rect)。 */
|
|
218
|
+
function indexElements(page) {
|
|
219
|
+
const map = new Map()
|
|
220
|
+
for (const el of page.elements ?? []) map.set(String(el.id ?? el.elementId), el)
|
|
221
|
+
return map
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 校验 `groups` 的结构合法性:成员存在、无重复、嵌套无环、一个元素只属一个直接组。
|
|
226
|
+
* @returns {{flat: Array, errors: Array<{code: string, detail: string}>, parentOf: Map<string, string>}}
|
|
227
|
+
*/
|
|
228
|
+
function normalizeGroups(page) {
|
|
229
|
+
const els = indexElements(page)
|
|
230
|
+
const groups = page.groups ?? []
|
|
231
|
+
const byId = new Map()
|
|
232
|
+
const errors = []
|
|
233
|
+
const seenGroupId = new Set()
|
|
234
|
+
for (const g of groups) {
|
|
235
|
+
const id = String(g?.id ?? '')
|
|
236
|
+
if (!id) { errors.push({ code: 'group-no-id', detail: 'groups[] 存在缺少 id 的组' }); continue }
|
|
237
|
+
if (seenGroupId.has(id)) { errors.push({ code: 'group-duplicate-id', detail: `组 id 重复:"${id}"` }); continue }
|
|
238
|
+
if (els.has(id)) { errors.push({ code: 'group-id-collides-element', detail: `组 id "${id}" 与元素 id 同名(同一命名空间)` }); continue }
|
|
239
|
+
seenGroupId.add(id)
|
|
240
|
+
const members = [...new Set((g.members ?? []).map(String))]
|
|
241
|
+
if (members.length !== (g.members ?? []).length) errors.push({ code: 'group-duplicate-member', detail: `组 "${id}" 的 members 含重复项` })
|
|
242
|
+
byId.set(id, { id, label: g.label === undefined ? undefined : String(g.label), members })
|
|
243
|
+
}
|
|
244
|
+
// 成员存在性
|
|
245
|
+
for (const g of byId.values()) {
|
|
246
|
+
for (const m of g.members) {
|
|
247
|
+
if (!els.has(m) && !byId.has(m)) errors.push({ code: 'group-member-missing', detail: `组 "${g.id}" 的成员 "${m}" 不存在` })
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// 唯一直接归属(元素/子组只能有一个直接父组)
|
|
251
|
+
const parentOf = new Map()
|
|
252
|
+
for (const g of byId.values()) {
|
|
253
|
+
for (const m of g.members) {
|
|
254
|
+
if (parentOf.has(m)) errors.push({ code: 'group-member-multi-parent', detail: `"${m}" 同时属于组 "${parentOf.get(m)}" 与 "${g.id}"(只允许一个直接父组)` })
|
|
255
|
+
else parentOf.set(m, g.id)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// 环检测(DFS)
|
|
259
|
+
const state = new Map()
|
|
260
|
+
const cycle = []
|
|
261
|
+
const walk = (id, stack) => {
|
|
262
|
+
if (state.get(id) === 'done') return
|
|
263
|
+
if (state.get(id) === 'visiting') { cycle.push([...stack, id].join(' → ')); return }
|
|
264
|
+
state.set(id, 'visiting')
|
|
265
|
+
for (const m of byId.get(id)?.members ?? []) if (byId.has(m)) walk(m, [...stack, id])
|
|
266
|
+
state.set(id, 'done')
|
|
267
|
+
}
|
|
268
|
+
for (const id of [...byId.keys()].sort()) walk(id, [])
|
|
269
|
+
for (const c of cycle) errors.push({ code: 'group-cycle', detail: `组嵌套成环:${c}` })
|
|
270
|
+
|
|
271
|
+
// 展开为扁平表(含 depth)
|
|
272
|
+
const depthOf = (id, guard = 0) => {
|
|
273
|
+
if (guard > 64) return 99
|
|
274
|
+
const p = parentOf.get(id)
|
|
275
|
+
return p === undefined ? 0 : 1 + depthOf(p, guard + 1)
|
|
276
|
+
}
|
|
277
|
+
const flat = [...byId.keys()].sort().map((id) => ({ ...byId.get(id), depth: depthOf(id) }))
|
|
278
|
+
return { flat, errors, parentOf }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* 推导全部结构关系并**逐条几何反验证**。
|
|
283
|
+
* @returns {{
|
|
284
|
+
* groups: Array, relations: Array, exempt: string[], invalid: Array,
|
|
285
|
+
* stats: {groups: number, contains: number, attach: number, badge: number, overlapDecl: number, exempt: number},
|
|
286
|
+
* errors: Array<{code: string, detail: string}>
|
|
287
|
+
* }}
|
|
288
|
+
*/
|
|
289
|
+
export function deriveRelations(page, opts = {}) {
|
|
290
|
+
const t = { ...RELATION_DEFAULTS, ...(opts.tolerance ?? {}) }
|
|
291
|
+
const els = indexElements(page)
|
|
292
|
+
const g = normalizeGroups(page)
|
|
293
|
+
const errors = [...g.errors]
|
|
294
|
+
const relations = []
|
|
295
|
+
const invalid = []
|
|
296
|
+
const exempt = new Set()
|
|
297
|
+
|
|
298
|
+
// 1) contains:父盒必须严格包含每个子盒
|
|
299
|
+
for (const id of [...els.keys()].sort()) {
|
|
300
|
+
const el = els.get(id)
|
|
301
|
+
const kids = [...new Set((el.contains ?? []).map(String))].sort()
|
|
302
|
+
if (kids.length === 0) continue
|
|
303
|
+
const parent = rectOf(el)
|
|
304
|
+
for (const cid of kids) {
|
|
305
|
+
const child = els.get(cid)
|
|
306
|
+
if (!child) { invalid.push({ type: 'contains', from: id, to: cid, why: `子元素 "${cid}" 不存在` }); continue }
|
|
307
|
+
const ok = contains(parent, rectOf(child), t.pad)
|
|
308
|
+
relations.push({ type: 'contains', from: id, to: cid, valid: ok, evidence: { pad: t.pad, parent, child: rectOf(child) }, reason: el.roleReason })
|
|
309
|
+
if (ok) exempt.add(key2(id, cid))
|
|
310
|
+
else invalid.push({ type: 'contains', from: id, to: cid, why: `父盒未真正包含子元素(pad=${t.pad})` })
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// 2) attach:端点必须落在目标元素的**对应边**上
|
|
315
|
+
for (const id of [...els.keys()].sort()) {
|
|
316
|
+
const el = els.get(id)
|
|
317
|
+
const a = el.attach
|
|
318
|
+
if (!a) continue
|
|
319
|
+
const pts = Array.isArray(el.points) ? el.points : []
|
|
320
|
+
const sides = [['from', pts[0]], ['to', pts[pts.length - 1]]]
|
|
321
|
+
for (const [end, pt] of sides) {
|
|
322
|
+
const spec = a[end]
|
|
323
|
+
if (!spec) continue
|
|
324
|
+
const ref = els.get(String(spec.ref))
|
|
325
|
+
if (!ref) { invalid.push({ type: 'attach', from: id, to: String(spec.ref), why: `${end} 端引用的元素不存在` }); continue }
|
|
326
|
+
if (!Array.isArray(pt)) { invalid.push({ type: 'attach', from: id, to: String(spec.ref), why: `${end} 端缺少坐标(points 至少 2 点)` }); continue }
|
|
327
|
+
// 统一入口(认包围盒边 + 斜边真实轮廓)——不再各处自己拼判定
|
|
328
|
+
const ok = onAttachSide(ref, String(spec.side), pt, t.tol)
|
|
329
|
+
relations.push({ type: 'attach', from: id, to: String(spec.ref), end, valid: ok, evidence: { side: spec.side, point: pt, tol: t.tol } })
|
|
330
|
+
if (!ok) invalid.push({ type: 'attach', from: id, to: String(spec.ref), why: `${end} 端点未落在 ${spec.ref} 的 ${spec.side} 边(tol=${t.tol})` })
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// 3) badgeOf:确实相交 + 面积比 ≤ 上限 + 不完全覆盖
|
|
335
|
+
for (const id of [...els.keys()].sort()) {
|
|
336
|
+
const el = els.get(id)
|
|
337
|
+
const ownerId = el.badgeOf === undefined ? null : String(el.badgeOf)
|
|
338
|
+
if (ownerId === null) continue
|
|
339
|
+
const owner = els.get(ownerId)
|
|
340
|
+
if (!owner) { invalid.push({ type: 'badge', from: id, to: ownerId, why: '被归属的元素不存在' }); continue }
|
|
341
|
+
const chipR = rectOf(el)
|
|
342
|
+
const ownerR = rectOf(owner)
|
|
343
|
+
const hit = intersects(chipR, ownerR, t.tol)
|
|
344
|
+
const ratio = area(ownerR) > 0 ? area(chipR) / area(ownerR) : 1
|
|
345
|
+
const ownerCornerOutside = [[ownerR.x, ownerR.y], [ownerR.right, ownerR.y], [ownerR.x, ownerR.bottom], [ownerR.right, ownerR.bottom]]
|
|
346
|
+
.some(([x, y]) => x < chipR.x || x > chipR.right || y < chipR.y || y > chipR.bottom)
|
|
347
|
+
// 注:面积比上限通常已经覆盖"完全覆盖"的情形(chip ⊇ owner ⇒ 面积比 ≥ 1),这一条作为**额外防线**保留。
|
|
348
|
+
const ok = hit && ratio <= t.badgeMaxRatio && ownerCornerOutside
|
|
349
|
+
relations.push({ type: 'badge', from: id, to: ownerId, valid: ok, evidence: { hit, areaRatio: Number(ratio.toFixed(3)), ownerCornerOutside, cap: t.badgeMaxRatio } })
|
|
350
|
+
if (ok) exempt.add(key2(id, ownerId))
|
|
351
|
+
else invalid.push({
|
|
352
|
+
type: 'badge',
|
|
353
|
+
from: id,
|
|
354
|
+
to: ownerId,
|
|
355
|
+
why: !hit ? '徽章与被归属元素并不相交' : (ratio > t.badgeMaxRatio ? `面积比 ${ratio.toFixed(2)} 超过上限 ${t.badgeMaxRatio}(这更像遮罩)` : '完全覆盖了被归属元素(这更像遮罩)'),
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// 4) expectedOverlaps(含跨组用法):两端存在、不得同为 content、必须确实相交
|
|
360
|
+
const declared = Array.isArray(page.expectedOverlaps) ? page.expectedOverlaps : []
|
|
361
|
+
for (const [i, d] of declared.entries()) {
|
|
362
|
+
const pair = Array.isArray(d?.pair) ? d.pair.map(String) : []
|
|
363
|
+
if (pair.length !== 2) { errors.push({ code: 'declared-bad-shape', detail: `expectedOverlaps[${i}] 必须是 {pair: [idA, idB]}${d?.reason ? '' : '(建议补 reason)'}` }); continue }
|
|
364
|
+
const [aId, bId] = pair
|
|
365
|
+
const A = els.get(aId)
|
|
366
|
+
const B = els.get(bId)
|
|
367
|
+
if (!A || !B) { errors.push({ code: 'declared-missing-endpoint', detail: `expectedOverlaps[${i}] 端点不存在:${!A ? aId : bId}` }); continue }
|
|
368
|
+
const contentOf = (el) => ['text', 'table', 'chart'].includes(el.elementType) || el.role === 'content'
|
|
369
|
+
if (contentOf(A) && contentOf(B)) { errors.push({ code: 'declared-content-collision', detail: `expectedOverlaps[${i}](${aId} × ${bId})两端都是内容元素——内容互压**不可声明**` }); continue }
|
|
370
|
+
const hit = intersects(rectOf(A), rectOf(B), t.tol)
|
|
371
|
+
relations.push({ type: 'overlapDecl', from: aId, to: bId, valid: hit, evidence: { intersects: hit }, reason: d?.reason })
|
|
372
|
+
if (hit) exempt.add(key2(aId, bId))
|
|
373
|
+
else invalid.push({ type: 'overlapDecl', from: aId, to: bId, why: '声明已失效:两端当前并不相交(建议移除该声明)' })
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// 声明不成立 ⇒ **点名错误**(按普通冲突处理);只有"声明已失效(不再相交)"降级为 ℹ
|
|
377
|
+
for (const iv of invalid) {
|
|
378
|
+
if (iv.type === 'overlapDecl') continue
|
|
379
|
+
errors.push({ code: 'relation-invalid', detail: `${iv.type} 声明不成立:${iv.from} × ${iv.to} —— ${iv.why}` })
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// 1b) 包含关系的**传递闭包**:A ⊇ B ⊇ C ⇒ A × C 也应豁免。
|
|
383
|
+
// 理由(用户的真实工作方式):组合是**嵌套**的——"第二层组合把第一层当成一个整体",
|
|
384
|
+
// 外层容器不该被迫枚举所有深后代(否则声明清单随嵌套层数爆炸,正是我们一开始想避免的)。
|
|
385
|
+
// 只沿**有效**的 contains 边传播;语义与 verify 既有的声明闭包(declaredClosure)一致。
|
|
386
|
+
const childrenOf = new Map()
|
|
387
|
+
for (const r of relations) {
|
|
388
|
+
if (r.type !== 'contains' || !r.valid) continue
|
|
389
|
+
if (!childrenOf.has(r.from)) childrenOf.set(r.from, [])
|
|
390
|
+
childrenOf.get(r.from).push(r.to)
|
|
391
|
+
}
|
|
392
|
+
let closurePairs = 0
|
|
393
|
+
for (const parent of [...childrenOf.keys()].sort()) {
|
|
394
|
+
const seen = new Set()
|
|
395
|
+
const stack = [...childrenOf.get(parent)].sort()
|
|
396
|
+
while (stack.length) {
|
|
397
|
+
const id = stack.pop()
|
|
398
|
+
if (seen.has(id)) continue
|
|
399
|
+
seen.add(id)
|
|
400
|
+
const k = key2(parent, id)
|
|
401
|
+
if (!exempt.has(k)) { exempt.add(k); closurePairs++ }
|
|
402
|
+
for (const grand of (childrenOf.get(id) ?? []).sort()) if (!seen.has(grand)) stack.push(grand)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// 确定性:全部按 id 排序
|
|
407
|
+
relations.sort((x, y) => `${x.type}|${x.from}|${x.to}|${x.end ?? ''}`.localeCompare(`${y.type}|${y.from}|${y.to}|${y.end ?? ''}`))
|
|
408
|
+
const stats = {
|
|
409
|
+
groups: g.flat.length,
|
|
410
|
+
contains: relations.filter((r) => r.type === 'contains').length,
|
|
411
|
+
attach: relations.filter((r) => r.type === 'attach').length,
|
|
412
|
+
badge: relations.filter((r) => r.type === 'badge').length,
|
|
413
|
+
overlapDecl: relations.filter((r) => r.type === 'overlapDecl').length,
|
|
414
|
+
exempt: exempt.size,
|
|
415
|
+
closurePairs,
|
|
416
|
+
}
|
|
417
|
+
return { groups: g.flat, relations, exempt: [...exempt].sort(), invalid, stats, errors }
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* 图专属机械检查(docs/08 §3)——不依赖任何声明也会查。
|
|
422
|
+
* @returns {{errors: Array, warnings: Array}}
|
|
423
|
+
*/
|
|
424
|
+
export function checkDiagram(page, opts = {}) {
|
|
425
|
+
const t = { ...RELATION_DEFAULTS, ...(opts.tolerance ?? {}) }
|
|
426
|
+
const errors = []
|
|
427
|
+
const warnings = []
|
|
428
|
+
const els = [...(page.elements ?? [])].map((el) => ({ el, id: String(el.id ?? el.elementId), r: rectOf(el) }))
|
|
429
|
+
.sort((a, b) => a.id.localeCompare(b.id))
|
|
430
|
+
const byId = new Map(els.map((e) => [e.id, e]))
|
|
431
|
+
|
|
432
|
+
// ① duplicate-element:同 kind + 包围盒几乎相同
|
|
433
|
+
for (let i = 0; i < els.length; i++) {
|
|
434
|
+
for (let j = i + 1; j < els.length; j++) {
|
|
435
|
+
const A = els[i]
|
|
436
|
+
const B = els[j]
|
|
437
|
+
if (A.el.elementType !== B.el.elementType) continue
|
|
438
|
+
const near = Math.abs(A.r.x - B.r.x) < t.duplicateTol && Math.abs(A.r.y - B.r.y) < t.duplicateTol
|
|
439
|
+
&& Math.abs(A.r.w - B.r.w) < t.duplicateTol && Math.abs(A.r.h - B.r.h) < t.duplicateTol
|
|
440
|
+
if (near) errors.push({ code: 'duplicate-element', detail: `"${A.id}" 与 "${B.id}" 几乎重合(Δ<${t.duplicateTol}px,同类型 ${A.el.elementType})` })
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ② text-over-text:两个文本元素相交(内容互压,永不可声明)
|
|
445
|
+
const texts = els.filter((e) => e.el.elementType === 'text')
|
|
446
|
+
for (let i = 0; i < texts.length; i++) {
|
|
447
|
+
for (let j = i + 1; j < texts.length; j++) {
|
|
448
|
+
if (intersects(texts[i].r, texts[j].r, t.overlapTol)) {
|
|
449
|
+
errors.push({ code: 'text-over-text', detail: `文本 "${texts[i].id}" 与 "${texts[j].id}" 相互遮挡(内容互压不可声明,请调整布局)` })
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ③ line-through-box:线穿过**非端点盒子**的内部
|
|
455
|
+
// 只看"盒子":shape/image/table,排除 decoration;排除**容器**(自己声明了 contains 的元素——
|
|
456
|
+
// 线在容器内部是正常的)。注意:**不能**把"容器的子元素"也排除掉,否则箭头/穿盒检查会漏掉真正的节点盒。
|
|
457
|
+
const boxes = els.filter((e) => ['shape', 'image', 'table'].includes(e.el.elementType)
|
|
458
|
+
&& e.el.role !== 'decoration' && !(Array.isArray(e.el.contains) && e.el.contains.length > 0))
|
|
459
|
+
for (const line of els.filter((e) => e.el.elementType === 'line')) {
|
|
460
|
+
const pts = Array.isArray(line.el.points) ? line.el.points : []
|
|
461
|
+
if (pts.length < 2) continue
|
|
462
|
+
const attachRefs = new Set()
|
|
463
|
+
for (const end of ['from', 'to']) {
|
|
464
|
+
const ref = line.el.attach?.[end]?.ref
|
|
465
|
+
if (ref !== undefined) attachRefs.add(String(ref))
|
|
466
|
+
}
|
|
467
|
+
for (const box of boxes) {
|
|
468
|
+
if (box.id === line.id || attachRefs.has(box.id)) continue
|
|
469
|
+
for (let k = 0; k + 1 < pts.length; k++) {
|
|
470
|
+
if (segmentCrossesRect(pts[k], pts[k + 1], box.r, t.tol)) {
|
|
471
|
+
errors.push({ code: 'line-through-box', detail: `线 "${line.id}" 穿过 "${box.id}" 内部` })
|
|
472
|
+
break
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ④ arrow-not-on-edge:带箭头但端点没接触任何元素边界(无 attach 时仅告警)
|
|
479
|
+
for (const line of els.filter((e) => e.el.elementType === 'line')) {
|
|
480
|
+
if (!line.el.arrow) continue
|
|
481
|
+
const pts = Array.isArray(line.el.points) ? line.el.points : []
|
|
482
|
+
if (pts.length < 2) continue
|
|
483
|
+
const tips = []
|
|
484
|
+
if (line.el.arrow === 'both') tips.push(pts[0], pts[pts.length - 1])
|
|
485
|
+
else tips.push(pts[pts.length - 1])
|
|
486
|
+
for (const tip of tips) {
|
|
487
|
+
const touches = boxes.some((b) => onSide('left', tip, b.r, t.tol) || onSide('right', tip, b.r, t.tol)
|
|
488
|
+
|| onSide('top', tip, b.r, t.tol) || onSide('bottom', tip, b.r, t.tol))
|
|
489
|
+
if (!touches) {
|
|
490
|
+
const hasAttach = line.el.attach !== undefined
|
|
491
|
+
const detail = `线 "${line.id}" 的箭头端未落在任何元素边界(tol=${t.tol})`
|
|
492
|
+
if (hasAttach) errors.push({ code: 'arrow-not-on-edge', detail: `${detail}——已声明 attach,属声明不成立` })
|
|
493
|
+
else warnings.push({ code: 'arrow-not-on-edge', detail: `${detail},可能脱靶` })
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return { errors, warnings }
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* `attach` → 坐标解析(阶段 A-④ 集成的核心,docs/08 §1.1)。
|
|
503
|
+
*
|
|
504
|
+
* 语义:**`attach` 优先于手写 `points`**——被改写的端点必须**另行提示**(不能安静地覆盖作者的写法)。
|
|
505
|
+
* 为什么需要它:作者写"从 A 的右边连到 B 的左边"时不该手算像素;元素一动,手算坐标就错。
|
|
506
|
+
* 解析后导出侧只认 `points`(导出器保持纯写盘层),几何仍由 relations 的反验证兜底。
|
|
507
|
+
*
|
|
508
|
+
* 锚点算法:把"另一端"投影到目标边的线段上并夹到线段范围内(最短连线,视觉上最自然)。
|
|
509
|
+
* 纯函数、确定性、不改输入。
|
|
510
|
+
*
|
|
511
|
+
* @param {object} el 线元素(原始 DSL 或归一化元素均可)
|
|
512
|
+
* @param {Map|object} pageOrMap 页面(或 id→元素 的 Map),用于查 ref 的几何
|
|
513
|
+
* @returns {{points: Array<[number,number]>|null, notes: string[], overridden: number}}
|
|
514
|
+
*/
|
|
515
|
+
export function resolveAttach(el, pageOrMap, opts = {}) {
|
|
516
|
+
const t = { ...RELATION_DEFAULTS, ...(opts.tolerance ?? {}) }
|
|
517
|
+
const src = Array.isArray(el?.points) ? el.points : null
|
|
518
|
+
const copy = src ? src.map((p) => [Number(p[0]), Number(p[1])]) : null
|
|
519
|
+
const a = el?.attach
|
|
520
|
+
if (!a) return { points: copy, notes: [], overridden: 0 }
|
|
521
|
+
const els = pageOrMap instanceof Map ? pageOrMap : indexElements(pageOrMap ?? {})
|
|
522
|
+
const notes = []
|
|
523
|
+
let overridden = 0
|
|
524
|
+
|
|
525
|
+
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v))
|
|
526
|
+
/** 目标边上、离 `toward` 最近的锚点(兜底:轴对齐夹紧)。 */
|
|
527
|
+
const clampOnSide = (rect, side, toward) => {
|
|
528
|
+
const [tx, ty] = toward
|
|
529
|
+
if (side === 'left') return [rect.x, clamp(ty, rect.y, rect.bottom)]
|
|
530
|
+
if (side === 'right') return [rect.right, clamp(ty, rect.y, rect.bottom)]
|
|
531
|
+
if (side === 'top') return [clamp(tx, rect.x, rect.right), rect.y]
|
|
532
|
+
if (side === 'bottom') return [clamp(tx, rect.x, rect.right), rect.bottom]
|
|
533
|
+
return [tx, ty]
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* 自然锚点:从**另一端**指向目标矩形中心,与该边的交点(保持线的自然方向)。
|
|
537
|
+
* 交点落在边线段外 ⇒ 返回 null,交回兜底夹紧(等价于落在最近的角)。
|
|
538
|
+
*/
|
|
539
|
+
const hitSide = (from, toward, rect, side) => {
|
|
540
|
+
const [x1, y1] = from
|
|
541
|
+
const [x2, y2] = toward
|
|
542
|
+
const dx = x2 - x1
|
|
543
|
+
const dy = y2 - y1
|
|
544
|
+
const vertical = side === 'left' || side === 'right'
|
|
545
|
+
const target = side === 'left' ? rect.x : side === 'right' ? rect.right : side === 'top' ? rect.y : rect.bottom
|
|
546
|
+
const denom = vertical ? dx : dy
|
|
547
|
+
if (Math.abs(denom) < 1e-9) return null
|
|
548
|
+
const t = (target - (vertical ? x1 : y1)) / denom
|
|
549
|
+
const hx = x1 + t * dx
|
|
550
|
+
const hy = y1 + t * dy
|
|
551
|
+
if (vertical) return (hy >= rect.y - t.tol && hy <= rect.bottom + t.tol) ? [target, clamp(hy, rect.y, rect.bottom)] : null
|
|
552
|
+
return (hx >= rect.x - t.tol && hx <= rect.right + t.tol) ? [clamp(hx, rect.x, rect.right), target] : null
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* 解析一个端点的锚点:
|
|
556
|
+
* **作者已经写在目标边上(容差内)⇒ 原样保留**(不做无谓改写、不产生噪音提示);
|
|
557
|
+
* 否则才按"另一端 → 目标中心"的自然连线求边交点,交点不在边线段上时退化为最近角。
|
|
558
|
+
*/
|
|
559
|
+
const anchorFor = (rect, side, toward, keep) => {
|
|
560
|
+
if (Array.isArray(keep) && onSide(side, keep, rect, t.tol)) return [keep[0], keep[1]]
|
|
561
|
+
const cx = rect.x + rect.w / 2
|
|
562
|
+
const cy = rect.y + rect.h / 2
|
|
563
|
+
return hitSide(toward, [cx, cy], rect, side) ?? clampOnSide(rect, side, toward)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// 没有手写 points 时,用两端锚点造一条(attach 足以定义一条线)
|
|
567
|
+
const out = copy && copy.length >= 2 ? copy : [[0, 0], [0, 0]]
|
|
568
|
+
const last = out.length - 1
|
|
569
|
+
// 先算 to(用它作为 from 的朝向参考),再算 from —— 两侧同时给出时才互相参考
|
|
570
|
+
const refFrom = els.get(String(a.from?.ref ?? ''))
|
|
571
|
+
const refTo = els.get(String(a.to?.ref ?? ''))
|
|
572
|
+
// 斜边预设(平行四边形/三角形/菱形):锚点落在**真实轮廓**上,而不是包围盒边。
|
|
573
|
+
// 单一来源 = `outlineAnchor`(与门禁侧的 `slantRect` 共用同一套公式,避免"解析对了、校验报错")。
|
|
574
|
+
const onOutline = (ref, rect, side, p) => outlineAnchor(ref, side, p) ?? p
|
|
575
|
+
const anchorOf = (spec, ref, toward, keep) => {
|
|
576
|
+
if (!spec || !ref) return null
|
|
577
|
+
const side = String(spec.side)
|
|
578
|
+
if (!ATTACH_SIDES.includes(side)) return null
|
|
579
|
+
const rect = rectOf(ref)
|
|
580
|
+
return onOutline(ref, rect, side, anchorFor(rect, side, toward, keep))
|
|
581
|
+
}
|
|
582
|
+
const towardTo = copy && copy.length >= 2 ? copy[0] : [out[0][0], out[0][1]]
|
|
583
|
+
const towardFrom = copy && copy.length >= 2 ? copy[last] : [out[last][0], out[last][1]]
|
|
584
|
+
const toAnchor = anchorOf(a.to, refTo, towardTo, copy ? copy[last] : null)
|
|
585
|
+
const fromAnchor = anchorOf(a.from, refFrom, toAnchor ?? towardFrom, copy ? copy[0] : null)
|
|
586
|
+
|
|
587
|
+
const apply = (idx, anchor, end) => {
|
|
588
|
+
if (!anchor) return
|
|
589
|
+
const before = [out[idx][0], out[idx][1]]
|
|
590
|
+
const d = Math.hypot(before[0] - anchor[0], before[1] - anchor[1])
|
|
591
|
+
out[idx] = [anchor[0], anchor[1]]
|
|
592
|
+
// 手写坐标与解析结果不一致(> 容差)⇒ 记为"被改写"并提示(静默覆盖是不可接受的)
|
|
593
|
+
if (copy && d > t.tol) {
|
|
594
|
+
overridden++
|
|
595
|
+
notes.push(`${end} 端手写坐标 [${Math.round(before[0])}, ${Math.round(before[1])}] 与 attach 解析结果 [${Math.round(anchor[0])}, ${Math.round(anchor[1])}] 不一致(相差 ${d.toFixed(1)}px)⇒ 按 attach 覆盖`)
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
apply(0, fromAnchor, 'from')
|
|
599
|
+
apply(last, toAnchor, 'to')
|
|
600
|
+
// 退化保护:解析后两端重合 ⇒ 无向线,明确报出来(不静默产出零长度线)
|
|
601
|
+
if (out.length === 2 && out[0][0] === out[1][0] && out[0][1] === out[1][1]) notes.push('解析后两端点重合(零长度线)——请检查 attach 的 ref/side')
|
|
602
|
+
return { points: out, notes, overridden }
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** 报告用一行摘要(A-③ 集成进 ppt_verify 时会用)。 */
|
|
606
|
+
export function summaryLine(rel) {
|
|
607
|
+
const s = rel.stats
|
|
608
|
+
const parts = [`组 ${s.groups}`, `包含 ${s.contains}`, `附着 ${s.attach}`, `徽章 ${s.badge}`, `有意重叠 ${s.overlapDecl}`]
|
|
609
|
+
return `结构关系:${parts.join('|')}(自动豁免重叠 ${s.exempt} 处,未落盘)`
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** 未做几何反验证的 byId 查询(供 verify 集成使用)。 */
|
|
613
|
+
export function elementMap(page) {
|
|
614
|
+
return indexElements(page)
|
|
615
|
+
}
|
package/lib/pptd/render-html.js
CHANGED
|
@@ -93,7 +93,22 @@ function elementHtml(el, ctx, debug) {
|
|
|
93
93
|
if (/^(https?:|data:|file:)/.test(src)) return src
|
|
94
94
|
return src.startsWith('./') ? '../' + src.slice(2) : '../' + src
|
|
95
95
|
}
|
|
96
|
-
const snap = (extra) => ({
|
|
96
|
+
const snap = (extra) => ({
|
|
97
|
+
id: el.id,
|
|
98
|
+
kind: el.type,
|
|
99
|
+
bounds: el.bounds,
|
|
100
|
+
...(el.role ? { role: el.role } : {}),
|
|
101
|
+
// 阶段 A:**结构声明字段必须进 layout.json**——`ppt_verify` 吃的是 layout.json,
|
|
102
|
+
// 白名单式快照漏字段会让"结构关系/豁免/图专属检查"在门禁层凭空消失。
|
|
103
|
+
// (与 normalizePage 同型的坑;150 元素规模基线脚本正是靠这条断言抓到的。)
|
|
104
|
+
...(el.contains ? { contains: el.contains } : {}),
|
|
105
|
+
...(el.badgeOf ? { badgeOf: el.badgeOf } : {}),
|
|
106
|
+
...(el.roleReason ? { roleReason: el.roleReason } : {}),
|
|
107
|
+
...(el.attach ? { attach: el.attach } : {}),
|
|
108
|
+
...(el.attachNotes ? { attachNotes: el.attachNotes } : {}),
|
|
109
|
+
...(el.arrow ? { arrow: el.arrow } : {}),
|
|
110
|
+
...extra,
|
|
111
|
+
})
|
|
97
112
|
switch (el.type) {
|
|
98
113
|
case 'text': {
|
|
99
114
|
const s = el.style
|
|
@@ -244,6 +259,9 @@ export async function renderDeck(ctx, { out = 'preview', debug = false } = {}) {
|
|
|
244
259
|
expectedOutOfSafeArea: page.page.expectedOutOfSafeArea ?? [],
|
|
245
260
|
contrastExempt: page.page.contrastExempt ?? [],
|
|
246
261
|
overlapMode: page.page.overlapMode ?? 'declared',
|
|
262
|
+
// 阶段 A:**逻辑组合必须随 layout.json 落盘**——`ppt_verify` 吃的是 layout.json,
|
|
263
|
+
// 不透传则"结构关系/豁免/图专属检查"在门禁这一层凭空消失(本轮修过同型问题:normalizePage 白名单吞字段)。
|
|
264
|
+
groups: page.page.groups ?? [],
|
|
247
265
|
elements: snaps,
|
|
248
266
|
})
|
|
249
267
|
}
|