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.
Files changed (94) hide show
  1. package/README.md +4 -1
  2. package/docs/00-/350/256/276/350/256/241/346/272/220.md +4 -0
  3. package/docs/02-/346/212/200/346/234/257/346/212/245/345/221/212.md +114 -1
  4. package/docs/03-/346/233/264/346/226/260/346/227/245/345/277/227.md +159 -0
  5. package/docs/05-/350/277/255/344/273/243/346/265/201/347/250/213.md +23 -0
  6. package/docs/06-/350/257/204/345/256/241/344/270/216/346/265/213/350/257/225.md +2 -1
  7. 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
  8. 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
  9. 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
  10. 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
  11. package/docs/11-/346/234/257/350/257/255/350/241/250.md +42 -0
  12. 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
  13. package/docs/13-/345/217/221/347/211/210/345/207/206/345/244/207/350/256/241/345/210/222.md +79 -0
  14. 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
  15. package/docs/15-1.2.0 /345/217/221/345/270/203/346/212/245/345/221/212.md" +46 -0
  16. 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
  17. package/examples/fx/fx-pro/preview/layout.json +12 -0
  18. package/examples/fx/fx-pro/preview/measured.json +1 -1
  19. package/examples/fx/fx-pro.pptx +0 -0
  20. package/examples/fx/seed.pptx +0 -0
  21. package/examples/fx/single.pptx +0 -0
  22. package/examples/fx/spliced.pptx +0 -0
  23. package/examples/smoke/imported/preview-rt/layout.json +3 -0
  24. package/examples/smoke/imported/source.pptx +0 -0
  25. package/examples/smoke/out-smoke.pptx +0 -0
  26. package/examples/smoke/preview/layout.json +3 -0
  27. package/lib/group-render.js +177 -0
  28. package/lib/msrender.js +27 -12
  29. package/lib/pptd/blocks.js +52 -0
  30. package/lib/pptd/export-pptx.js +266 -11
  31. package/lib/pptd/layout.js +54 -3
  32. package/lib/pptd/line-rules.js +259 -0
  33. package/lib/pptd/line-text-rule.js +90 -0
  34. package/lib/pptd/relations.js +615 -0
  35. package/lib/pptd/render-html.js +19 -1
  36. package/lib/pptd/route-geometry.js +120 -0
  37. package/lib/pptd/schema.js +146 -11
  38. package/lib/pptd/style-profile.js +133 -0
  39. package/lib/scaffold.js +54 -1
  40. package/lib/tools.js +20 -2
  41. package/lib/verify.js +69 -1
  42. package/package.json +20 -3
  43. package/scripts/audit-manual-facts.mjs +15 -3
  44. package/scripts/bench-diagram-scale.mjs +165 -0
  45. package/scripts/check-generators.mjs +22 -0
  46. package/scripts/check-lib-sync.mjs +33 -0
  47. package/scripts/check-line-text.mjs +30 -0
  48. package/scripts/check-package.mjs +60 -0
  49. package/scripts/check-removed-traces.mjs +37 -0
  50. package/scripts/check-syntax.mjs +44 -0
  51. package/scripts/check-unknown-keys.mjs +114 -0
  52. package/scripts/fixtures/handdrawn-baseline.json +177 -0
  53. package/scripts/fixtures/prompt-cost-baseline.json +178 -0
  54. package/scripts/handdrawn-option-probe.mjs +78 -0
  55. package/scripts/probe-prompt-cost.mjs +127 -0
  56. package/scripts/red-probe.mjs +75 -0
  57. package/scripts/render-group.mjs +72 -0
  58. package/scripts/smoke.mjs +191 -20
  59. package/scripts/test-fast.mjs +108 -0
  60. package/scripts/test-map.mjs +162 -0
  61. package/scripts/test-release.mjs +64 -0
  62. package/scripts/verify-diagram-relations.mjs +405 -0
  63. package/scripts/verify-handdrawn.mjs +115 -0
  64. package/scripts/verify-handwritten.mjs +203 -0
  65. package/scripts/verify-preset-scope.mjs +1 -1
  66. package/scripts/verify-primitives.mjs +714 -0
  67. package/scripts/verify-products.mjs +37 -0
  68. package/scripts/verify-shape.mjs +84 -0
  69. package/scripts/visual-pair-batch.mjs +77 -0
  70. package/skills/ppt-studio-diagram/SKILL.md +30 -0
  71. package/skills/ppt-studio-diagram/capability-matrix.md +22 -0
  72. package/skills/ppt-studio-diagram/composition.md +22 -0
  73. package/skills/ppt-studio-diagram/mechanisms.md +141 -0
  74. package/skills/ppt-studio-diagram/pitfalls.md +97 -0
  75. package/skills/ppt-studio-diagram/routing.md +17 -0
  76. package/skills/ppt-studio-diagram/text-and-spacing.md +16 -0
  77. package/skills/ppt-studio-diagram/verify.md +50 -0
  78. package/skills/ppt-studio-diagram-compare/SKILL.md +38 -0
  79. package/skills/ppt-studio-diagram-flow/SKILL.md +38 -0
  80. package/skills/ppt-studio-diagram-funnel/SKILL.md +39 -0
  81. package/skills/ppt-studio-diagram-layers/SKILL.md +32 -0
  82. package/skills/ppt-studio-diagram-loop/SKILL.md +39 -0
  83. package/skills/ppt-studio-diagram-matrix/SKILL.md +37 -0
  84. package/skills/ppt-studio-diagram-sequence/SKILL.md +31 -0
  85. package/skills/ppt-studio-diagram-state/SKILL.md +48 -0
  86. package/skills/ppt-studio-diagram-steps-ring/SKILL.md +38 -0
  87. package/skills/ppt-studio-diagram-swimlane/SKILL.md +37 -0
  88. package/skills/ppt-studio-diagram-timeline/SKILL.md +38 -0
  89. package/skills/ppt-studio-diagram-tree/SKILL.md +38 -0
  90. package/skills/ppt-studio-manual/SKILL.md +2 -2
  91. package/templates/academic-white/preview.png +0 -0
  92. package/templates/business-blue/preview.png +0 -0
  93. package/templates/pitch-bold/preview.png +0 -0
  94. package/templates/tech-dark/preview.png +0 -0
