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
package/lib/pptd/export-pptx.js
CHANGED
|
@@ -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
|
-
|
|
95
|
-
|
|
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
|
|
373
|
-
|
|
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
|
-
|
|
518
|
-
|
|
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
|
package/lib/pptd/layout.js
CHANGED
|
@@ -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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
}
|