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
@@ -10,12 +10,16 @@
10
10
  * - 1px = 1pt;EMU = pt × 12700。
11
11
  * - 讲稿(2026-09-14 新增):页面 `notes:` 文本 → 标准 pptx 备注页(notesSlide + notesMaster)。
12
12
  * 无 `notes:` 的工程**不产出任何备注部件**(产物与旧实现一致 ⇒ 老工程零变化);parity 里 notesExp/notesOut 自证。
13
+ * - 折线/箭头两端/虚线(2026-09-27 阶段 A):`line.points` 支持 N 点(>2 点走 `a:custGeom` **开放路径**);
14
+ * `arrow: true | 'end' | 'both'`;`line.dash: solid | dash | dot | dashDot`。
15
+ * **只用 2 点 + `arrow: true` + 无 dash 的工程产物逐字节不变**(仍旧走 straightConnector1 那条码路)。
13
16
  */
14
17
  import { writeFile, readFile } from 'node:fs/promises'
15
18
  import { join, isAbsolute } from 'node:path'
16
19
  import { createHash } from 'node:crypto'
17
20
  import { zipWrite } from '../zips.js'
18
21
  import { normalizePage } from './layout.js'
22
+ import { onSide, rectOf, onAttachSide } from './relations.js'
19
23
  import { chartData, chartColors, chartIssue, resolveChart } from './svgCharts.js'
20
24
  import { auditNativeCharts, chartFrameXml, chartPartNames, embeddedWorkbook, nativeChartRels, nativeChartXml } from './nativeChart.js'
21
25
 