@@ -0,0 +1,259 @@
1
+ /**
2
+ * 折线绘制规则检查(阶段 A1,泛用性优先)。
3
+ *
4
+ * 但**手写页面**(不属于任何族的自由发挥)此前拿不到任何反馈 ⇒ 助手手绘时会重复踩坑
5
+ * (箭头端拐弯、斜段方向不清)。这里对**所有**页面的折线统一给警告。
6
+ *
7
+ * 规则(docs/13 §5.8 的手写版):
8
+ * · `arrow-end-short-run`:箭头端紧邻的那一段 < 18px。拐点贴箭头时,箭头三角"吃掉"整段 ⇒
9
+ * 看起来像断的、也看不出指向(用户实测附图)。
10
+ * · `line-diagonal-segment`:折线出现斜段。连接线只用轴对齐段;**刻意斜**的图形
11
+ * (环形/放射/漏斗这类本身就是斜的)用 `role: 'decoration'` 或写 `roleReason` 显式声明豁免。
12
+ *
13
+ * 只产出 **warning**:不改任何判定 ⇒ 既有稿的产物、结论与门禁结果都不变(无图页逐字节不变)。
14
+ */
15
+
16
+ export { ARROW_MIN_RUN }
17
+
18
+ const r1 = (n) => Math.round(n * 10) / 10
19
+
20
+ import { outlineAnchor } from './relations.js'
21
+ import { ARROW_MIN_RUN } from './route-geometry.js'
22
+ import { checkLineText } from './line-text-rule.js'
23
+
24
+ export function checkLineRules(els) {
25
+ const out = []
26
+ out.push(...checkLineText(els)) // G1?????????????? role/roleReason ???
27
+ const lines = [] // 供第二阶段的"线×线/线×边框"两两比较
28
+ for (const el of els ?? []) {
29
+ // 两条路径的字段名不同:**DSL 原文**用 elementType,**preview 快照**用 kind(实测踩过:
30
+ // 只判 elementType 时快照路径一条都不报)。几何用 points(快照里也保留)。
31
+ const isLine = el?.elementType === 'line' || el?.kind === 'line'
32
+ if (!isLine) continue
33
+ const pts = el.points
34
+ if (!Array.isArray(pts) || pts.length < 2) continue
35
+ const id = el.elementId ?? el.id ?? '?'
36
+ lines.push({ id, pts, deliberate: el.role === 'decoration' || Boolean(el.roleReason) })
37
+ const deliberate = el.role === 'decoration' || Boolean(el.roleReason) // 显式声明"斜线是设计意图"
38
+
39
+ // ① 斜段(只报第一条,避免刷屏)
40
+ if (!deliberate) {
41
+ for (let i = 0; i + 1 < pts.length; i++) {
42
+ const dx = Math.abs(pts[i + 1][0] - pts[i][0])
43
+ const dy = Math.abs(pts[i + 1][1] - pts[i][1])
44
+ if (dx > 0.5 && dy > 0.5) {
45
+ out.push({
46
+ code: 'line-diagonal-segment',
47
+ message: `${id}: 折线第 ${i + 1} 段是斜段(Δx=${r1(dx)} Δy=${r1(dy)})—— 连接线请只用轴对齐段(横/竖),斜段会让箭头方向不确定;若这是刻意的环形/放射/漏斗等图形,给该元素加 role: decoration 或写 roleReason 即视为设计意图、不再提示`,
48
+ })
49
+ break
50
+ }
51
+ }
52
+ }
53
+
54
+ // ② 箭头端直段(双箭头则两端都查)
55
+ const segLen = (i) => Math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1])
56
+ const ends = []
57
+ if (el.arrow === true || el.arrow === 'end' || el.arrow === 'both') ends.push({ i: pts.length - 2, where: '终点' })
58
+ if (el.arrow === 'both') ends.push({ i: 0, where: '起点' })
59
+ for (const { i, where } of ends) {
60
+ const d = segLen(i)
61
+ if (d < ARROW_MIN_RUN) {
62
+ out.push({
63
+ code: 'arrow-end-short-run',
64
+ message: `${id}: 箭头${where}紧邻的直线段只有 ${r1(d)}px(建议 ≥ ${ARROW_MIN_RUN}px)—— 拐点贴着箭头时箭头像"断的"、看不出指向;把拐点往外挪,或用 attach: {ref, side} 让引擎把端点锚到元素边上`,
65
+ })
66
+ }
67
+ }
68
+ }
69
+ // ── ③ 共线重叠:用户裁定的"真缺陷"判据 ──────────────────────────────────
70
+ // 两条线走在**同一条直线上且部分重合**(只共用一个点不算——树状干线是标准画法,读者能分清方向)。
71
+ const segsOf = (l) => l.pts.slice(0, -1).map((p, i) => ({ p, q: l.pts[i + 1] }))
72
+ const overlap1D = (a1, a2, b1, b2) => Math.min(Math.max(a1, a2), Math.max(b1, b2)) - Math.max(Math.min(a1, a2), Math.min(b1, b2))
73
+ for (let a = 0; a < lines.length; a++) {
74
+ for (let b = a + 1; b < lines.length; b++) {
75
+ const la = lines[a]; const lb = lines[b]
76
+ if (la.deliberate && lb.deliberate) continue
77
+ // **共用端点的"扇出/汇入"豁免**(用户裁定):两条边从同一个锚点出发(或汇入同一个锚点)、
78
+ // 随后分开 ⇒ 这是标准树状干线画法,读者能分清方向;只有"互不相关的两条线走成一条"才是缺陷。
79
+ const near = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]) <= 1
80
+ const endsA = [la.pts[0], la.pts[la.pts.length - 1]]
81
+ const endsB = [lb.pts[0], lb.pts[lb.pts.length - 1]]
82
+ if (endsA.some((x) => endsB.some((y) => near(x, y)))) continue
83
+ let hit = false
84
+ for (const sa of segsOf(la)) {
85
+ for (const sb of segsOf(lb)) {
86
+ const ah = Math.abs(sa.p[1] - sa.q[1]) < 0.5; const bh = Math.abs(sb.p[1] - sb.q[1]) < 0.5
87
+ if (ah && bh && Math.abs(sa.p[1] - sb.p[1]) < 0.5 && overlap1D(sa.p[0], sa.q[0], sb.p[0], sb.q[0]) > 2) hit = true
88
+ const av = Math.abs(sa.p[0] - sa.q[0]) < 0.5; const bv = Math.abs(sb.p[0] - sb.q[0]) < 0.5
89
+ if (av && bv && Math.abs(sa.p[0] - sb.p[0]) < 0.5 && overlap1D(sa.p[1], sa.q[1], sb.p[1], sb.q[1]) > 2) hit = true
90
+ }
91
+ }
92
+ if (hit) out.push({ code: 'line-collinear-overlap', message: `${la.id} 与 ${lb.id} 存在**共线重叠**的线段(两条线走在同一条直线上且部分重合)⇒ 读者分不清哪条是哪条;请错开走廊,或改成"共用一个点后分开"的**树状干线**(只共点、不共线,是标准画法)` })
93
+ }
94
+ }
95
+
96
+ // ── ④ 线压在盒子边框上(图 4 那类缺陷的机械判据)────────────────────────
97
+ // 盒子:**两种字段形状都要认**——DSL 原文 bounds=[x,y,w,h],preview/layout 快照 bounds={x,y,w,h}
98
+ //(实测踩过:只认数组时,压框判据在快照路径上一律不触发)。
99
+ const rectOf = (b) => Array.isArray(b) ? { x: b[0], y: b[1], w: b[2], h: b[3] }
100
+ : (b && typeof b === 'object' && typeof b.x === 'number' ? { x: b.x, y: b.y, w: b.w, h: b.h } : null)
101
+ const boxes = (els ?? []).filter((e) => e && rectOf(e.bounds)
102
+ && (e.elementType === 'shape' || e.elementType === 'image' || e.elementType === 'table' || (e.kind && e.kind !== 'line')))
103
+ .map((e) => ({ id: e.elementId ?? e.id ?? '?', b: rectOf(e.bounds), el: e }))
104
+ for (const l of lines) {
105
+ if (l.deliberate) continue
106
+ let hitId = null
107
+ for (const s of segsOf(l)) {
108
+ for (const { id: bid, b } of boxes) {
109
+ const edges = [[b.x, b.y, b.x + b.w, b.y], [b.x, b.y + b.h, b.x + b.w, b.y + b.h], [b.x, b.y, b.x, b.y + b.h], [b.x + b.w, b.y, b.x + b.w, b.y + b.h]]
110
+ for (const [x1, y1, x2, y2] of edges) {
111
+ const eh = Math.abs(y1 - y2) < 0.5; const sh = Math.abs(s.p[1] - s.q[1]) < 0.5
112
+ if (eh && sh && Math.abs(s.p[1] - y1) <= 1.5 && overlap1D(s.p[0], s.q[0], x1, x2) > 8) hitId = bid
113
+ const ev = Math.abs(x1 - x2) < 0.5; const sv = Math.abs(s.p[0] - s.q[0]) < 0.5
114
+ if (ev && sv && Math.abs(s.p[0] - x1) <= 1.5 && overlap1D(s.p[1], s.q[1], y1, y2) > 8) hitId = bid
115
+ }
116
+ }
117
+ }
118
+ if (hitId) out.push({ code: 'line-on-box-edge', message: `${l.id} 有一段**压在 "${hitId}" 的边框上**(线与框重合 ⇒ 分不清线与边界)—— 把这段挪开 ≥8px,或让该盒子这条边不可见` })
119
+ }
120
+
121
+ // ── ⑤ 箭头端与目标图形的接合(R3:**只警告** + 可声明豁免)──────────────
122
+ // 用户明确要求:① "箭头插进图形里"与② "端点离目标边还差一截"都可能是**意料之内的设计**
123
+ // ⇒ 必须是 warning(绝不 error),且该元素加 `roleReason`(或 `role: decoration`)即视为刻意如此、不再提示。
124
+ // 带 attach 的线跳过(引擎已保证接在真实轮廓上);斜边图形用 outlineAnchor 算真实轮廓。
125
+ const GAP_MIN = 2 // 贴得比这更近算"接上了",不算缺口
126
+ const GAP_MAX = 10 // 超过这距离就不是"想接没接上",而是另一回事
127
+ // 只看**内容图形**:`role: decoration`(背景板/容器/分区底色)不算"图形"——
128
+ // 连线进入背景板是正常画法(暖色架构图的支线就是伸进内核底板的,实测假阳性)。
129
+ const shapeBoxes = (els ?? []).filter((e) => e && rectOf(e.bounds) && e.role !== 'decoration'
130
+ && (e.elementType === 'shape' || e.kind === 'shape'))
131
+ for (const el of (els ?? [])) {
132
+ if (!el || el.attach || el.role === 'decoration' || el.roleReason) continue
133
+ const isLine = el.elementType === 'line' || el.kind === 'line'
134
+ const pts = isLine && Array.isArray(el.points) ? el.points : null
135
+ if (!pts || pts.length < 2) continue
136
+ const arrow = el.arrow === true || el.arrow === 'end' || el.arrow === 'both' || el.arrowEnd === true
137
+ if (!arrow) continue
138
+ const id = el.elementId ?? el.id ?? '?'
139
+ const tip = pts[pts.length - 1]
140
+ const prev = pts[pts.length - 2]
141
+ const dx = tip[0] - prev[0]
142
+ const dy = tip[1] - prev[1]
143
+ for (const sb of shapeBoxes) {
144
+ const b = rectOf(sb.bounds)
145
+ if (tip[0] < b.x - GAP_MAX - 2 || tip[0] > b.x + b.w + GAP_MAX + 2
146
+ || tip[1] < b.y - GAP_MAX - 2 || tip[1] > b.y + b.h + GAP_MAX + 2) continue
147
+ const sid = sb.elementId ?? sb.id ?? '?'
148
+ const L = (yy) => (outlineAnchor(sb, 'left', [0, yy]) ?? [b.x, yy])[0]
149
+ const R = (yy) => (outlineAnchor(sb, 'right', [0, yy]) ?? [b.x + b.w, yy])[0]
150
+ if (tip[1] > b.y + 1 && tip[1] < b.y + b.h - 1 && tip[0] > L(tip[1]) + 1 && tip[0] < R(tip[1]) - 1) {
151
+ out.push({ code: 'arrow-tip-inside-shape', message: `${id} 的箭头端点落在 "${sid}" **内部**(插进图形里)—— 接在图形边缘更清楚;若确实要插进去(强调/贯穿),给这条线加 \`roleReason\` 声明即可不再提示` })
152
+ continue
153
+ }
154
+ const cx = b.x + b.w / 2
155
+ const cy = b.y + b.h / 2
156
+ if ((cx - tip[0]) * dx + (cy - tip[1]) * dy <= 0) continue // 不是朝这个图形去的 ⇒ 不管
157
+ // 缺口要按**真实轮廓**算:斜边图形在端点所在 y 上的边界是 L(y)/R(y),
158
+ // 用包围盒算会在"端点落在包围盒内、但在斜边外"时恒得 0(实测踩过)。
159
+ const cyC = Math.min(Math.max(tip[1], b.y), b.y + b.h)
160
+ const hGap = Math.max(L(cyC) - tip[0], tip[0] - R(cyC), 0)
161
+ const inBand = tip[1] >= b.y - 0.5 && tip[1] <= b.y + b.h + 0.5
162
+ const vGap = inBand ? 0 : Math.min(Math.abs(tip[1] - b.y), Math.abs(tip[1] - (b.y + b.h)))
163
+ const gap = Math.hypot(hGap, vGap)
164
+ if (gap >= GAP_MIN && gap <= GAP_MAX) {
165
+ out.push({ code: 'arrow-end-gap', message: `${id} 的箭头端点离 "${sid}" 还有约 ${gap.toFixed(0)}px(看起来**没接上**)—— 用 \`attach: {ref: ${sid}, side: …}\` 让引擎贴边;若刻意留空,加 \`roleReason\` 声明` })
166
+ break
167
+ }
168
+ }
169
+ }
170
+
171
+ // ── ⑥ 线×线"真交叉"(+ 型:两条都穿过对方)────────────────────────────
172
+ // 用户要求(2026-09-30):门禁不是判死刑,而是**把问题定位到可修的程度**,让助手定向返工、
173
+ // 迭代到终态绿。所以这里必须给出**可执行信息**:哪两条线、在哪交叉、怎么改。
174
+ // 与 R9 一致:T 形结点、共线重叠、共用端点都**不算**交叉(那些是标准画法;共线重叠另有判据)。
175
+ // 刻意交叉(示意汇流/流程交叉点)加 roleReason 声明即静默。
176
+ {
177
+ const segs = []
178
+ for (const l of lines) for (let i = 0; i + 1 < l.pts.length; i++) segs.push({ id: l.id, a: l.pts[i], b: l.pts[i + 1] })
179
+ const seen = new Set()
180
+ // **尊重声明**:任一条被声明为结构性(role: decoration 或 roleReason)就不参与交叉判定
181
+ // ——其他判据都有这一步,这里第一版漏了(实测:泳道分隔线被跨道连线穿过后误报)。
182
+ const deliberateIds = new Set(lines.filter((l) => l.deliberate).map((l) => l.id))
183
+ for (let i = 0; i < segs.length; i++) {
184
+ for (let j = i + 1; j < segs.length; j++) {
185
+ const s = segs[i]; const t = segs[j]
186
+ if (s.id === t.id) continue
187
+ if (deliberateIds.has(s.id) || deliberateIds.has(t.id)) continue
188
+ const key = s.id < t.id ? `${s.id}|${t.id}` : `${t.id}|${s.id}`
189
+ if (seen.has(key)) continue
190
+ const sh = Math.abs(s.a[1] - s.b[1]) < 0.5
191
+ const th = Math.abs(t.a[1] - t.b[1]) < 0.5
192
+ if (sh === th) continue
193
+ const h = sh ? s : t; const v = sh ? t : s
194
+ const hx0 = Math.min(h.a[0], h.b[0]); const hx1 = Math.max(h.a[0], h.b[0])
195
+ const vy0 = Math.min(v.a[1], v.b[1]); const vy1 = Math.max(v.a[1], v.b[1])
196
+ if (!(v.a[0] > hx0 + 1 && v.a[0] < hx1 - 1 && h.a[1] > vy0 + 1 && h.a[1] < vy1 - 1)) continue
197
+ seen.add(key)
198
+ out.push({ code: 'line-crossing', message: `${s.id} 与 ${t.id} 在 (${Math.round(v.a[0])}, ${Math.round(h.a[1])}) **交叉**(+ 型:两条都穿过对方)—— 给其中一条换车道/换进出边(或调换路由顺序)后重跑本门禁;若刻意交叉示意汇流,给该线加 roleReason 声明即静默` })
199
+ }
200
+ }
201
+ }
202
+
203
+ // ── ⑦ 折线折返尖刺(line-spike)────────────────────────────────────────
204
+ // 连续三点共线且中间点折返(越过又折回)⇒ 画出来是一根"戳出去"的多余线。
205
+ // 用户实测图 10 顶端那根线就是这个(已在 normalizePage 末尾兜底修掉)⇒ 本条是**回归护栏**:
206
+ // 以后任何路径再引入折返,门禁会直接点名"哪条线、哪个点"。
207
+ for (const l of lines) {
208
+ if (l.deliberate) continue
209
+ const p2 = l.pts
210
+ let bad = 0
211
+ for (let i = 1; i + 1 < p2.length; i++) {
212
+ const a1 = p2[i - 1]
213
+ const b1 = p2[i]
214
+ const c1 = p2[i + 1]
215
+ const sameX = Math.abs(a1[0] - b1[0]) < 0.5 && Math.abs(b1[0] - c1[0]) < 0.5
216
+ const sameY = Math.abs(a1[1] - b1[1]) < 0.5 && Math.abs(b1[1] - c1[1]) < 0.5
217
+ if ((sameX && (b1[1] - a1[1]) * (c1[1] - b1[1]) < 0) || (sameY && (b1[0] - a1[0]) * (c1[0] - b1[0]) < 0)) { bad = i + 1; break }
218
+ }
219
+ if (bad) out.push({ code: 'line-spike', message: `${l.id} 在第 ${bad} 个点处**折返**(共线又折回 ⇒ 会画出一根戳出去的多余线)—— 删掉该多余点即可;若确为刻意的往返重叠,给该线加 roleReason 声明` })
220
+ }
221
+
222
+ // ── ⑧ 无箭头连线的端点"差一点没接上"(line-end-off-edge)────────────────
223
+ // 与 `arrow-end-gap` 分工:**带箭头**的由那条管;这里只管**不带箭头**的折线端点——
224
+ // 它在离某个图形轮廓 2–12px 处停住、且方向朝着该图形 ⇒ 观感上"没接上"(用户实测过同类)。
225
+ // 只警告、可声明;带 attach 的线跳过(引擎已保证贴边)。
226
+ for (const el of (els ?? [])) {
227
+ if (!el || el.attach || el.role === 'decoration' || el.roleReason) continue
228
+ const isLine = el.elementType === 'line' || el.kind === 'line'
229
+ const pts2 = isLine && Array.isArray(el.points) ? el.points : null
230
+ if (!pts2 || pts2.length < 2) continue
231
+ const arrow = el.arrow === true || el.arrow === 'end' || el.arrow === 'both' || el.arrowEnd === true
232
+ if (arrow) continue
233
+ const id = el.elementId ?? el.id ?? '?'
234
+ for (const [tip, prev] of [[pts2[pts2.length - 1], pts2[pts2.length - 2]], [pts2[0], pts2[1]]]) {
235
+ const dx = tip[0] - prev[0]
236
+ const dy = tip[1] - prev[1]
237
+ for (const sb of boxes) {
238
+ if (sb.id === id) continue
239
+ const b = sb.b
240
+ const cx = b.x + b.w / 2
241
+ const cy = b.y + b.h / 2
242
+ if ((cx - tip[0]) * dx + (cy - tip[1]) * dy <= 0) continue
243
+ const cyC = Math.min(Math.max(tip[1], b.y), b.y + b.h)
244
+ const lx = (outlineAnchor(sb.el, 'left', [0, cyC]) ?? [b.x, cyC])[0]
245
+ const rx = (outlineAnchor(sb.el, 'right', [0, cyC]) ?? [b.x + b.w, cyC])[0]
246
+ const hGap = Math.max(lx - tip[0], tip[0] - rx, 0)
247
+ const inBand = tip[1] >= b.y - 0.5 && tip[1] <= b.y + b.h + 0.5
248
+ const vGap = inBand ? 0 : Math.min(Math.abs(tip[1] - b.y), Math.abs(tip[1] - (b.y + b.h)))
249
+ const gap = Math.hypot(hGap, vGap)
250
+ if (gap >= 2 && gap <= 12) {
251
+ out.push({ code: 'line-end-off-edge', message: `${id} 的端点离 "${sb.id}" 还有约 ${gap.toFixed(0)}px(**没接上**)—— 用 attach 让它贴边,或给它加箭头改成 arrow-end-gap 那条判据;若刻意留空,加 roleReason 声明` })
252
+ break
253
+ }
254
+ }
255
+ }
256
+ }
257
+
258
+ return out
259
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * 线 × 文字碰撞检查(G1 补齐的真缺口)
3
+ *
4
+ * 背景:`line-rules.js` 已有 line×line(line-crossing / line-collinear-overlap)、
5
+ * line×边框(line-on-box-edge)、箭头相关规则,但**没有 line×text** ——
6
+ * 红样本实测:竖线穿过文字标签时门禁 0 错、且只报 4 条无关警告。
7
+ * 这是用户实际反馈过的问题(图 09 生命线穿过"残饵画面"标签)。
8
+ *
9
+ * 判定:线段落在**文字矩形内部**的长度 ≥ 2px 即命中(仅相切不算)。
10
+ * 级别:警告(由调用方决定 severity,与 line-crossing 一致)。
11
+ * 豁免:与 line-rules 其余规则一致 —— `role: decoration` 或 `roleReason` 声明"刻意穿过"。
12
+ */
13
+ const r1 = (n) => Math.round(n * 10) / 10
14
+
15
+ /** 把 bounds 统一成 {x,y,w,h}(DSL 用数组,快照用对象) */
16
+ function rectOf(el) {
17
+ const b = el?.bounds
18
+ if (Array.isArray(b)) return { x: b[0], y: b[1], w: b[2], h: b[3] }
19
+ if (b && typeof b === 'object') return { x: b.x, y: b.y, w: b.w, h: b.h }
20
+ return null
21
+ }
22
+
23
+ /**
24
+ * 线段 p→q 落在矩形内部的长度(Liang-Barsky 裁剪)。0 表示未穿过内部。
25
+ */
26
+ export function segmentInsideRect(p, q, r) {
27
+ const dx = q[0] - p[0]
28
+ const dy = q[1] - p[1]
29
+ const cx = r.x + r.w / 2
30
+ const cy = r.y + r.h / 2
31
+ const hw = r.w / 2
32
+ const hh = r.h / 2
33
+ const ax = p[0] - cx
34
+ const ay = p[1] - cy
35
+ let t0 = 0
36
+ let t1 = 1
37
+ const clip = (pv, qv) => {
38
+ if (Math.abs(pv) < 1e-9) return qv >= 0
39
+ const t = qv / pv
40
+ if (pv < 0) {
41
+ if (t > t1) return false
42
+ if (t > t0) t0 = t
43
+ } else {
44
+ if (t < t0) return false
45
+ if (t < t1) t1 = t
46
+ }
47
+ return true
48
+ }
49
+ if (!clip(-dx, ax + hw)) return 0
50
+ if (!clip(dx, hw - ax)) return 0
51
+ if (!clip(-dy, ay + hh)) return 0
52
+ if (!clip(dy, hh - ay)) return 0
53
+ return Math.hypot(dx, dy) * (t1 - t0)
54
+ }
55
+
56
+ /**
57
+ * @param {Array} els 同一页的元素(DSL 或快照形态均可)
58
+ * @returns {Array<{code:string,id:string,message:string}>} 警告级条目
59
+ */
60
+ export function checkLineText(els) {
61
+ const out = []
62
+ const list = els ?? []
63
+ const texts = list.filter((e) => (e?.elementType === 'text' || e?.kind === 'text') && rectOf(e))
64
+ const lines = list.filter((e) => (e?.elementType === 'line' || e?.kind === 'line') && Array.isArray(e?.points) && e.points.length >= 2)
65
+ if (!texts.length || !lines.length) return out
66
+ for (const l of lines) {
67
+ if (l.role === 'decoration' || l.roleReason) continue // 声明"刻意穿过"⇒ 不提示
68
+ const lid = l.elementId ?? l.id ?? '?'
69
+ const seen = new Set()
70
+ for (const t of texts) {
71
+ const tid = t.elementId ?? t.id ?? '?'
72
+ if (seen.has(tid)) continue
73
+ const r = rectOf(t)
74
+ let hit = 0
75
+ for (let i = 0; i + 1 < l.points.length; i++) {
76
+ const len = segmentInsideRect(l.points[i], l.points[i + 1], r)
77
+ if (len > hit) hit = len
78
+ }
79
+ if (hit >= 2) {
80
+ seen.add(tid)
81
+ out.push({
82
+ code: 'line-cross-text',
83
+ id: lid,
84
+ message: `${lid} 穿过文字 "${tid}" 约 ${r1(hit)}px(压字)——把标签挪开,或给标签加**白底芯片**遮罩;若刻意穿过,给该线加 roleReason 声明`,
85
+ })
86
+ }
87
+ }
88
+ }
89
+ return out
90
+ }