@@ -37,6 +41,22 @@ export async function exportPptx(ctx, { out = 'out.pptx', engine = 'pptd' } = {}
37
41
  // straightConnector1 在 OOXML 里**只按包围盒的左上→右下**绘制,方向必须靠 flipH/flipV 表达;
38
42
  // 漏写 → 反向斜率/镜像(cross1×cross2 会重合成一条线)。这里逐条从写出的 XML 反推端点自证。
39
43
  const lineProof = { exp: 0, out: 0, wrong: [] }
44
+ // 附着线自证(阶段 A-④/⑤):声明 `attach` 的线,**导出侧从实际 points 独立复核**端点落在被引用元素的对应边上。
45
+ // 不信归一化层——那正是"解析了 attach 但 side/ref 用错"会漏掉的地方。
46
+ const attachProof = { exp: 0, out: 0, wrong: [] }
47
+ // 折线(>2 点)自证(2026-09-27 阶段 A):custGeom 开放路径的每条边都要从**写出的 XML**反推回页面坐标
48
+ // 与源 points 一致(否则"预览对、成品折错/镜像"这类事故会静默通过——与 lineProof 同一条纪律)。
49
+ // 另:arrowHeadExp/arrowHeadsOut = 声明 `both` 的**头端**数 vs 产物 XML 里的 `<a:headEnd` 计数。
50
+ const polyProof = { exp: 0, out: 0, wrong: [] }
51
+ const arrowProof = { headExp: 0, headOut: 0 }
52
+ const fieldWarnings = new Set()
53
+ const warnField = (id, msg) => {
54
+ const line = `元素 "${id}": ${msg}`
55
+ if (!fieldWarnings.has(line)) {
56
+ fieldWarnings.add(line)
57
+ report.warnings.push(line)
58
+ }
59
+ }
40
60
  UID = 1000
41
61
 
42
62
  // 媒体部件命名(2026-09-26 修复"同名静默错图")——**整册共享**(不能按页各算一套,否则同一 srcPath
@@ -91,11 +111,45 @@ export async function exportPptx(ctx, { out = 'out.pptx', engine = 'pptd' } = {}
91
111
  case 'text': shapes.push(textSp(el, report, ctx.minFontSize)); break
92
112
  case 'shape': shapes.push(shapeSp(el)); break
93
113
  case 'line': {
94
- lineProof.exp++
95
- shapes.push(connectorSp(el, lineProof))
114
+ // attach 解析接入点(relations.resolveAttach):声明了 `attach` 但还没算出 `points` 的线,
115
+ // 在归一化阶段由 relations.js 解析成 `points`(`attach` 优先于手写 points,并另行提示)。
116
+ // 导出侧**只认 points**——这里不做任何 attach 几何推导(保持导出器是纯写盘层)。
117
+ if (!el.points && el.attach) report.warnings.push(`元素 "${el.id}": 声明了 attach 但归一化层未解析出 points(请检查 relations.resolveAttach 是否已接入 normalizePage)`)
118
+ const isPoly = Array.isArray(el.points) && el.points.length > 2
119
+ // 退化点(points 不足 2 点):schema 是第一道闸;漏到导出层 ⇒ **明确拒绝**(警告 + 不产坏 XML),
120
+ // 绝不静默写出一条 undefined 坐标的线(NaN/undefined 进 OOXML = PowerPoint 报修复)。
121
+ if (!isPoly && (!Array.isArray(el.points) || el.points.length < 2)) {
122
+ report.warnings.push(`元素 "${el.id}": points 不足 2 点(${JSON.stringify(el.points)})⇒ 跳过导出该项(schema 应已拒收;请检查校验是否被绕过)`)
123
+ break
124
+ }
125
+ if (isPoly) {
126
+ polyProof.exp++
127
+ shapes.push(polylineSp(el, polyProof, arrowProof, warnField))
128
+ } else {
129
+ lineProof.exp++
130
+ shapes.push(connectorSp(el, lineProof, arrowProof, warnField))
131
+ }
132
+ // 阶段 A-⑤:附着线单独计数并**在导出侧独立复核**(不信归一化层的结果)
133
+ // 判据:声明了 attach 的每个端点,其**实际 points 端点**必须落在被引用元素的对应边上。
134
+ // 这能抓到"归一化解析了 attach 但 side/ref 用错"这类只有几何能证伪的错误。
135
+ if (el.attach) {
136
+ attachProof.exp++
137
+ const ends = [[el.points?.[0], el.attach.from], [el.points?.[el.points.length - 1], el.attach.to]]
138
+ let ok = true
139
+ for (const [pt, spec] of ends) {
140
+ if (!spec) continue
141
+ const ref = els.find((e) => e.id === String(spec.ref))
142
+ if (!ref || !Array.isArray(pt)) { ok = false; attachProof.wrong.push(`${el.id}: attach.${String(spec.side)} 引用 "${spec.ref}" 找不到或端点缺失`); break }
143
+ if (!onAttachSide(ref, String(spec.side), pt, 1.5)) {
144
+ ok = false
145
+ attachProof.wrong.push(`${el.id}: 端点 [${Math.round(pt[0])}, ${Math.round(pt[1])}] 不在 "${spec.ref}" 的 ${spec.side} 边(tol=1.5)`)
146
+ break
147
+ }
148
+ }
149
+ if (ok) attachProof.out++
150
+ }
96
151
  break
97
- }
98
- case 'image': {
152
+ } case 'image': {
99
153
  counts.image++
100
154
  const rId = addMedia(el.src)
101
155
  shapes.push(picSp(el, rId))
@@ -252,6 +306,15 @@ export async function exportPptx(ctx, { out = 'out.pptx', engine = 'pptd' } = {}
252
306
  const notesParts = Object.keys(files).filter((k) => /^ppt\/notesSlides\/notesSlide\d+\.xml$/.test(k))
253
307
  const notesOut = notesParts.length
254
308
  const notesRelOk = slides.filter((s) => s.notes).every((s) => String(files[`ppt/slides/_rels/slide${slides.indexOf(s) + 1}.xml.rels`]).includes(`notesSlide${s.notesNo}.xml`))
309
+ // 多端箭头(阶段 A §7):声明 `arrow: both` 的**头端**数(每条 both 线应写 1 个 headEnd)== 产物里的 `<a:headEnd` 计数。
310
+ // 从产物反推(不是数中间变量)——"生成器以为写了、写盘路径把它丢了"这一类只有回读才抓得到。
311
+ let arrowHeadsOut = 0
312
+ for (let i = 0; i < slides.length; i++) {
313
+ const raw = files[`ppt/slides/slide${i + 1}.xml`]
314
+ const txt = typeof raw === 'string' ? raw : String(raw ?? '')
315
+ arrowHeadsOut += (txt.match(/<a:headEnd\b/g) ?? []).length
316
+ }
317
+ const linesWrongAll = [...lineProof.wrong, ...polyProof.wrong]
255
318
  // 媒体 parity(2026-09-26 新增):**不同 srcPath 数 == 包内媒体部件数**。
256
319
  // 为什么必须单独加一条:图数量 parity(imagesExp/imagesOut)只数 `<p:pic>`,两张不同的图塌成同一个部件时
257
320
  // 它依然是 2/2、ok=true(实测)。而"两张不同的图被写成同一个部件"= 成品里静默用错图,正是本插件最不能出的错。
@@ -324,6 +387,11 @@ export async function exportPptx(ctx, { out = 'out.pptx', engine = 'pptd' } = {}
324
387
  report.parity = {
325
388
  tablesExp, tablesOut, imagesExp, imagesOut, illegalFrames: illegal,
326
389
  linesExp: lineProof.exp, linesOut: lineProof.out, linesWrong: lineProof.wrong.length,
390
+ // 折线(>2 点)与多端箭头(阶段 A 新增;无此类元素的工程全部为 0,既有断言不受影响)
391
+ polyLinesExp: polyProof.exp, polyLinesOut: polyProof.out, polyLinesWrong: polyProof.wrong.length,
392
+ // 附着线(阶段 A-④/⑤:attach 驱动坐标,导出侧独立复核端点落在被引用元素的对应边上)
393
+ attachedLinesExp: attachProof.exp, attachedLinesOut: attachProof.out, attachedLinesWrong: [...attachProof.wrong],
394
+ arrowEndsExp: arrowProof.headExp, arrowEndsOut: arrowHeadsOut,
327
395
  notesExp, notesOut,
328
396
  mediaExp: mediaExpSet.size, mediaOut,
329
397
  textExp, textMissing, chartShapesExp, chartShapesOut,
@@ -333,6 +401,9 @@ export async function exportPptx(ctx, { out = 'out.pptx', engine = 'pptd' } = {}
333
401
  chartDataMismatch: chartAudit.dataMismatch, chartRelsMissing: [...chartAudit.relsMissing, ...chartRelMissing],
334
402
  ok: tablesExp === tablesOut && imagesExp === imagesOut && illegal === 0 &&
335
403
  lineProof.exp === lineProof.out && lineProof.wrong.length === 0 &&
404
+ polyProof.exp === polyProof.out && polyProof.wrong.length === 0 &&
405
+ attachProof.exp === attachProof.out && attachProof.wrong.length === 0 &&
406
+ arrowProof.headExp === arrowHeadsOut && linesWrongAll.length === 0 &&
336
407
  notesExp === notesOut && notesRelOk && mediaExpSet.size === mediaOut &&
337
408
  textMissing.length === 0 && chartShapesExp === chartShapesOut &&
338
409
  chartFramesMissing.length === 0 && chartAudit.ok && chartRelMissing.length === 0,
@@ -366,13 +437,189 @@ function xfrm(x, y, w, h, rot = 0, flips = '') {
366
437
  * 描边属性。`arrow` 的 `<a:tailEnd>` **必须写在 `<a:ln>` 内部**(2026-09-14 修复)——
367
438
  * 旧实现把它拼在 `</a:ln>` 之后,PowerPoint 直接忽略 ⇒ 箭头全部消失(预览有、成品没有)。
368
439
  * OOXML 子元素顺序:fill → prstDash → join → headEnd → tailEnd。
440
+ * `arrow` 取值(阶段 A):`true|'end'` ⇒ 只写 tailEnd(既有语义,产物不变);`'both'` ⇒ headEnd + tailEnd;
441
+ * `line.dash`(阶段 A):solid(缺省,**不写元素**)| dash | dot | dashDot ⇒ `<a:prstDash val="…">`。
369
442
  */
370
443
  function lineSpPr(line, arrow = false) {
371
444
  const w = Math.round((line?.width ?? 1) * EMU)
372
- const tail = arrow ? '<a:tailEnd type="triangle" w="med" len="med"/>' : ''
373
- return '<a:ln w="' + w + '"><a:solidFill><a:srgbClr val="' + hex(line?.color ?? '#000000') + '"/></a:solidFill>' + tail + '</a:ln>'
445
+ const a = arrowState(arrow)
446
+ const dash = dashVal(line)
447
+ const dashXml = dash ? '<a:prstDash val="' + dash + '"/>' : ''
448
+ const head = a === 'both' ? '<a:headEnd type="triangle" w="med" len="med"/>' : ''
449
+ const tail = a !== 'none' ? '<a:tailEnd type="triangle" w="med" len="med"/>' : ''
450
+ return '<a:ln w="' + w + '"><a:solidFill><a:srgbClr val="' + hex(line?.color ?? '#000000') + '"/></a:solidFill>' + dashXml + head + tail + '</a:ln>'
451
+ }
452
+
453
+ // ── 阶段 A:折线 / 箭头两端 / 虚线(2026-09-27)─────────────────────────────
454
+ /** 箭头端语义白名单:true ≡ 'end'(向后兼容,产物不变)|'both' 两端|false/undefined 无箭头。 */
455
+ const ARROW_STATES = ['none', 'end', 'both']
456
+
457
+ /**
458
+ * `arrow` 归一化:`true` 与 `'end'` 同义(既有产物不变);`'both'` = 两端。
459
+ * 非法值(如 `arrow: 'bogus'`):schema 层负责拒收;万一漏到导出层 ⇒ 明确返回 'none'(**绝不静默产出坏 XML**),
460
+ * 且由调用方 warnField 把这次降级写进 report.warnings(不静默)。
461
+ */
462
+ function arrowState(arrow) {
463
+ if (arrow === true || arrow === 'end') return 'end'
464
+ if (arrow === 'both') return 'both'
465
+ return 'none'
466
+ }
467
+
468
+ /** 虚线映射。OOXML ST_PresetLineDashVal 合法值:solid/dot/dash/dashDot/...(我们只放行白名单四种)。 */
469
+ const DASH_VALUES = { solid: 'solid', dash: 'dash', dot: 'dot', dashDot: 'dashDot' }
470
+
471
+ /**
472
+ * `line.dash` → prstDash 值。
473
+ * 缺省/`solid` ⇒ 返回 null ⇒ **完全不写 `<a:prstDash>`**(既有产物逐字节不变的关键)。
474
+ * `dashDot` 拼写严格(OOXML 是 dashDot 不是 dashdot);非法值 ⇒ null + 由调用方 warnField 报出降级。
475
+ */
476
+ function dashVal(line) {
477
+ const d = line?.dash
478
+ if (d === undefined || d === null || d === 'solid') return null
479
+ return DASH_VALUES[d] ?? null
480
+ }
481
+
482
+ /** 非法字段值告警(schema 是第一道闸;导出层是第二道 —— 两道都不许静默)。 */
483
+ function validateLineFields(el, warn) {
484
+ const raw = el?.arrow
485
+ const okArrow = raw === undefined || raw === null || typeof raw === 'boolean' || ARROW_STATES.includes(raw)
486
+ if (!okArrow && warn) warn(el.id, `arrow 取值非法(${JSON.stringify(raw)})⇒ 按"无箭头"导出;合法值:true | 'end' | 'both'`)
487
+ const d = el?.line?.dash
488
+ const okDash = d === undefined || d === null || Object.prototype.hasOwnProperty.call(DASH_VALUES, d)
489
+ if (!okDash && warn) warn(el.id, `line.dash 取值非法(${JSON.stringify(d)})⇒ 按 solid 导出;合法值:solid | dash | dot | dashDot`)
490
+ }
491
+
492
+ /**
493
+ * N 点折线(>2 点)→ `a:custGeom` **开放路径**(moveTo + 若干 lnTo,**不 close**),描边、无填充,
494
+ * 箭头挂在这条路径自己的 `a:ln` 上(headEnd/tailEnd)。设计依据:docs/08 §6 —— 折线必须是**一条**可描边的
495
+ * 开放路径,否则两端箭头无从挂载(N 条独立直线只有首尾两条能挂,中间接缝还会露白)。
496
+ *
497
+ * 坐标:路径用抽象单位 0..100000,`a:path w/h` 与 `a:ext` 同比例 ⇒ DrawingML 线性映射回包围盒;
498
+ * 映射方向与 `a:ext` 空间一致(y 向下),不做翻转。导出后由 polylineEndsFromXml() 从写出的 XML **反推**验证。
499
+ *
500
+ * 为什么**不复用** custGeomXml():那条码路是 `shape.kind === 'custGeom'` 的产物(`fill` + `ln` 跟在几何之后),
501
+ * 形状语义允许填色;折线必须"无填充 + 描边",且改动既有 custGeom 写盘器会破坏"老工程产物逐字节不变"。
502
+ *
503
+ * 降级:若实测 PowerPoint 打开/渲染有问题,把 POLYLINE_MODE 改为 'segments' ⇒ N 条相邻重叠 1px 的直线
504
+ * (箭头只挂首尾两条;中段接缝靠 1px 重叠遮住)。降级必须记录理由并进 parity(polyLinesExp/Out 照数)。
505
+ */
506
+ const POLYLINE_MODE = 'openPath' // 'openPath' | 'segments'
507
+ const POLY_OVERLAP = 1 // 降级模式:相邻段端点**重叠** 1px(消接缝)
508
+
509
+ function openPathXml(points) {
510
+ const xs = points.map((p) => Number(p[0]))
511
+ const ys = points.map((p) => Number(p[1]))
512
+ const x = Math.min(...xs)
513
+ const y = Math.min(...ys)
514
+ // 退化(全同点/纯水平/纯垂直):至少 1px 包围盒,避免 w/h=0 的路径 Space 除零
515
+ const w = Math.max(1, Math.max(...xs) - x)
516
+ const h = Math.max(1, Math.max(...ys) - y)
517
+ const px = (v) => Math.round(((v - x) / w) * 100000)
518
+ const py = (v) => Math.round(((v - y) / h) * 100000)
519
+ const cmds = points.map((p, i) => `<a:${i === 0 ? 'moveTo' : 'lnTo'}><a:pt x="${px(p[0])}" y="${py(p[1])}"/></a:${i === 0 ? 'moveTo' : 'lnTo'}>`).join('')
520
+ return '<a:custGeom><a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/><a:rect l="l" t="t" r="r" b="b"/>'
521
+ + `<a:pathLst><a:path w="100000" h="100000" fill="none">${cmds}</a:path></a:pathLst></a:custGeom>`
374
522
  }
375
523
 
524
+ function polylineSp(el, proof, arrowProof, warnField) {
525
+ const id = nid()
526
+ const pts = el.points.map((p) => [Number(p[0]), Number(p[1])])
527
+ if (pts.length < 2 || pts.some((p) => !Number.isFinite(p[0]) || !Number.isFinite(p[1]))) {
528
+ if (warnField) warnField(el.id, `points 不足 2 点或含非有限数值(${JSON.stringify(el.points)})⇒ 按 1×1 退化点导出,不产坏 XML`)
529
+ pts.length = 0
530
+ pts.push([0, 0], [0, 0])
531
+ }
532
+ const arrow = arrowState(el.arrow)
533
+ validateLineFields(el, warnField)
534
+ const xs = pts.map((p) => p[0])
535
+ const ys = pts.map((p) => p[1])
536
+ const x = Math.min(...xs)
537
+ const y = Math.min(...ys)
538
+ const w = Math.max(...xs) - x
539
+ const h = Math.max(...ys) - y
540
+ // 箭头端计数(parity 用):每条 'both' 线声明 1 个头端;'end'/true 只声明尾端。
541
+ if (arrow === 'both') arrowProof.headExp++
542
+
543
+ if (POLYLINE_MODE === 'segments') {
544
+ // 降级形态:N 条直线(straightConnector1),相邻端点重叠 POLY_OVERLAP px 去缝。
545
+ // 箭头挂首段的 headEnd / 末段的 tailEnd ⇒ 仍满足"两端箭头"。
546
+ const segs = []
547
+ for (let i = 0; i + 1 < pts.length; i++) {
548
+ const a = pts[i]
549
+ let b = pts[i + 1]
550
+ if (i + 1 < pts.length - 1) {
551
+ const dx = b[0] - a[0]
552
+ const dy = b[1] - a[1]
553
+ const len = Math.hypot(dx, dy) || 1
554
+ b = [b[0] + (dx / len) * POLY_OVERLAP, b[1] + (dy / len) * POLY_OVERLAP]
555
+ }
556
+ const first = i === 0
557
+ const last = i === pts.length - 2
558
+ const segArrow = first && last ? arrow : first && arrow === 'both' ? 'end' : last ? arrow : 'none'
559
+ segs.push(connectorSp({
560
+ id: first ? el.id : `${el.id}~s${i}`,
561
+ points: [a, b],
562
+ line: el.line,
563
+ arrow: segArrow === 'both' ? false : segArrow,
564
+ }))
565
+ }
566
+ if (proof) {
567
+ proof.out++
568
+ // 降级形态的几何自证:段链首尾必须仍落在源 points 的首尾上(用直连两端核对)
569
+ const back = connectorEndsFromXml(segs[0])
570
+ const back2 = connectorEndsFromXml(segs[segs.length - 1])
571
+ const same = back !== null && back2 !== null &&
572
+ Math.abs(back[0][0] - pts[0][0]) < 0.01 && Math.abs(back[0][1] - pts[0][1]) < 0.01 &&
573
+ Math.abs(back2[1][0] - pts[pts.length - 1][0]) < 0.01 && Math.abs(back2[1][1] - pts[pts.length - 1][1]) < 0.01
574
+ if (!same) proof.wrong.push(`${el.id}: 降级 N 段直线的首段起点/末段终点与源 points 不一致(${JSON.stringify(pts)})`)
575
+ }
576
+ return segs.join('')
577
+ }
578
+
579
+ // 首选形态:单条开放路径(a:custGeom)
580
+ const xml = '<p:sp><p:nvSpPr><p:cNvPr id="' + id + '" name="' + xm(el.id) + '"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>'
581
+ + '<p:spPr>' + xfrm(x, y, w, h) + openPathXml(pts) + '<a:noFill/>' + lineSpPr(el.line, el.arrow) + '</p:spPr>'
582
+ + '<p:txBody><a:bodyPr/><a:lstStyle/><a:p/></p:txBody></p:sp>'
583
+ if (proof) {
584
+ proof.out++
585
+ const back = polylineEndsFromXml(xml, w, h)
586
+ const same = back !== null && back.length === pts.length && back.every((p, i) =>
587
+ Math.abs(p[0] - pts[i][0]) < 0.01 && Math.abs(p[1] - pts[i][1]) < 0.01)
588
+ if (!same) {
589
+ proof.wrong.push(`${el.id}: 源 points ${JSON.stringify(pts.map((p) => p.map((v) => Math.round(v * 100) / 100)))} → OOXML 开放路径反推 ${back === null ? '解析失败' : JSON.stringify(back.map((p) => p.map((v) => Math.round(v * 100) / 100)))}`)
590
+ }
591
+ }
592
+ return xml
593
+ }
594
+
595
+ /**
596
+ * 从折线 XML 反推页面坐标顶点(导出自证;也供测试复用)。
597
+ * 语义:路径单位 0..pathW/pathH 经 `a:path w/h` → `a:ext cx/cy` 线性映射回包围盒,与 `a:ext` 同向(不做翻转)。
598
+ */
599
+ export function polylineEndsFromXml(xml, w, h) {
600
+ const off = xml.match(/<a:off x="(-?\d+)" y="(-?\d+)"\/>/)
601
+ const ext = xml.match(/<a:ext cx="(\d+)" cy="(\d+)"\/>/)
602
+ const path = xml.match(/<a:path w="(\d+)" h="(\d+)"[^>]*>([\s\S]*?)<\/a:path>/)
603
+ if (!off || !ext || !path) return null
604
+ const x = Number(off[1]) / EMU
605
+ const y = Number(off[2]) / EMU
606
+ const cx = Number(ext[1]) / EMU
607
+ const cy = Number(ext[2]) / EMU
608
+ const pw = Number(path[1])
609
+ const ph = Number(path[2])
610
+ if (!(pw > 0) || !(ph > 0)) return null
611
+ // w/h 由调用方给出时用于把 pt 归一映射;缺省则用 a:ext(两者一致)
612
+ const outW = typeof w === 'number' ? w : cx
613
+ const outH = typeof h === 'number' ? h : cy
614
+ const pts = []
615
+ for (const m of path[3].matchAll(/<a:(moveTo|lnTo)><a:pt x="(-?\d+)" y="(-?\d+)"\/><\/a:(?:moveTo|lnTo)>/g)) {
616
+ if (m[1] === 'moveTo' && pts.length > 0) break
617
+ pts.push([x + (Number(m[2]) / pw) * outW, y + (Number(m[3]) / ph) * outH])
618
+ }
619
+ return pts.length ? pts : null
620
+ }
621
+
622
+
376
623
  // ── text ─────────────────────────────────────────────────────────────────
377
624
  const FIT_TOL = 1 // 与 verify 的 TOL 一致:verify 通过 ⇒ 导出不缩字(反馈 E2 ★★)
378
625
 
@@ -484,9 +731,13 @@ function srgbClrXml(color, alpha) {
484
731
  * p2 在 p1 左侧 → flipH;p2 在 p1 上方 → flipV。
485
732
  * 漏写会镜像斜率;两条交叉线(如 × 的两笔)还会重叠成一条 —— 预览对、PowerPoint 里不对的根源。
486
733
  * `tailEnd`(箭头)始终落在画出的"终点"= p2,所以定点翻转后箭头方向自动正确。
734
+ * 阶段 A:`arrow: 'both'` 时额外写 headEnd(落在**起点** p1 侧);字段值经 validateLineFields 复核(非法值降级 + 告警)。
735
+ * **2 点 + arrow: true + 无 dash 的产物与改动前逐字节一致**(dash 缺省不写元素、true ≡ 'end')——回归硬门槛。
487
736
  */
488
- function connectorSp(el, proof) {
737
+ function connectorSp(el, proof, arrowProof, warnField) {
489
738
  const id = nid()
739
+ validateLineFields(el, warnField)
740
+ if (arrowProof && arrowState(el.arrow) === 'both') arrowProof.headExp++
490
741
  const p1 = el.points[0]
491
742
  const p2 = el.points[1]
492
743
  const x1 = p1[0]
@@ -504,7 +755,7 @@ function connectorSp(el, proof) {
504
755
  const flips = (x2 < x1 ? ' flipH="1"' : '') + (y2 < y1 ? ' flipV="1"' : '')
505
756
  const xml = '<p:cxnSp><p:nvCxnSpPr><p:cNvPr id="' + id + '" name="' + xm(el.id) + '"/><p:cNvCxnSpPr/><p:nvPr/></p:nvCxnSpPr>'
506
757
  + '<p:spPr>' + xfrm(minX, minY, wdt, hgt, 0, flips) + '<a:prstGeom prst="straightConnector1"><a:avLst/></a:prstGeom>'
507
- + lineSpPr(el.line, el.arrow === true) + '</p:spPr></p:cxnSp>'
758
+ + lineSpPr(el.line, el.arrow === true || el.arrow === 'end' ? true : el.arrow === 'both' ? 'both' : false) + '</p:spPr></p:cxnSp>'
508
759
  if (proof !== undefined && proof !== null) {
509
760
  proof.out++
510
761
  // 从写出的 XML 反推线段两端(OOXML 语义:本地 (0,0)→(w,h) 经 flip 映射回页面),必须与源 points 一致。
@@ -513,9 +764,13 @@ function connectorSp(el, proof) {
513
764
  Math.abs(back[0][0] - x1) < 0.01 && Math.abs(back[1][0] - x2) < 0.01 &&
514
765
  Math.abs(back[0][1] - y1) < 0.01 && Math.abs(back[1][1] - y2) < 0.01
515
766
  if (!same) proof.wrong.push(`${el.id}: 源 [[${x1},${y1}],[${x2},${y2}]] → OOXML 还原 ${back === null ? '解析失败' : JSON.stringify(back.map((p) => p.map((v) => Math.round(v * 100) / 100)))}`)
516
- // 箭头必须在 <a:ln> 内部(写在外面 PowerPoint 直接忽略)
517
- if (el.arrow === true && !/<a:ln\b[^>]*>(?:(?!<\/a:ln>)[\s\S])*<a:tailEnd/.test(xml)) {
518
- proof.wrong.push(`${el.id}: arrow=true 但 <a:tailEnd> 不在 <a:ln> 内(PowerPoint 会忽略箭头)`)
767
+ // 箭头必须在 <a:ln> 内部(写在外面 PowerPoint 直接忽略);两端箭头必须两端都在。
768
+ const a = arrowState(el.arrow)
769
+ if (a !== 'none' && !/<a:ln\b[^>]*>(?:(?!<\/a:ln>)[\s\S])*<a:tailEnd/.test(xml)) {
770
+ proof.wrong.push(`${el.id}: arrow=${JSON.stringify(el.arrow)} 但 <a:tailEnd> 不在 <a:ln> 内(PowerPoint 会忽略箭头)`)
771
+ }
772
+ if (a === 'both' && !/<a:ln\b[^>]*>(?:(?!<\/a:ln>)[\s\S])*<a:headEnd/.test(xml)) {
773
+ proof.wrong.push(`${el.id}: arrow='both' 但 <a:headEnd> 不在 <a:ln> 内`)
519
774
  }
520
775
  }
521
776
  return xml
@@ -131,10 +131,18 @@ function boundsOf(el) {
131
131
  return { x, y, w: Math.max(1, Math.max(...xs) - x), h: Math.max(1, Math.max(...ys) - y) }
132
132
  }
133
133
 
134
+ // 阶段 A-④:`attach`(把线段两端锚到元素边)在归一化阶段解析成坐标。
135
+ // 方向是 layout → relations(relations 不依赖 layout),无循环依赖。
136
+ import { resolveAttach } from './relations.js'
137
+ // G1:手画与族共用同一份路由后处理(正交化 + 箭头端最短直段),带 n<3 与折返两道守卫
138
+ import { routePolyline } from './route-geometry.js'
139
+
134
140
  /** 归一化一个页面:元素 → 统一对象(含解析样式与文本度量)。 */
135
141
  export function normalizePage(page, ctx) {
136
142
  const { resolveColor, styleOf } = ctx
137
143
  const elements = []
144
+ // attach 解析要用**原始 DSL 元素**的几何查 ref(此时还没归一化)
145
+ const srcById = new Map((page.page.elements ?? []).map((e) => [e.elementId, e]))
138
146
  for (const el of page.page.elements ?? []) {
139
147
  const b = boundsOf(el)
140
148
  const base = {
@@ -142,6 +150,11 @@ export function normalizePage(page, ctx) {
142
150
  type: el.elementType,
143
151
  bounds: b,
144
152
  ...(el.role ? { role: el.role } : {}),
153
+ // 阶段 A(docs/08 §1):结构声明字段必须**透传**——归一化是白名单式构造,
154
+ // 漏在这里会让"结构关系"在导出/校验层凭空消失(几何反验证在 src/pptd/relations.js)。
155
+ ...(el.contains ? { contains: el.contains } : {}),
156
+ ...(el.badgeOf ? { badgeOf: el.badgeOf } : {}),
157
+ ...(el.roleReason ? { roleReason: el.roleReason } : {}),
145
158
  }
146
159
  switch (el.elementType) {
147
160
  case 'text': {
@@ -161,9 +174,23 @@ export function normalizePage(page, ctx) {
161
174
  break
162
175
  }
163
176
  case 'line': {
164
- const pts = el.points ? el.points.map((p) => p) : [[el.x1, el.y1], [el.x2, el.y2]]
165
- const line = { color: resolveColor(el.line?.color ?? '#000'), width: el.line?.width ?? 1 }
166
- elements.push({ ...base, type: 'line', points: pts, arrow: !!el.arrow, line })
177
+ // 阶段 A-④:`attach` **优先于**手写 points —— 解析成坐标,被改写的端点进 attachNotes(另行提示,不静默覆盖)。
178
+ // 导出侧只认 points(保持纯写盘层);几何仍由 relations 的反验证兜底。
179
+ const resolved = el.attach ? resolveAttach(el, srcById) : null
180
+ const raw = resolved?.points ?? (el.points ? el.points.map((p) => p) : [[el.x1, el.y1], [el.x2, el.y2]])
181
+ // 复用 route-geometry.js 的同一实现(族与手画不再双标),守卫详情见该模块注释。
182
+ // 例外与族一致:斜段图形(环/放射语义)豁免正交化;亦可显式写 orthogonal: false。
183
+ const exempt = el.orthogonal === false || el.line?.orthogonal === false
184
+ || el.role === 'decoration' || Boolean(el.roleReason)
185
+ const pts = exempt ? raw : routePolyline(raw, el.arrow ?? false)
186
+ const line = { color: resolveColor(el.line?.color ?? '#000'), width: el.line?.width ?? 1, ...(el.line?.dash ? { dash: el.line.dash } : {}) }
187
+ // 阶段 A:`arrow` 必须**原样**透传。原先写作 `!!el.arrow` 会把 `'both'` 压成 `true`
188
+ // ⇒ 两端箭头/虚线折线走到导出层时已经不认得(本轮实测:headEnd 计数 0)。
189
+ elements.push({
190
+ ...base, type: 'line', points: pts, arrow: el.arrow ?? false, line,
191
+ ...(el.attach ? { attach: el.attach } : {}),
192
+ ...(resolved?.notes?.length ? { attachNotes: resolved.notes } : {}),
193
+ })
167
194
  break
168
195
  }
169
196
  case 'image':
@@ -205,5 +232,29 @@ export function normalizePage(page, ctx) {
205
232
  elements.push(base)
206
233
  }
207
234
  }
235
+ // ── 兜底规整:去除折线"折返尖刺"(X1 最终修复)──────────────────────────
236
+ // 用户实测(图 10 顶端多出一根线):锚点解析/后处理之后,点序里会出现"共线且折返"的多余点
237
+ //(如 91.1 → 73.1 → 118.1),画出来就是一根戳出去的线。
238
+ // 放在 **normalizePage 末尾** 是因为:预览、导出、门禁**都**走本函数 ⇒ 这是唯一
239
+ // "改完不会被任何后续步骤覆盖"的位置(此前我改在它的上游,被解析/后处理覆盖 ⇒ 修不掉)。
240
+ for (const e of elements) {
241
+ if (e.type !== 'line' || !Array.isArray(e.points) || e.points.length < 3) continue
242
+ const res = []
243
+ for (const q of e.points) {
244
+ // 反复回退,直到"最后两点 + 当前点"不再折返;**当前点始终保留**
245
+ //(第一版写成 pop 后 continue,把当前点一起丢了 ⇒ 3 点被规整成 1 点 ⇒ 渲染报 "p2 is not iterable",实测踩过)
246
+ while (res.length >= 2) {
247
+ const a1 = res[res.length - 2]
248
+ const b1 = res[res.length - 1]
249
+ const sameX = Math.abs(a1[0] - b1[0]) < 0.5 && Math.abs(b1[0] - q[0]) < 0.5
250
+ const sameY = Math.abs(a1[1] - b1[1]) < 0.5 && Math.abs(b1[1] - q[1]) < 0.5
251
+ const reversal = (sameX && (b1[1] - a1[1]) * (q[1] - b1[1]) < 0) || (sameY && (b1[0] - a1[0]) * (q[0] - b1[0]) < 0)
252
+ if (reversal) res.pop()
253
+ else break
254
+ }
255
+ res.push(q)
256
+ }
257
+ if (res.length >= 2) e.points = res
258
+ }
208
259
  return elements
209
260
  }