wizz-method 2.13.0 → 2.15.0
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/THIRD-PARTY-NOTICES.md +2 -0
- package/package.json +10 -3
- package/skills-registry.yaml +30 -25
- package/src/bmm-skills/1-analysis/research/wizz-domain-research/customize.toml +4 -3
- package/src/bmm-skills/1-analysis/research/wizz-market-research/customize.toml +4 -3
- package/src/bmm-skills/1-analysis/research/wizz-technical-research/customize.toml +3 -1
- package/src/bmm-skills/1-analysis/wizz-agent-analyst/customize.toml +3 -1
- package/src/bmm-skills/1-analysis/wizz-agent-tech-writer/customize.toml +4 -3
- package/src/bmm-skills/1-analysis/wizz-document-project/customize.toml +4 -3
- package/src/bmm-skills/1-analysis/wizz-prfaq/customize.toml +4 -3
- package/src/bmm-skills/1-analysis/wizz-product-brief/customize.toml +5 -6
- package/src/bmm-skills/2-plan-workflows/wizz-agent-pm/customize.toml +3 -1
- package/src/bmm-skills/2-plan-workflows/wizz-agent-ux-designer/customize.toml +3 -1
- package/src/bmm-skills/2-plan-workflows/wizz-create-prd/customize.toml +4 -3
- package/src/bmm-skills/2-plan-workflows/wizz-edit-prd/customize.toml +4 -3
- package/src/bmm-skills/2-plan-workflows/wizz-prd/customize.toml +5 -6
- package/src/bmm-skills/2-plan-workflows/wizz-ux/customize.toml +4 -3
- package/src/bmm-skills/2-plan-workflows/wizz-validate-prd/customize.toml +4 -3
- package/src/bmm-skills/3-solutioning/wizz-agent-architect/customize.toml +3 -1
- package/src/bmm-skills/3-solutioning/wizz-architecture/customize.toml +5 -6
- package/src/bmm-skills/3-solutioning/wizz-check-implementation-readiness/customize.toml +4 -3
- package/src/bmm-skills/3-solutioning/wizz-create-architecture/customize.toml +4 -3
- package/src/bmm-skills/3-solutioning/wizz-create-epics-and-stories/customize.toml +4 -3
- package/src/bmm-skills/3-solutioning/wizz-generate-project-context/steps/step-03-complete.md +7 -0
- package/src/bmm-skills/4-implementation/wizz-agent-dev/customize.toml +3 -1
- package/src/bmm-skills/4-implementation/wizz-checkpoint-preview/customize.toml +4 -3
- package/src/bmm-skills/4-implementation/wizz-code-review/customize.toml +4 -3
- package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-03-triage.md +23 -14
- package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-04-present.md +2 -2
- package/src/bmm-skills/4-implementation/wizz-correct-course/customize.toml +4 -3
- package/src/bmm-skills/4-implementation/wizz-create-story/customize.toml +4 -3
- package/src/bmm-skills/4-implementation/wizz-dev-story/customize.toml +4 -3
- package/src/bmm-skills/4-implementation/wizz-investigate/customize.toml +4 -3
- package/src/bmm-skills/4-implementation/wizz-qa-generate-e2e-tests/customize.toml +4 -3
- package/src/bmm-skills/4-implementation/wizz-quick-dev/SKILL.md +1 -1
- package/src/bmm-skills/4-implementation/wizz-quick-dev/customize.toml +4 -3
- package/src/bmm-skills/4-implementation/wizz-quick-dev/spec-template.md +6 -0
- package/src/bmm-skills/4-implementation/wizz-quick-dev/step-04-review.md +27 -16
- package/src/bmm-skills/4-implementation/wizz-quick-dev/step-oneshot.md +27 -6
- package/src/bmm-skills/4-implementation/wizz-retrospective/customize.toml +4 -3
- package/src/bmm-skills/4-implementation/wizz-sprint-planning/customize.toml +4 -3
- package/src/bmm-skills/4-implementation/wizz-sprint-status/customize.toml +4 -3
- package/src/core-skills/wizz-brainstorming/customize.toml +5 -6
- package/src/core-skills/wizz-brainstorming/scripts/brain.py +19 -0
- package/src/core-skills/wizz-brainstorming/scripts/tests/test_brain.py +33 -0
- package/src/core-skills/wizz-forge-idea/scripts/resolve_personas.py +3 -1
- package/src/core-skills/wizz-forge-idea/scripts/tests/test_resolve_personas.py +21 -0
- package/src/core-skills/wizz-help/SKILL.md +5 -3
- package/src/core-skills/wizz-party-mode/customize.toml +5 -4
- package/src/core-skills/wizz-party-mode/scripts/resolve_party.py +3 -1
- package/src/core-skills/wizz-party-mode/scripts/tests/test_resolve_party.py +21 -0
- package/src/core-skills/wizz-spec/customize.toml +4 -5
- package/src/modules/wizz/_shared/model-ladder.md +4 -4
- package/src/modules/wizz/agents/wizz-designer/SKILL.md +1 -1
- package/src/modules/wizz/agents/wizz-maestro/SKILL.md +1 -1
- package/src/modules/wizz/subagents/opencode/wizz-exec-review.md +2 -2
- package/src/modules/wizz/subagents/opencode/wizz-exec-standard.md +2 -2
- package/src/modules/wizz/subagents/opencode/wizz-exec-strong.md +2 -2
- package/src/modules/wizz/subagents/spec.yaml +3 -3
- package/src/scripts/resolve_config.py +8 -0
- package/src/scripts/resolve_customization.py +24 -3
- package/src/scripts/tests/test_resolve_config.py +42 -0
- package/src/scripts/tests/test_resolve_customization.py +45 -0
- package/src/skills-lib/_shared/complexity-signal.md +1 -1
- package/src/skills-lib/ctc-align/SKILL.md +1 -1
- package/src/skills-lib/wizz-agent-browser/SKILL.md +5 -0
- package/src/skills-lib/wizz-design-needs/SKILL.md +1 -1
- package/src/skills-lib/wizz-huashu-design/scripts/html2pptx.js +902 -1061
- package/src/skills-lib/wizz-humanizer/README.md +134 -71
- package/src/skills-lib/wizz-humanizer/SKILL.md +51 -37
- package/src/skills-lib/wizz-humanizer/references/ai-writing-patterns.md +208 -190
- package/src/skills-lib/wizz-premium-landing-ui-researcher/SKILL.md +1 -1
- package/src/skills-lib/wizz-router/SKILL.md +1 -1
- package/src/skills-lib/wizz-ui-component-curator/SKILL.md +22 -2
- package/tools/installer/core/doctor.js +31 -1
- package/tools/installer/core/installer.js +32 -0
- package/tools/installer/core/project-instructions.js +199 -0
- package/src/bmm-skills/3-solutioning/wizz-architecture/scripts/__pycache__/lint_spine.cpython-313.pyc +0 -0
- package/src/bmm-skills/3-solutioning/wizz-architecture/scripts/tests/__pycache__/test_lint_spine.cpython-313-pytest-9.1.1.pyc +0 -0
- package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/__pycache__/sprint_status.cpython-313.pyc +0 -0
- package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/tests/__pycache__/test_git_evidence.cpython-313-pytest-9.1.1.pyc +0 -0
- package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/tests/__pycache__/test_sprint_status.cpython-313-pytest-9.1.1.pyc +0 -0
- package/src/bmm-skills/4-implementation/wizz-sprint-planning/scripts/__pycache__/sprint_plan.cpython-313.pyc +0 -0
- package/src/bmm-skills/4-implementation/wizz-sprint-planning/scripts/tests/__pycache__/test_sprint_plan.cpython-313-pytest-9.1.1.pyc +0 -0
- package/src/core-skills/wizz-advanced-elicitation/scripts/__pycache__/pick_methods.cpython-313.pyc +0 -0
- package/src/core-skills/wizz-advanced-elicitation/scripts/tests/__pycache__/test_pick_methods.cpython-313-pytest-9.1.1.pyc +0 -0
- package/src/core-skills/wizz-brainstorming/scripts/__pycache__/brain.cpython-313.pyc +0 -0
- package/src/core-skills/wizz-brainstorming/scripts/tests/__pycache__/test_brain.cpython-313-pytest-9.1.1.pyc +0 -0
- package/src/core-skills/wizz-brainstorming/scripts/tests/__pycache__/test_brain.cpython-314.pyc +0 -0
- package/src/core-skills/wizz-customize/scripts/__pycache__/list_customizable_skills.cpython-313.pyc +0 -0
- package/src/core-skills/wizz-customize/scripts/tests/__pycache__/test_list_customizable_skills.cpython-313-pytest-9.1.1.pyc +0 -0
- package/src/core-skills/wizz-forge-idea/scripts/__pycache__/resolve_personas.cpython-313.pyc +0 -0
- package/src/core-skills/wizz-forge-idea/scripts/__pycache__/resolve_personas.cpython-314.pyc +0 -0
- package/src/core-skills/wizz-forge-idea/scripts/tests/__pycache__/test_resolve_personas.cpython-313-pytest-9.1.1.pyc +0 -0
- package/src/core-skills/wizz-forge-idea/scripts/tests/__pycache__/test_resolve_personas.cpython-314.pyc +0 -0
- package/src/core-skills/wizz-party-mode/scripts/__pycache__/resolve_party.cpython-313.pyc +0 -0
- package/src/core-skills/wizz-party-mode/scripts/__pycache__/resolve_party.cpython-314.pyc +0 -0
- package/src/core-skills/wizz-party-mode/scripts/tests/__pycache__/test_resolve_party.cpython-313-pytest-9.1.1.pyc +0 -0
- package/src/core-skills/wizz-party-mode/scripts/tests/__pycache__/test_resolve_party.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/resolve_customization.cpython-314.pyc +0 -0
- package/src/skills-lib/wizz-huashu-design/package-lock.json +0 -846
- package/src/skills-lib/wizz-humanizer/WARP.md +0 -53
- package/src/skills-lib/wizz-humanizer/references/full-example.md +0 -72
- package/src/skills-lib/wizz-humanizer/references/personality-and-soul.md +0 -36
- package/src/skills-lib/wizz-react-components/package-lock.json +0 -231
- package/src/skills-lib/wizz-supabase-postgres-best-practices/CLAUDE.md +0 -68
- package/src/skills-lib/wizz-ui-ux-pro-max/scripts/__pycache__/core.cpython-314.pyc +0 -0
- package/src/skills-lib/wizz-ui-ux-pro-max/scripts/__pycache__/design_system.cpython-314.pyc +0 -0
|
@@ -1,1178 +1,1019 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
* slide.addChart(pptx.charts.LINE, data, placeholders[0]);
|
|
10
|
-
*
|
|
11
|
-
* await pptx.writeFile('output.pptx');
|
|
12
|
-
*
|
|
13
|
-
* FEATURES:
|
|
14
|
-
* - Converts HTML to PowerPoint with accurate positioning
|
|
15
|
-
* - Supports text, images, shapes, and bullet lists
|
|
16
|
-
* - Extracts placeholder elements (class="placeholder") with positions
|
|
17
|
-
* - Handles CSS gradients, borders, and margins
|
|
18
|
-
*
|
|
19
|
-
* VALIDATION:
|
|
20
|
-
* - Uses body width/height from HTML for viewport sizing
|
|
21
|
-
* - Throws error if HTML dimensions don't match presentation layout
|
|
22
|
-
* - Throws error if content overflows body (with overflow details)
|
|
23
|
-
*
|
|
24
|
-
* RETURNS:
|
|
25
|
-
* { slide, placeholders } where placeholders is an array of { id, x, y, w, h }
|
|
26
|
-
*/
|
|
1
|
+
// Copyright (c) 2026 alchaincyf (花叔). MIT License.
|
|
2
|
+
//
|
|
3
|
+
// 独立实现:按一份只描述输入、输出和可观察行为的规格从零编写,实现过程中没有
|
|
4
|
+
// 查看其他 html2pptx 的源码。依赖 Playwright 与 pptxgenjs 两个公开库。
|
|
5
|
+
//
|
|
6
|
+
// 职责:把一页 960pt×540pt(约定俗成,实际以 body 尺寸为准)的 HTML,用 Playwright
|
|
7
|
+
// 起一个真实 Chromium 打开、量出每个元素的渲染结果(位置/尺寸/computed style),
|
|
8
|
+
// 翻译成 pptxgenjs 的原生可编辑对象(文本框 / 形状 / 线条 / 图片)。
|
|
27
9
|
|
|
28
|
-
|
|
29
|
-
const path = require('path');
|
|
30
|
-
const sharp = require('sharp');
|
|
10
|
+
'use strict';
|
|
31
11
|
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
const EMU_PER_IN = 914400;
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { chromium } = require('playwright');
|
|
35
14
|
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const style = window.getComputedStyle(body);
|
|
15
|
+
// ---- 单位换算常量 ----
|
|
16
|
+
const PT_PER_PX = 0.75; // CSS px -> pt
|
|
17
|
+
const IN_PER_PX = 1 / 96; // CSS px -> inch
|
|
18
|
+
const EMU_PER_INCH = 914400;
|
|
41
19
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
});
|
|
20
|
+
function toPt(px) {
|
|
21
|
+
return px * PT_PER_PX;
|
|
22
|
+
}
|
|
23
|
+
function toIn(px) {
|
|
24
|
+
return px * IN_PER_PX;
|
|
25
|
+
}
|
|
49
26
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const directions = [];
|
|
59
|
-
if (widthOverflowPt > 0) directions.push(`${widthOverflowPt.toFixed(1)}pt horizontally`);
|
|
60
|
-
if (heightOverflowPt > 0) directions.push(`${heightOverflowPt.toFixed(1)}pt vertically`);
|
|
61
|
-
const reminder = heightOverflowPt > 0 ? ' (Remember: leave 0.5" margin at bottom of slide)' : '';
|
|
62
|
-
errors.push(`HTML content overflows body by ${directions.join(' and ')}${reminder}`);
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// 浏览器内提取逻辑:作为字符串函数整体交给 page.evaluate 执行,运行在页面上下文里,
|
|
29
|
+
// 不能依赖外层闭包变量(Playwright 序列化 Function.toString() 后在浏览器里重新求值)。
|
|
30
|
+
// ============================================================================
|
|
31
|
+
function extractPageDataInBrowser() {
|
|
32
|
+
const PT_PER_PX = 0.75;
|
|
33
|
+
function toPt(px) {
|
|
34
|
+
return px * PT_PER_PX;
|
|
63
35
|
}
|
|
36
|
+
function toIn(px) {
|
|
37
|
+
return px / 96;
|
|
38
|
+
}
|
|
39
|
+
const SINGLE_WEIGHT_FONTS = ['impact'];
|
|
40
|
+
const INLINE_TAGS = new Set(['SPAN', 'B', 'STRONG', 'I', 'EM', 'U']);
|
|
41
|
+
const TEXT_TAG_BG_CHECK = new Set(['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'UL', 'OL', 'LI']);
|
|
42
|
+
const TEXT_ELEMENT_TAGS = new Set(['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI']);
|
|
43
|
+
const MANUAL_BULLET_CHARS_VALIDATE = ['•', '-', '*', '▪', '▸', '○', '●', '◆', '◇', '■', '□'];
|
|
44
|
+
const MANUAL_BULLET_CHARS_STRIP = ['•', '-', '*', '▪', '▸'];
|
|
64
45
|
|
|
65
|
-
return { ...bodyDimensions, errors };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Helper: Validate dimensions match presentation layout
|
|
69
|
-
function validateDimensions(bodyDimensions, pres) {
|
|
70
46
|
const errors = [];
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
47
|
+
const elements = [];
|
|
48
|
+
const placeholders = [];
|
|
49
|
+
const consumed = new Set();
|
|
50
|
+
|
|
51
|
+
// ---- 基础工具 ----
|
|
52
|
+
function parsePx(v) {
|
|
53
|
+
const n = parseFloat(v);
|
|
54
|
+
return isNaN(n) ? 0 : n;
|
|
55
|
+
}
|
|
77
56
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
`don't match presentation layout (${layoutWidth.toFixed(1)}" × ${layoutHeight.toFixed(1)}")`
|
|
82
|
-
);
|
|
83
|
-
}
|
|
57
|
+
function rectOf(el) {
|
|
58
|
+
const r = el.getBoundingClientRect();
|
|
59
|
+
return { x: r.left, y: r.top, w: r.width, h: r.height };
|
|
84
60
|
}
|
|
85
|
-
return errors;
|
|
86
|
-
}
|
|
87
61
|
|
|
88
|
-
function
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
for (const el of slideData.elements) {
|
|
94
|
-
// Check text elements (p, h1-h6, list, merged-text)
|
|
95
|
-
if (['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'list', 'merged-text'].includes(el.type)) {
|
|
96
|
-
const fontSize = el.style?.fontSize || 0;
|
|
97
|
-
const bottomEdge = el.position.y + el.position.h;
|
|
98
|
-
const distanceFromBottom = slideHeightInches - bottomEdge;
|
|
99
|
-
|
|
100
|
-
if (fontSize > 12 && distanceFromBottom < minBottomMargin) {
|
|
101
|
-
const getText = () => {
|
|
102
|
-
if (typeof el.text === 'string') return el.text;
|
|
103
|
-
if (Array.isArray(el.text)) return el.text.find(t => t.text)?.text || '';
|
|
104
|
-
if (Array.isArray(el.items)) return el.items.find(item => item.text)?.text || '';
|
|
105
|
-
return '';
|
|
106
|
-
};
|
|
107
|
-
const textPrefix = getText().substring(0, 50) + (getText().length > 50 ? '...' : '');
|
|
62
|
+
function truncate(str, max) {
|
|
63
|
+
if (str.length <= max) return str;
|
|
64
|
+
return str.slice(0, max) + '...';
|
|
65
|
+
}
|
|
108
66
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
`(${distanceFromBottom.toFixed(2)}" from bottom, minimum ${minBottomMargin}" required)`
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
67
|
+
function collapseWs(s) {
|
|
68
|
+
return s.replace(/[\t\n\r ]+/g, ' ');
|
|
115
69
|
}
|
|
116
70
|
|
|
117
|
-
|
|
118
|
-
|
|
71
|
+
function parseColorString(str) {
|
|
72
|
+
if (!str) return null;
|
|
73
|
+
const m = /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)/i.exec(str);
|
|
74
|
+
if (!m) return null;
|
|
75
|
+
const r = Math.max(0, Math.min(255, Math.round(parseFloat(m[1]))));
|
|
76
|
+
const g = Math.max(0, Math.min(255, Math.round(parseFloat(m[2]))));
|
|
77
|
+
const b = Math.max(0, Math.min(255, Math.round(parseFloat(m[3]))));
|
|
78
|
+
const hasAlpha = m[4] !== undefined;
|
|
79
|
+
const alpha = hasAlpha ? parseFloat(m[4]) : 1;
|
|
80
|
+
const hex = [r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('');
|
|
81
|
+
return { hex, alpha, hasAlpha };
|
|
82
|
+
}
|
|
119
83
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (slideData.background.type === 'image' && slideData.background.path) {
|
|
123
|
-
let imagePath = slideData.background.path.startsWith('file://')
|
|
124
|
-
? slideData.background.path.replace('file://', '')
|
|
125
|
-
: slideData.background.path;
|
|
126
|
-
targetSlide.background = { path: imagePath };
|
|
127
|
-
} else if (slideData.background.type === 'color' && slideData.background.value) {
|
|
128
|
-
targetSlide.background = { color: slideData.background.value };
|
|
84
|
+
function isVisibleColor(c) {
|
|
85
|
+
return !!c && c.alpha > 0;
|
|
129
86
|
}
|
|
130
|
-
}
|
|
131
87
|
|
|
132
|
-
//
|
|
133
|
-
function
|
|
134
|
-
|
|
135
|
-
if (
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
path: imagePath,
|
|
139
|
-
x: el.position.x,
|
|
140
|
-
y: el.position.y,
|
|
141
|
-
w: el.position.w,
|
|
142
|
-
h: el.position.h
|
|
143
|
-
});
|
|
144
|
-
} else if (el.type === 'line') {
|
|
145
|
-
targetSlide.addShape(pres.ShapeType.line, {
|
|
146
|
-
x: el.x1,
|
|
147
|
-
y: el.y1,
|
|
148
|
-
w: el.x2 - el.x1,
|
|
149
|
-
h: el.y2 - el.y1,
|
|
150
|
-
line: { color: el.color, width: el.width }
|
|
151
|
-
});
|
|
152
|
-
} else if (el.type === 'shape') {
|
|
153
|
-
const shapeOptions = {
|
|
154
|
-
x: el.position.x,
|
|
155
|
-
y: el.position.y,
|
|
156
|
-
w: el.position.w,
|
|
157
|
-
h: el.position.h,
|
|
158
|
-
shape: el.shape.rectRadius > 0 ? pres.ShapeType.roundRect : pres.ShapeType.rect
|
|
159
|
-
};
|
|
88
|
+
// {color, transparency?} —— 只有解析出显式 alpha 通道时才带 transparency
|
|
89
|
+
function fillFieldOf(c) {
|
|
90
|
+
const out = { color: c.hex };
|
|
91
|
+
if (c.hasAlpha) out.transparency = Math.round((1 - c.alpha) * 100);
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
160
94
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (el.shape.rectRadius > 0) shapeOptions.rectRadius = el.shape.rectRadius;
|
|
167
|
-
if (el.shape.shadow) shapeOptions.shadow = el.shape.shadow;
|
|
168
|
-
|
|
169
|
-
targetSlide.addText(el.text || '', shapeOptions);
|
|
170
|
-
} else if (el.type === 'list') {
|
|
171
|
-
const listOptions = {
|
|
172
|
-
x: el.position.x,
|
|
173
|
-
y: el.position.y,
|
|
174
|
-
w: el.position.w,
|
|
175
|
-
h: el.position.h,
|
|
176
|
-
fontSize: el.style.fontSize,
|
|
177
|
-
fontFace: el.style.fontFace,
|
|
178
|
-
color: el.style.color,
|
|
179
|
-
align: el.style.align,
|
|
180
|
-
valign: 'top',
|
|
181
|
-
lineSpacing: el.style.lineSpacing,
|
|
182
|
-
paraSpaceBefore: el.style.paraSpaceBefore,
|
|
183
|
-
paraSpaceAfter: el.style.paraSpaceAfter,
|
|
184
|
-
margin: el.style.margin
|
|
185
|
-
};
|
|
186
|
-
if (el.style.margin) listOptions.margin = el.style.margin;
|
|
187
|
-
targetSlide.addText(el.items, listOptions);
|
|
188
|
-
} else if (el.type === 'merged-text') {
|
|
189
|
-
// data-pptx-merge container — all paragraphs in one editable text frame.
|
|
190
|
-
const mergedOptions = {
|
|
191
|
-
x: el.position.x,
|
|
192
|
-
y: el.position.y,
|
|
193
|
-
w: el.position.w,
|
|
194
|
-
h: el.position.h,
|
|
195
|
-
fontSize: el.style.fontSize,
|
|
196
|
-
fontFace: el.style.fontFace,
|
|
197
|
-
color: el.style.color,
|
|
198
|
-
align: el.style.align,
|
|
199
|
-
valign: 'top',
|
|
200
|
-
lineSpacing: el.style.lineSpacing,
|
|
201
|
-
paraSpaceBefore: el.style.paraSpaceBefore,
|
|
202
|
-
paraSpaceAfter: el.style.paraSpaceAfter,
|
|
203
|
-
margin: el.style.margin,
|
|
204
|
-
inset: 0
|
|
205
|
-
};
|
|
206
|
-
if (el.style.transparency != null) mergedOptions.transparency = el.style.transparency;
|
|
207
|
-
targetSlide.addText(el.items, mergedOptions);
|
|
208
|
-
} else {
|
|
209
|
-
// Check if text is single-line (height suggests one line)
|
|
210
|
-
const lineHeight = el.style.lineSpacing || el.style.fontSize * 1.2;
|
|
211
|
-
const isSingleLine = el.position.h <= lineHeight * 1.5;
|
|
212
|
-
|
|
213
|
-
let adjustedX = el.position.x;
|
|
214
|
-
let adjustedW = el.position.w;
|
|
215
|
-
|
|
216
|
-
// Make single-line text 2% wider to account for underestimate
|
|
217
|
-
if (isSingleLine) {
|
|
218
|
-
const widthIncrease = el.position.w * 0.02;
|
|
219
|
-
const align = el.style.align;
|
|
220
|
-
|
|
221
|
-
if (align === 'center') {
|
|
222
|
-
// Center: expand both sides
|
|
223
|
-
adjustedX = el.position.x - (widthIncrease / 2);
|
|
224
|
-
adjustedW = el.position.w + widthIncrease;
|
|
225
|
-
} else if (align === 'right') {
|
|
226
|
-
// Right: expand to the left
|
|
227
|
-
adjustedX = el.position.x - widthIncrease;
|
|
228
|
-
adjustedW = el.position.w + widthIncrease;
|
|
229
|
-
} else {
|
|
230
|
-
// Left (default): expand to the right
|
|
231
|
-
adjustedW = el.position.w + widthIncrease;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
95
|
+
function firstFontFamily(v) {
|
|
96
|
+
if (!v) return '';
|
|
97
|
+
const first = v.split(',')[0].trim();
|
|
98
|
+
return first.replace(/^["']|["']$/g, '');
|
|
99
|
+
}
|
|
234
100
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
w: adjustedW,
|
|
239
|
-
h: el.position.h,
|
|
240
|
-
fontSize: el.style.fontSize,
|
|
241
|
-
fontFace: el.style.fontFace,
|
|
242
|
-
color: el.style.color,
|
|
243
|
-
bold: el.style.bold,
|
|
244
|
-
italic: el.style.italic,
|
|
245
|
-
underline: el.style.underline,
|
|
246
|
-
valign: 'top',
|
|
247
|
-
lineSpacing: el.style.lineSpacing,
|
|
248
|
-
paraSpaceBefore: el.style.paraSpaceBefore,
|
|
249
|
-
paraSpaceAfter: el.style.paraSpaceAfter,
|
|
250
|
-
inset: 0 // Remove default PowerPoint internal padding
|
|
251
|
-
};
|
|
101
|
+
function isSingleWeightFont(fontFace) {
|
|
102
|
+
return SINGLE_WEIGHT_FONTS.indexOf((fontFace || '').toLowerCase()) !== -1;
|
|
103
|
+
}
|
|
252
104
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (el.style.transparency !== null && el.style.transparency !== undefined) textOptions.transparency = el.style.transparency;
|
|
105
|
+
function normalizeAlign(v) {
|
|
106
|
+
return v === 'start' ? 'left' : v;
|
|
107
|
+
}
|
|
257
108
|
|
|
258
|
-
|
|
109
|
+
function parseLineHeightPt(lineHeightStr, fontSizePt) {
|
|
110
|
+
const v = parseFloat(lineHeightStr);
|
|
111
|
+
if (!isNaN(v) && /px\s*$/.test(String(lineHeightStr).trim())) {
|
|
112
|
+
return v * PT_PER_PX;
|
|
259
113
|
}
|
|
114
|
+
// 防御性兜底:真实 Chromium 的 computed line-height 基本总是已解析的 px 值,
|
|
115
|
+
// 这里的 fontSize*1.2 分支理论上不会命中。
|
|
116
|
+
return fontSizePt * 1.2;
|
|
260
117
|
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// Helper: Extract slide data from HTML page
|
|
264
|
-
async function extractSlideData(page) {
|
|
265
|
-
return await page.evaluate(() => {
|
|
266
|
-
const PT_PER_PX = 0.75;
|
|
267
|
-
const PX_PER_IN = 96;
|
|
268
|
-
|
|
269
|
-
// Fonts that are single-weight and should not have bold applied
|
|
270
|
-
// (applying bold causes PowerPoint to use faux bold which makes text wider)
|
|
271
|
-
const SINGLE_WEIGHT_FONTS = ['impact'];
|
|
272
|
-
|
|
273
|
-
// Helper: Check if a font should skip bold formatting
|
|
274
|
-
const shouldSkipBold = (fontFamily) => {
|
|
275
|
-
if (!fontFamily) return false;
|
|
276
|
-
const normalizedFont = fontFamily.toLowerCase().replace(/['"]/g, '').split(',')[0].trim();
|
|
277
|
-
return SINGLE_WEIGHT_FONTS.includes(normalizedFont);
|
|
278
|
-
};
|
|
279
118
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
119
|
+
function applyTextTransform(str, tt) {
|
|
120
|
+
if (tt === 'uppercase') return str.toUpperCase();
|
|
121
|
+
if (tt === 'lowercase') return str.toLowerCase();
|
|
122
|
+
if (tt === 'capitalize') return str.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
123
|
+
return str;
|
|
124
|
+
}
|
|
286
125
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
126
|
+
function getBordersInfo(cs) {
|
|
127
|
+
return {
|
|
128
|
+
top: { w: parsePx(cs.borderTopWidth), c: cs.borderTopColor },
|
|
129
|
+
right: { w: parsePx(cs.borderRightWidth), c: cs.borderRightColor },
|
|
130
|
+
bottom: { w: parsePx(cs.borderBottomWidth), c: cs.borderBottomColor },
|
|
131
|
+
left: { w: parsePx(cs.borderLeftWidth), c: cs.borderLeftColor },
|
|
290
132
|
};
|
|
133
|
+
}
|
|
291
134
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
135
|
+
function computeRectRadius(cs, widthPx, heightPx) {
|
|
136
|
+
const raw = (cs.borderTopLeftRadius || '0px').trim();
|
|
137
|
+
const m = /^(-?[\d.]+)(px|pt|%)?$/.exec(raw);
|
|
138
|
+
if (!m) return 0;
|
|
139
|
+
const val = parseFloat(m[1]);
|
|
140
|
+
const unit = m[2] || 'px';
|
|
141
|
+
if (!val) return 0;
|
|
142
|
+
if (unit === '%') {
|
|
143
|
+
if (val >= 50) return 1;
|
|
144
|
+
return ((val / 100) * Math.min(widthPx, heightPx)) / 96;
|
|
145
|
+
}
|
|
146
|
+
if (unit === 'pt') return val / 72;
|
|
147
|
+
return val / 96;
|
|
148
|
+
}
|
|
298
149
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
150
|
+
function parseBoxShadow(str) {
|
|
151
|
+
if (!str || str === 'none') return null;
|
|
152
|
+
if (/inset/i.test(str)) return null;
|
|
153
|
+
const colorMatch = str.match(/rgba?\([^)]+\)/i);
|
|
154
|
+
const colorParsed = colorMatch ? parseColorString(colorMatch[0]) : null;
|
|
155
|
+
const withoutColor = colorMatch ? str.replace(colorMatch[0], '') : str;
|
|
156
|
+
const nums = withoutColor.match(/-?[\d.]+px/g) || [];
|
|
157
|
+
if (nums.length < 2) return null;
|
|
158
|
+
const vals = nums.map((s) => parseFloat(s));
|
|
159
|
+
const offsetX = vals[0];
|
|
160
|
+
const offsetY = vals[1];
|
|
161
|
+
const blur = vals[2] || 0;
|
|
162
|
+
const offsetPt = Math.hypot(offsetX, offsetY) * PT_PER_PX;
|
|
163
|
+
let angleDeg = (Math.atan2(offsetY, offsetX) * 180) / Math.PI;
|
|
164
|
+
angleDeg = ((angleDeg % 360) + 360) % 360;
|
|
165
|
+
const blurPt = blur * PT_PER_PX;
|
|
166
|
+
const color = colorParsed ? colorParsed.hex : '000000';
|
|
167
|
+
const opacity = colorParsed && colorParsed.hasAlpha ? colorParsed.alpha : 0.5;
|
|
168
|
+
return { type: 'outer', angle: angleDeg, blur: blurPt, color, offset: offsetPt, opacity };
|
|
169
|
+
}
|
|
307
170
|
|
|
308
|
-
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
angle = 90;
|
|
318
|
-
} else if (writingMode === 'vertical-lr') {
|
|
319
|
-
// vertical-lr alone = text reads bottom to top = 270° in PowerPoint
|
|
320
|
-
angle = 270;
|
|
321
|
-
}
|
|
171
|
+
function violationOf(el) {
|
|
172
|
+
const cs = getComputedStyle(el);
|
|
173
|
+
const bg = parseColorString(cs.backgroundColor);
|
|
174
|
+
if (isVisibleColor(bg)) return 'background';
|
|
175
|
+
const b = getBordersInfo(cs);
|
|
176
|
+
if (b.top.w > 0 || b.right.w > 0 || b.bottom.w > 0 || b.left.w > 0) return 'border';
|
|
177
|
+
if (cs.boxShadow && cs.boxShadow !== 'none') return 'shadow';
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
322
180
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
181
|
+
// ---- 旋转角度 + 位置/尺寸(4.5 节) ----
|
|
182
|
+
function computeRotationDeg(cs) {
|
|
183
|
+
let angle = 0;
|
|
184
|
+
if (cs.writingMode === 'vertical-rl') angle += 90;
|
|
185
|
+
else if (cs.writingMode === 'vertical-lr') angle += 270;
|
|
186
|
+
const t = cs.transform;
|
|
187
|
+
if (t && t !== 'none') {
|
|
188
|
+
const rm = /rotate\(([-\d.]+)deg\)/.exec(t);
|
|
189
|
+
if (rm) {
|
|
190
|
+
angle += parseFloat(rm[1]);
|
|
191
|
+
} else {
|
|
192
|
+
const mm = /matrix\(([^)]+)\)/.exec(t);
|
|
193
|
+
if (mm) {
|
|
194
|
+
const parts = mm[1].split(',').map((s) => parseFloat(s.trim()));
|
|
195
|
+
angle += (Math.atan2(parts[1], parts[0]) * 180) / Math.PI;
|
|
338
196
|
}
|
|
339
197
|
}
|
|
198
|
+
}
|
|
199
|
+
return ((angle % 360) + 360) % 360;
|
|
200
|
+
}
|
|
340
201
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
// The browser shows us the rotated dimensions (tall box for vertical text)
|
|
360
|
-
// But PowerPoint needs the pre-rotation dimensions (wide box that will be rotated)
|
|
361
|
-
// So we swap: browser's height becomes PPT's width, browser's width becomes PPT's height
|
|
362
|
-
const centerX = rect.left + rect.width / 2;
|
|
363
|
-
const centerY = rect.top + rect.height / 2;
|
|
364
|
-
|
|
365
|
-
return {
|
|
366
|
-
x: centerX - rect.height / 2,
|
|
367
|
-
y: centerY - rect.width / 2,
|
|
368
|
-
w: rect.height,
|
|
369
|
-
h: rect.width
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// For other rotations, use element's offset dimensions
|
|
374
|
-
const centerX = rect.left + rect.width / 2;
|
|
375
|
-
const centerY = rect.top + rect.height / 2;
|
|
376
|
-
return {
|
|
377
|
-
x: centerX - el.offsetWidth / 2,
|
|
378
|
-
y: centerY - el.offsetHeight / 2,
|
|
379
|
-
w: el.offsetWidth,
|
|
380
|
-
h: el.offsetHeight
|
|
381
|
-
};
|
|
382
|
-
};
|
|
383
|
-
|
|
384
|
-
// Parse CSS box-shadow into PptxGenJS shadow properties
|
|
385
|
-
const parseBoxShadow = (boxShadow) => {
|
|
386
|
-
if (!boxShadow || boxShadow === 'none') return null;
|
|
387
|
-
|
|
388
|
-
// Browser computed style format: "rgba(0, 0, 0, 0.3) 2px 2px 8px 0px [inset]"
|
|
389
|
-
// CSS format: "[inset] 2px 2px 8px 0px rgba(0, 0, 0, 0.3)"
|
|
390
|
-
|
|
391
|
-
const insetMatch = boxShadow.match(/inset/);
|
|
392
|
-
|
|
393
|
-
// IMPORTANT: PptxGenJS/PowerPoint doesn't properly support inset shadows
|
|
394
|
-
// Only process outer shadows to avoid file corruption
|
|
395
|
-
if (insetMatch) return null;
|
|
396
|
-
|
|
397
|
-
// Extract color first (rgba or rgb at start)
|
|
398
|
-
const colorMatch = boxShadow.match(/rgba?\([^)]+\)/);
|
|
202
|
+
function computeGeometryPx(el, rotationDeg) {
|
|
203
|
+
const r = el.getBoundingClientRect();
|
|
204
|
+
if (rotationDeg === 90 || rotationDeg === 270) {
|
|
205
|
+
const cx = r.left + r.width / 2;
|
|
206
|
+
const cy = r.top + r.height / 2;
|
|
207
|
+
const w = r.height;
|
|
208
|
+
const h = r.width;
|
|
209
|
+
return { x: cx - w / 2, y: cy - h / 2, w, h };
|
|
210
|
+
}
|
|
211
|
+
if (rotationDeg !== 0) {
|
|
212
|
+
const cx = r.left + r.width / 2;
|
|
213
|
+
const cy = r.top + r.height / 2;
|
|
214
|
+
const w = el.offsetWidth;
|
|
215
|
+
const h = el.offsetHeight;
|
|
216
|
+
return { x: cx - w / 2, y: cy - h / 2, w, h };
|
|
217
|
+
}
|
|
218
|
+
return { x: r.left, y: r.top, w: r.width, h: r.height };
|
|
219
|
+
}
|
|
399
220
|
|
|
400
|
-
|
|
401
|
-
|
|
221
|
+
// ---- 4.5.3 内联富文本通用解析 ----
|
|
222
|
+
function hasInlineFormatting(el) {
|
|
223
|
+
return !!el.querySelector('span,b,strong,i,em,u,br');
|
|
224
|
+
}
|
|
402
225
|
|
|
403
|
-
|
|
226
|
+
function buildRuns(containerEl, marginErrsOut) {
|
|
227
|
+
const rawRuns = [];
|
|
228
|
+
|
|
229
|
+
function styleOf(el) {
|
|
230
|
+
const cs = getComputedStyle(el);
|
|
231
|
+
const fontSizePt = parseFloat(cs.fontSize) * PT_PER_PX;
|
|
232
|
+
const fontFace = firstFontFamily(cs.fontFamily);
|
|
233
|
+
let bold = cs.fontWeight === 'bold' || parseInt(cs.fontWeight, 10) >= 600;
|
|
234
|
+
if (isSingleWeightFont(fontFace)) bold = false;
|
|
235
|
+
const italic = cs.fontStyle === 'italic';
|
|
236
|
+
const deco = cs.textDecorationLine || cs.textDecoration || '';
|
|
237
|
+
const underline = deco.indexOf('underline') !== -1;
|
|
238
|
+
const colorParsed = parseColorString(cs.color);
|
|
239
|
+
const textTransform = cs.textTransform;
|
|
240
|
+
return { fontSizePt, bold, italic, underline, colorParsed, textTransform };
|
|
241
|
+
}
|
|
404
242
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
243
|
+
function sameStyle(a, b) {
|
|
244
|
+
if (!a || !b) return false;
|
|
245
|
+
return (
|
|
246
|
+
a.fontSizePt === b.fontSizePt &&
|
|
247
|
+
a.bold === b.bold &&
|
|
248
|
+
a.italic === b.italic &&
|
|
249
|
+
a.underline === b.underline &&
|
|
250
|
+
a.textTransform === b.textTransform &&
|
|
251
|
+
JSON.stringify(a.colorParsed) === JSON.stringify(b.colorParsed)
|
|
252
|
+
);
|
|
253
|
+
}
|
|
408
254
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
255
|
+
function append(text, style) {
|
|
256
|
+
if (text === '') return;
|
|
257
|
+
const last = rawRuns[rawRuns.length - 1];
|
|
258
|
+
if (last && sameStyle(last.style, style)) {
|
|
259
|
+
last.text += text;
|
|
260
|
+
} else {
|
|
261
|
+
rawRuns.push({ text, style });
|
|
414
262
|
}
|
|
263
|
+
}
|
|
415
264
|
|
|
416
|
-
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
opacity = parseFloat(opacityMatch[0].replace(')', ''));
|
|
265
|
+
function checkInlineMargins(el, tag) {
|
|
266
|
+
const cs = getComputedStyle(el);
|
|
267
|
+
['Left', 'Right', 'Top', 'Bottom'].forEach((dir) => {
|
|
268
|
+
const v = parsePx(cs['margin' + dir]);
|
|
269
|
+
if (v !== 0) {
|
|
270
|
+
marginErrsOut.push(
|
|
271
|
+
`Inline element <${tag.toLowerCase()}> has a non-zero margin-${dir.toLowerCase()} (inline elements do not support margin — remove it).`
|
|
272
|
+
);
|
|
425
273
|
}
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function walk(node) {
|
|
278
|
+
if (node.nodeType === 3) {
|
|
279
|
+
const collapsed = collapseWs(node.textContent);
|
|
280
|
+
if (collapsed === '') return;
|
|
281
|
+
const style = styleOf(node.parentElement);
|
|
282
|
+
append(applyTextTransform(collapsed, style.textTransform), style);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (node.nodeType !== 1) return;
|
|
286
|
+
const tag = node.tagName;
|
|
287
|
+
if (tag === 'BR') {
|
|
288
|
+
const style = styleOf(node.parentElement);
|
|
289
|
+
append('\n', style);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (INLINE_TAGS.has(tag)) {
|
|
293
|
+
const text = node.textContent;
|
|
294
|
+
if (!text || !text.trim()) return;
|
|
295
|
+
checkInlineMargins(node, tag);
|
|
296
|
+
Array.from(node.childNodes).forEach(walk);
|
|
297
|
+
return;
|
|
426
298
|
}
|
|
299
|
+
// 不认识的标签(比如 <code>):整段忽略,文字从输出里消失(既有行为,不扩展)。
|
|
300
|
+
}
|
|
427
301
|
|
|
428
|
-
|
|
429
|
-
type: 'outer',
|
|
430
|
-
angle: Math.round(angle),
|
|
431
|
-
blur: blur * 0.75, // Convert to points
|
|
432
|
-
color: colorMatch ? rgbToHex(colorMatch[0]) : '000000',
|
|
433
|
-
offset: offset,
|
|
434
|
-
opacity
|
|
435
|
-
};
|
|
436
|
-
};
|
|
302
|
+
Array.from(containerEl.childNodes).forEach(walk);
|
|
437
303
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
element.childNodes.forEach((node) => {
|
|
443
|
-
let textTransform = baseTextTransform;
|
|
444
|
-
|
|
445
|
-
const isText = node.nodeType === Node.TEXT_NODE || node.tagName === 'BR';
|
|
446
|
-
if (isText) {
|
|
447
|
-
const text = node.tagName === 'BR' ? '\n' : textTransform(node.textContent.replace(/\s+/g, ' '));
|
|
448
|
-
const prevRun = runs[runs.length - 1];
|
|
449
|
-
if (prevNodeIsText && prevRun) {
|
|
450
|
-
prevRun.text += text;
|
|
451
|
-
} else {
|
|
452
|
-
runs.push({ text, options: { ...baseOptions } });
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
} else if (node.nodeType === Node.ELEMENT_NODE && node.textContent.trim()) {
|
|
456
|
-
const options = { ...baseOptions };
|
|
457
|
-
const computed = window.getComputedStyle(node);
|
|
458
|
-
|
|
459
|
-
// Handle inline elements with computed styles
|
|
460
|
-
if (node.tagName === 'SPAN' || node.tagName === 'B' || node.tagName === 'STRONG' || node.tagName === 'I' || node.tagName === 'EM' || node.tagName === 'U') {
|
|
461
|
-
const isBold = computed.fontWeight === 'bold' || parseInt(computed.fontWeight) >= 600;
|
|
462
|
-
if (isBold && !shouldSkipBold(computed.fontFamily)) options.bold = true;
|
|
463
|
-
if (computed.fontStyle === 'italic') options.italic = true;
|
|
464
|
-
if (computed.textDecoration && computed.textDecoration.includes('underline')) options.underline = true;
|
|
465
|
-
if (computed.color && computed.color !== 'rgb(0, 0, 0)') {
|
|
466
|
-
options.color = rgbToHex(computed.color);
|
|
467
|
-
const transparency = extractAlpha(computed.color);
|
|
468
|
-
if (transparency !== null) options.transparency = transparency;
|
|
469
|
-
}
|
|
470
|
-
if (computed.fontSize) options.fontSize = pxToPoints(computed.fontSize);
|
|
471
|
-
|
|
472
|
-
// Apply text-transform on the span element itself
|
|
473
|
-
if (computed.textTransform && computed.textTransform !== 'none') {
|
|
474
|
-
const transformStr = computed.textTransform;
|
|
475
|
-
textTransform = (text) => applyTextTransform(text, transformStr);
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
// Validate: Check for margins on inline elements
|
|
479
|
-
if (computed.marginLeft && parseFloat(computed.marginLeft) > 0) {
|
|
480
|
-
errors.push(`Inline element <${node.tagName.toLowerCase()}> has margin-left which is not supported in PowerPoint. Remove margin from inline elements.`);
|
|
481
|
-
}
|
|
482
|
-
if (computed.marginRight && parseFloat(computed.marginRight) > 0) {
|
|
483
|
-
errors.push(`Inline element <${node.tagName.toLowerCase()}> has margin-right which is not supported in PowerPoint. Remove margin from inline elements.`);
|
|
484
|
-
}
|
|
485
|
-
if (computed.marginTop && parseFloat(computed.marginTop) > 0) {
|
|
486
|
-
errors.push(`Inline element <${node.tagName.toLowerCase()}> has margin-top which is not supported in PowerPoint. Remove margin from inline elements.`);
|
|
487
|
-
}
|
|
488
|
-
if (computed.marginBottom && parseFloat(computed.marginBottom) > 0) {
|
|
489
|
-
errors.push(`Inline element <${node.tagName.toLowerCase()}> has margin-bottom which is not supported in PowerPoint. Remove margin from inline elements.`);
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// Recursively process the child node. This will flatten nested spans into multiple runs.
|
|
493
|
-
parseInlineFormatting(node, options, runs, textTransform);
|
|
494
|
-
}
|
|
495
|
-
}
|
|
304
|
+
if (rawRuns.length > 0) {
|
|
305
|
+
rawRuns[0].text = rawRuns[0].text.replace(/^ +/, '');
|
|
306
|
+
rawRuns[rawRuns.length - 1].text = rawRuns[rawRuns.length - 1].text.replace(/ +$/, '');
|
|
307
|
+
}
|
|
496
308
|
|
|
497
|
-
|
|
309
|
+
// pptxgenjs 的 TextProps 要求 { text, options }:options 里只放和继承样式不同的
|
|
310
|
+
// 覆盖项,缺省项交给 pptxgenjs 自己按顶层/上一个 run 的默认值处理。
|
|
311
|
+
return rawRuns
|
|
312
|
+
.filter((r) => r.text !== '')
|
|
313
|
+
.map((r) => {
|
|
314
|
+
const options = { fontSize: r.style.fontSizePt };
|
|
315
|
+
if (r.style.bold) options.bold = true;
|
|
316
|
+
if (r.style.italic) options.italic = true;
|
|
317
|
+
if (r.style.underline) options.underline = true;
|
|
318
|
+
const c = r.style.colorParsed;
|
|
319
|
+
const isPureBlack = c && c.hex === '000000' && !c.hasAlpha;
|
|
320
|
+
if (c && !isPureBlack) {
|
|
321
|
+
options.color = c.hex;
|
|
322
|
+
if (c.hasAlpha) options.transparency = Math.round((1 - c.alpha) * 100);
|
|
323
|
+
}
|
|
324
|
+
return { text: r.text, options };
|
|
498
325
|
});
|
|
326
|
+
}
|
|
499
327
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
328
|
+
function stripLeadingManualBullet(run) {
|
|
329
|
+
for (const ch of MANUAL_BULLET_CHARS_STRIP) {
|
|
330
|
+
if (run.text.indexOf(ch) === 0) {
|
|
331
|
+
run.text = run.text.slice(1).replace(/^\s+/, '');
|
|
332
|
+
return;
|
|
504
333
|
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
505
336
|
|
|
506
|
-
|
|
507
|
-
|
|
337
|
+
// ---- 4.2 占位符 ----
|
|
338
|
+
function processPlaceholder(el) {
|
|
339
|
+
consumed.add(el);
|
|
340
|
+
Array.from(el.querySelectorAll('*')).forEach((d) => consumed.add(d));
|
|
341
|
+
const rect = rectOf(el);
|
|
342
|
+
if (rect.w === 0 || rect.h === 0) {
|
|
343
|
+
const dim = rect.w === 0 ? 'width' : 'height';
|
|
344
|
+
const id = el.id || 'unnamed';
|
|
345
|
+
errors.push(`占位区 #${id} 渲染出来的${dim === 'width' ? '宽度' : '高度'}是 0,检查它的 CSS 布局`);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const id = el.id || 'placeholder-' + placeholders.length;
|
|
349
|
+
placeholders.push({ id, x: toIn(rect.x), y: toIn(rect.y), w: toIn(rect.w), h: toIn(rect.h) });
|
|
350
|
+
}
|
|
508
351
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
const
|
|
512
|
-
|
|
513
|
-
|
|
352
|
+
// ---- 4.3 图片 ----
|
|
353
|
+
function processImage(el) {
|
|
354
|
+
const rect = rectOf(el);
|
|
355
|
+
if (rect.w === 0 || rect.h === 0) return;
|
|
356
|
+
elements.push({ type: 'image', src: el.src, x: toIn(rect.x), y: toIn(rect.y), w: toIn(rect.w), h: toIn(rect.h) });
|
|
357
|
+
}
|
|
514
358
|
|
|
515
|
-
|
|
516
|
-
|
|
359
|
+
// ---- 4.4 div ----
|
|
360
|
+
function buildBorderLines(rect, b) {
|
|
361
|
+
const out = [];
|
|
362
|
+
const sides = [
|
|
363
|
+
{ info: b.top, geo: () => ({ x: rect.x, y: rect.y + b.top.w / 2, w: rect.w, h: 0 }) },
|
|
364
|
+
{ info: b.right, geo: () => ({ x: rect.x + rect.w - b.right.w / 2, y: rect.y, w: 0, h: rect.h }) },
|
|
365
|
+
{ info: b.bottom, geo: () => ({ x: rect.x, y: rect.y + rect.h - b.bottom.w / 2, w: rect.w, h: 0 }) },
|
|
366
|
+
{ info: b.left, geo: () => ({ x: rect.x + b.left.w / 2, y: rect.y, w: 0, h: rect.h }) },
|
|
367
|
+
];
|
|
368
|
+
sides.forEach((s) => {
|
|
369
|
+
if (s.info.w > 0) {
|
|
370
|
+
const g = s.geo();
|
|
371
|
+
const c = parseColorString(s.info.c);
|
|
372
|
+
out.push({
|
|
373
|
+
type: 'line',
|
|
374
|
+
x: toIn(g.x),
|
|
375
|
+
y: toIn(g.y),
|
|
376
|
+
w: toIn(g.w),
|
|
377
|
+
h: toIn(g.h),
|
|
378
|
+
color: c ? c.hex : '000000',
|
|
379
|
+
widthPt: toPt(s.info.w),
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
517
385
|
|
|
518
|
-
|
|
519
|
-
|
|
386
|
+
function processDiv(div) {
|
|
387
|
+
// 1: 裸文字校验(永远执行,不因为后面的 background-image 违规而跳过)
|
|
388
|
+
const rawDirectText = Array.from(div.childNodes)
|
|
389
|
+
.filter((n) => n.nodeType === 3)
|
|
390
|
+
.map((n) => n.textContent)
|
|
391
|
+
.join('')
|
|
392
|
+
.trim();
|
|
393
|
+
if (rawDirectText) {
|
|
520
394
|
errors.push(
|
|
521
|
-
|
|
522
|
-
'then reference with background-image: url(\'gradient.png\')'
|
|
395
|
+
`<div> 里直接写了文字「${truncate(rawDirectText, 50)}」,PPT 里不会出现——请用 <p>、<h1>-<h6>、<ul>/<ol> 包起来`
|
|
523
396
|
);
|
|
524
397
|
}
|
|
525
398
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
path: urlMatch[1]
|
|
534
|
-
};
|
|
399
|
+
// 2: background-image 校验(命中时不产出 shape/line,但不消费该节点,子孙继续遍历)
|
|
400
|
+
const cs = getComputedStyle(div);
|
|
401
|
+
if (cs.backgroundImage && cs.backgroundImage !== 'none') {
|
|
402
|
+
if (/gradient/i.test(cs.backgroundImage)) {
|
|
403
|
+
errors.push(
|
|
404
|
+
'<div> 背景不能用 CSS 渐变:换成纯色 background-color,或把渐变先导出成 PNG 再用 <img> / slide.addImage() 放上去'
|
|
405
|
+
);
|
|
535
406
|
} else {
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
};
|
|
407
|
+
errors.push(
|
|
408
|
+
'<div> 不能用 background-image:图片请改成 <img> 标签,或用 slide.addImage() 单独叠一层'
|
|
409
|
+
);
|
|
540
410
|
}
|
|
541
|
-
|
|
542
|
-
background = {
|
|
543
|
-
type: 'color',
|
|
544
|
-
value: rgbToHex(bgColor)
|
|
545
|
-
};
|
|
411
|
+
return;
|
|
546
412
|
}
|
|
547
413
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
const placeholders = [];
|
|
551
|
-
const textTags = ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'UL', 'OL', 'LI'];
|
|
552
|
-
const processed = new Set();
|
|
553
|
-
|
|
554
|
-
document.querySelectorAll('*').forEach((el) => {
|
|
555
|
-
if (processed.has(el)) return;
|
|
556
|
-
|
|
557
|
-
// [data-pptx-merge="true"] — opt-in: merge all <p>/<h1>-<h6> descendants
|
|
558
|
-
// into ONE PowerPoint text frame (single editable text box).
|
|
559
|
-
// Each child paragraph becomes a run with breakLine:true at the end;
|
|
560
|
-
// per-paragraph fontSize/color/bold/italic/underline are preserved as run options.
|
|
561
|
-
// The container's bg/border (if any) still becomes its own shape, same as a normal div.
|
|
562
|
-
if (el.tagName === 'DIV' && el.dataset && el.dataset.pptxMerge === 'true') {
|
|
563
|
-
const containerRect = el.getBoundingClientRect();
|
|
564
|
-
if (containerRect.width === 0 || containerRect.height === 0) {
|
|
565
|
-
processed.add(el);
|
|
566
|
-
return;
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
// Reject nested merge containers — undefined behavior.
|
|
570
|
-
if (el.querySelector('[data-pptx-merge="true"]')) {
|
|
571
|
-
errors.push(
|
|
572
|
-
`data-pptx-merge container cannot contain another data-pptx-merge container. ` +
|
|
573
|
-
'Nested merge is not supported.'
|
|
574
|
-
);
|
|
575
|
-
processed.add(el);
|
|
576
|
-
return;
|
|
577
|
-
}
|
|
414
|
+
const rect = rectOf(div);
|
|
415
|
+
if (rect.w === 0 || rect.h === 0) return;
|
|
578
416
|
|
|
579
|
-
|
|
417
|
+
const bgColor = parseColorString(cs.backgroundColor);
|
|
418
|
+
const hasBg = isVisibleColor(bgColor);
|
|
419
|
+
const b = getBordersInfo(cs);
|
|
420
|
+
const hasBorder = b.top.w > 0 || b.right.w > 0 || b.bottom.w > 0 || b.left.w > 0;
|
|
421
|
+
const hasUniformBorder = hasBorder && b.top.w === b.right.w && b.right.w === b.bottom.w && b.bottom.w === b.left.w;
|
|
580
422
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
errors.push(
|
|
584
|
-
'Background images on data-pptx-merge container are not supported. ' +
|
|
585
|
-
'Use solid colors or borders, or layer images via slide.addImage().'
|
|
586
|
-
);
|
|
587
|
-
return;
|
|
588
|
-
}
|
|
423
|
+
let shapeEl = null;
|
|
424
|
+
let lineEls = [];
|
|
589
425
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
mergeComputed.borderTopWidth,
|
|
594
|
-
mergeComputed.borderRightWidth,
|
|
595
|
-
mergeComputed.borderBottomWidth,
|
|
596
|
-
mergeComputed.borderLeftWidth
|
|
597
|
-
].map(b => parseFloat(b) || 0);
|
|
598
|
-
const mHasBorder = mBorders.some(b => b > 0);
|
|
599
|
-
const mHasUniformBorder = mHasBorder && mBorders.every(b => b === mBorders[0]);
|
|
600
|
-
|
|
601
|
-
if (mHasBg || mHasUniformBorder) {
|
|
602
|
-
elements.push({
|
|
603
|
-
type: 'shape',
|
|
604
|
-
text: '',
|
|
605
|
-
position: {
|
|
606
|
-
x: pxToInch(containerRect.left),
|
|
607
|
-
y: pxToInch(containerRect.top),
|
|
608
|
-
w: pxToInch(containerRect.width),
|
|
609
|
-
h: pxToInch(containerRect.height)
|
|
610
|
-
},
|
|
611
|
-
shape: {
|
|
612
|
-
fill: mHasBg ? rgbToHex(mergeComputed.backgroundColor) : null,
|
|
613
|
-
transparency: mHasBg ? extractAlpha(mergeComputed.backgroundColor) : null,
|
|
614
|
-
line: mHasUniformBorder ? {
|
|
615
|
-
color: rgbToHex(mergeComputed.borderColor),
|
|
616
|
-
width: pxToPoints(mergeComputed.borderWidth)
|
|
617
|
-
} : null,
|
|
618
|
-
rectRadius: (() => {
|
|
619
|
-
const radius = mergeComputed.borderRadius;
|
|
620
|
-
const radiusValue = parseFloat(radius);
|
|
621
|
-
if (radiusValue === 0) return 0;
|
|
622
|
-
if (radius.includes('%')) {
|
|
623
|
-
if (radiusValue >= 50) return 1;
|
|
624
|
-
const minDim = Math.min(containerRect.width, containerRect.height);
|
|
625
|
-
return (radiusValue / 100) * pxToInch(minDim);
|
|
626
|
-
}
|
|
627
|
-
if (radius.includes('pt')) return radiusValue / 72;
|
|
628
|
-
return radiusValue / PX_PER_IN;
|
|
629
|
-
})(),
|
|
630
|
-
shadow: parseBoxShadow(mergeComputed.boxShadow)
|
|
631
|
-
}
|
|
632
|
-
});
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
// Collect <p>/<h*> descendants in document order.
|
|
636
|
-
const textDescendants = Array.from(el.querySelectorAll('p, h1, h2, h3, h4, h5, h6'));
|
|
637
|
-
if (textDescendants.length === 0) {
|
|
638
|
-
errors.push(
|
|
639
|
-
`data-pptx-merge container has no <p>/<h*> children to merge. ` +
|
|
640
|
-
'Remove the data-pptx-merge attribute or add text elements.'
|
|
641
|
-
);
|
|
642
|
-
processed.add(el);
|
|
643
|
-
return;
|
|
644
|
-
}
|
|
426
|
+
if (hasBorder && !hasUniformBorder) {
|
|
427
|
+
lineEls = buildBorderLines(rect, b);
|
|
428
|
+
}
|
|
645
429
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
// Container padding becomes the textbox internal margin (PptxGenJS: [left, right, bottom, top]).
|
|
660
|
-
margin: [
|
|
661
|
-
pxToPoints(mergeComputed.paddingLeft),
|
|
662
|
-
pxToPoints(mergeComputed.paddingRight),
|
|
663
|
-
pxToPoints(mergeComputed.paddingBottom),
|
|
664
|
-
pxToPoints(mergeComputed.paddingTop)
|
|
665
|
-
]
|
|
430
|
+
if (hasBg || hasBorder) {
|
|
431
|
+
if (hasBg || hasUniformBorder) {
|
|
432
|
+
const shadow = parseBoxShadow(cs.boxShadow);
|
|
433
|
+
shapeEl = {
|
|
434
|
+
type: 'shape',
|
|
435
|
+
x: toIn(rect.x),
|
|
436
|
+
y: toIn(rect.y),
|
|
437
|
+
w: toIn(rect.w),
|
|
438
|
+
h: toIn(rect.h),
|
|
439
|
+
fill: hasBg ? fillFieldOf(bgColor) : null,
|
|
440
|
+
line: hasUniformBorder ? { color: parseColorString(b.top.c).hex, width: toPt(b.top.w) } : null,
|
|
441
|
+
rectRadius: computeRectRadius(cs, rect.w, rect.h),
|
|
442
|
+
shadow,
|
|
666
443
|
};
|
|
667
|
-
const baseTransparency = extractAlpha(firstComputed.color);
|
|
668
|
-
if (baseTransparency !== null) baseStyle.transparency = baseTransparency;
|
|
669
|
-
|
|
670
|
-
// Build the merged runs.
|
|
671
|
-
const mergedRuns = [];
|
|
672
|
-
textDescendants.forEach((textEl, idx) => {
|
|
673
|
-
const isLast = idx === textDescendants.length - 1;
|
|
674
|
-
const tComputed = window.getComputedStyle(textEl);
|
|
675
|
-
const transformStr = tComputed.textTransform;
|
|
676
|
-
|
|
677
|
-
// Per-paragraph style overrides — only include if they differ from base.
|
|
678
|
-
const elemFontSize = pxToPoints(tComputed.fontSize);
|
|
679
|
-
const elemFontFace = tComputed.fontFamily.split(',')[0].replace(/['"]/g, '').trim();
|
|
680
|
-
const elemColor = rgbToHex(tComputed.color);
|
|
681
|
-
const elemBold = tComputed.fontWeight === 'bold' || parseInt(tComputed.fontWeight) >= 600;
|
|
682
|
-
const elemItalic = tComputed.fontStyle === 'italic';
|
|
683
|
-
const elemUnderline = tComputed.textDecoration.includes('underline');
|
|
684
|
-
|
|
685
|
-
const runBaseOptions = {};
|
|
686
|
-
if (elemFontSize !== baseStyle.fontSize) runBaseOptions.fontSize = elemFontSize;
|
|
687
|
-
if (elemFontFace !== baseStyle.fontFace) runBaseOptions.fontFace = elemFontFace;
|
|
688
|
-
if (elemColor !== baseStyle.color) runBaseOptions.color = elemColor;
|
|
689
|
-
if (elemBold && !shouldSkipBold(tComputed.fontFamily)) runBaseOptions.bold = true;
|
|
690
|
-
if (elemItalic) runBaseOptions.italic = true;
|
|
691
|
-
if (elemUnderline) runBaseOptions.underline = true;
|
|
692
|
-
|
|
693
|
-
const hasInline = textEl.querySelector('b, i, u, strong, em, span, br');
|
|
694
|
-
let runs;
|
|
695
|
-
if (hasInline) {
|
|
696
|
-
runs = parseInlineFormatting(
|
|
697
|
-
textEl,
|
|
698
|
-
runBaseOptions,
|
|
699
|
-
[],
|
|
700
|
-
(str) => applyTextTransform(str, transformStr)
|
|
701
|
-
);
|
|
702
|
-
} else {
|
|
703
|
-
const txt = applyTextTransform(textEl.textContent.trim(), transformStr);
|
|
704
|
-
if (!txt) return;
|
|
705
|
-
runs = [{ text: txt, options: { ...runBaseOptions } }];
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
if (runs.length > 0 && !isLast) {
|
|
709
|
-
runs[runs.length - 1].options.breakLine = true;
|
|
710
|
-
}
|
|
711
|
-
mergedRuns.push(...runs);
|
|
712
|
-
processed.add(textEl);
|
|
713
|
-
});
|
|
714
|
-
|
|
715
|
-
if (mergedRuns.length === 0) {
|
|
716
|
-
processed.add(el);
|
|
717
|
-
return;
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
elements.push({
|
|
721
|
-
type: 'merged-text',
|
|
722
|
-
items: mergedRuns,
|
|
723
|
-
position: {
|
|
724
|
-
x: pxToInch(containerRect.left),
|
|
725
|
-
y: pxToInch(containerRect.top),
|
|
726
|
-
w: pxToInch(containerRect.width),
|
|
727
|
-
h: pxToInch(containerRect.height)
|
|
728
|
-
},
|
|
729
|
-
style: baseStyle
|
|
730
|
-
});
|
|
731
|
-
|
|
732
|
-
processed.add(el);
|
|
733
|
-
return;
|
|
734
444
|
}
|
|
445
|
+
consumed.add(div);
|
|
446
|
+
}
|
|
735
447
|
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
const hasBg = computed.backgroundColor && computed.backgroundColor !== 'rgba(0, 0, 0, 0)';
|
|
740
|
-
const hasBorder = (computed.borderWidth && parseFloat(computed.borderWidth) > 0) ||
|
|
741
|
-
(computed.borderTopWidth && parseFloat(computed.borderTopWidth) > 0) ||
|
|
742
|
-
(computed.borderRightWidth && parseFloat(computed.borderRightWidth) > 0) ||
|
|
743
|
-
(computed.borderBottomWidth && parseFloat(computed.borderBottomWidth) > 0) ||
|
|
744
|
-
(computed.borderLeftWidth && parseFloat(computed.borderLeftWidth) > 0);
|
|
745
|
-
const hasShadow = computed.boxShadow && computed.boxShadow !== 'none';
|
|
746
|
-
|
|
747
|
-
if (hasBg || hasBorder || hasShadow) {
|
|
748
|
-
errors.push(
|
|
749
|
-
`Text element <${el.tagName.toLowerCase()}> has ${hasBg ? 'background' : hasBorder ? 'border' : 'shadow'}. ` +
|
|
750
|
-
'Backgrounds, borders, and shadows are only supported on <div> elements, not text elements.'
|
|
751
|
-
);
|
|
752
|
-
return;
|
|
753
|
-
}
|
|
754
|
-
}
|
|
448
|
+
if (shapeEl) elements.push(shapeEl);
|
|
449
|
+
lineEls.forEach((l) => elements.push(l));
|
|
450
|
+
}
|
|
755
451
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
} else {
|
|
764
|
-
placeholders.push({
|
|
765
|
-
id: el.id || `placeholder-${placeholders.length}`,
|
|
766
|
-
x: pxToInch(rect.left),
|
|
767
|
-
y: pxToInch(rect.top),
|
|
768
|
-
w: pxToInch(rect.width),
|
|
769
|
-
h: pxToInch(rect.height)
|
|
770
|
-
});
|
|
771
|
-
}
|
|
772
|
-
processed.add(el);
|
|
773
|
-
return;
|
|
774
|
-
}
|
|
452
|
+
// ---- 4.1 data-pptx-merge 容器 ----
|
|
453
|
+
function processMergeContainer(container) {
|
|
454
|
+
const rect = rectOf(container);
|
|
455
|
+
if (rect.w === 0 || rect.h === 0) {
|
|
456
|
+
consumed.add(container);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
775
459
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
src: el.src,
|
|
783
|
-
position: {
|
|
784
|
-
x: pxToInch(rect.left),
|
|
785
|
-
y: pxToInch(rect.top),
|
|
786
|
-
w: pxToInch(rect.width),
|
|
787
|
-
h: pxToInch(rect.height)
|
|
788
|
-
}
|
|
789
|
-
});
|
|
790
|
-
processed.add(el);
|
|
791
|
-
return;
|
|
792
|
-
}
|
|
793
|
-
}
|
|
460
|
+
const nested = container.querySelectorAll('[data-pptx-merge="true"]');
|
|
461
|
+
if (nested.length > 0) {
|
|
462
|
+
errors.push('data-pptx-merge containers cannot be nested. Remove the inner data-pptx-merge="true" attribute.');
|
|
463
|
+
consumed.add(container);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
794
466
|
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
if (
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
);
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
}
|
|
467
|
+
const cs = getComputedStyle(container);
|
|
468
|
+
if (cs.backgroundImage && cs.backgroundImage !== 'none') {
|
|
469
|
+
if (/gradient/i.test(cs.backgroundImage)) {
|
|
470
|
+
errors.push(
|
|
471
|
+
'CSS gradients are not supported on data-pptx-merge containers. Use a solid background-color, or overlay the gradient with slide.addImage().'
|
|
472
|
+
);
|
|
473
|
+
} else {
|
|
474
|
+
errors.push(
|
|
475
|
+
'data-pptx-merge containers do not support background-image. Use a solid background-color/border, or overlay the picture with slide.addImage().'
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
consumed.add(container);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
813
481
|
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
}
|
|
482
|
+
const paragraphs = Array.from(container.querySelectorAll('p,h1,h2,h3,h4,h5,h6'));
|
|
483
|
+
if (paragraphs.length === 0) {
|
|
484
|
+
errors.push(
|
|
485
|
+
'data-pptx-merge container has no mergeable text elements. Remove data-pptx-merge or add <p>/<h1>-<h6> text inside it.'
|
|
486
|
+
);
|
|
487
|
+
consumed.add(container);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
823
490
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
const inset = (widthPt / 72) / 2; // Convert points to inches, then half
|
|
845
|
-
borderLines.push({
|
|
846
|
-
type: 'line',
|
|
847
|
-
x1: x, y1: y + inset, x2: x + w, y2: y + inset,
|
|
848
|
-
width: widthPt,
|
|
849
|
-
color: rgbToHex(computed.borderTopColor)
|
|
850
|
-
});
|
|
851
|
-
}
|
|
852
|
-
if (parseFloat(borderRight) > 0) {
|
|
853
|
-
const widthPt = pxToPoints(borderRight);
|
|
854
|
-
const inset = (widthPt / 72) / 2;
|
|
855
|
-
borderLines.push({
|
|
856
|
-
type: 'line',
|
|
857
|
-
x1: x + w - inset, y1: y, x2: x + w - inset, y2: y + h,
|
|
858
|
-
width: widthPt,
|
|
859
|
-
color: rgbToHex(computed.borderRightColor)
|
|
860
|
-
});
|
|
861
|
-
}
|
|
862
|
-
if (parseFloat(borderBottom) > 0) {
|
|
863
|
-
const widthPt = pxToPoints(borderBottom);
|
|
864
|
-
const inset = (widthPt / 72) / 2;
|
|
865
|
-
borderLines.push({
|
|
866
|
-
type: 'line',
|
|
867
|
-
x1: x, y1: y + h - inset, x2: x + w, y2: y + h - inset,
|
|
868
|
-
width: widthPt,
|
|
869
|
-
color: rgbToHex(computed.borderBottomColor)
|
|
870
|
-
});
|
|
871
|
-
}
|
|
872
|
-
if (parseFloat(borderLeft) > 0) {
|
|
873
|
-
const widthPt = pxToPoints(borderLeft);
|
|
874
|
-
const inset = (widthPt / 72) / 2;
|
|
875
|
-
borderLines.push({
|
|
876
|
-
type: 'line',
|
|
877
|
-
x1: x + inset, y1: y, x2: x + inset, y2: y + h,
|
|
878
|
-
width: widthPt,
|
|
879
|
-
color: rgbToHex(computed.borderLeftColor)
|
|
880
|
-
});
|
|
881
|
-
}
|
|
882
|
-
}
|
|
491
|
+
const bgColor = parseColorString(cs.backgroundColor);
|
|
492
|
+
const hasBg = isVisibleColor(bgColor);
|
|
493
|
+
const b = getBordersInfo(cs);
|
|
494
|
+
const hasUniformBorder = b.top.w > 0 && b.top.w === b.right.w && b.right.w === b.bottom.w && b.bottom.w === b.left.w;
|
|
495
|
+
|
|
496
|
+
let shapeEl = null;
|
|
497
|
+
if (hasBg || hasUniformBorder) {
|
|
498
|
+
const shadow = parseBoxShadow(cs.boxShadow);
|
|
499
|
+
shapeEl = {
|
|
500
|
+
type: 'shape',
|
|
501
|
+
x: toIn(rect.x),
|
|
502
|
+
y: toIn(rect.y),
|
|
503
|
+
w: toIn(rect.w),
|
|
504
|
+
h: toIn(rect.h),
|
|
505
|
+
fill: hasBg ? fillFieldOf(bgColor) : null,
|
|
506
|
+
line: hasUniformBorder ? { color: parseColorString(b.top.c).hex, width: toPt(b.top.w) } : null,
|
|
507
|
+
rectRadius: computeRectRadius(cs, rect.w, rect.h),
|
|
508
|
+
shadow,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
883
511
|
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
rectRadius: (() => {
|
|
912
|
-
const radius = computed.borderRadius;
|
|
913
|
-
const radiusValue = parseFloat(radius);
|
|
914
|
-
if (radiusValue === 0) return 0;
|
|
915
|
-
|
|
916
|
-
if (radius.includes('%')) {
|
|
917
|
-
if (radiusValue >= 50) return 1;
|
|
918
|
-
// Calculate percentage of smaller dimension
|
|
919
|
-
const minDim = Math.min(rect.width, rect.height);
|
|
920
|
-
return (radiusValue / 100) * pxToInch(minDim);
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
if (radius.includes('pt')) return radiusValue / 72;
|
|
924
|
-
return radiusValue / PX_PER_IN;
|
|
925
|
-
})(),
|
|
926
|
-
shadow: shadow
|
|
927
|
-
}
|
|
928
|
-
});
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
// Add partial border lines
|
|
932
|
-
elements.push(...borderLines);
|
|
933
|
-
|
|
934
|
-
processed.add(el);
|
|
935
|
-
return;
|
|
936
|
-
}
|
|
937
|
-
}
|
|
512
|
+
// 框级基准样式取第一个文字后代
|
|
513
|
+
const baseEl = paragraphs[0];
|
|
514
|
+
const baseCs = getComputedStyle(baseEl);
|
|
515
|
+
const baseFontSizePt = parseFloat(baseCs.fontSize) * PT_PER_PX;
|
|
516
|
+
const baseFontFace = firstFontFamily(baseCs.fontFamily);
|
|
517
|
+
const baseColorParsed = parseColorString(baseCs.color);
|
|
518
|
+
const baseAlign = normalizeAlign(baseCs.textAlign);
|
|
519
|
+
const baseLineSpacingPt = parseLineHeightPt(baseCs.lineHeight, baseFontSizePt);
|
|
520
|
+
|
|
521
|
+
const marginArr = [
|
|
522
|
+
toPt(parsePx(cs.paddingLeft)),
|
|
523
|
+
toPt(parsePx(cs.paddingRight)),
|
|
524
|
+
toPt(parsePx(cs.paddingBottom)),
|
|
525
|
+
toPt(parsePx(cs.paddingTop)),
|
|
526
|
+
];
|
|
527
|
+
|
|
528
|
+
paragraphs.forEach((p) => consumed.add(p));
|
|
529
|
+
consumed.add(container);
|
|
530
|
+
|
|
531
|
+
const allRuns = [];
|
|
532
|
+
paragraphs.forEach((p, idx) => {
|
|
533
|
+
const marginErrs = [];
|
|
534
|
+
const runs = buildRuns(p, marginErrs);
|
|
535
|
+
errors.push(...marginErrs);
|
|
536
|
+
if (runs.length === 0) return;
|
|
537
|
+
if (idx < paragraphs.length - 1) {
|
|
538
|
+
runs[runs.length - 1].options.breakLine = true;
|
|
938
539
|
}
|
|
540
|
+
allRuns.push(...runs);
|
|
541
|
+
});
|
|
939
542
|
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
if (runs.length > 0) {
|
|
960
|
-
runs[0].text = runs[0].text.replace(/^[•\-\*▪▸]\s*/, '');
|
|
961
|
-
runs[0].options.bullet = { indent: textIndent };
|
|
962
|
-
}
|
|
963
|
-
// Set breakLine on last run
|
|
964
|
-
if (runs.length > 0 && !isLast) {
|
|
965
|
-
runs[runs.length - 1].options.breakLine = true;
|
|
966
|
-
}
|
|
967
|
-
items.push(...runs);
|
|
968
|
-
});
|
|
969
|
-
|
|
970
|
-
const computed = window.getComputedStyle(liElements[0] || el);
|
|
971
|
-
|
|
972
|
-
elements.push({
|
|
973
|
-
type: 'list',
|
|
974
|
-
items: items,
|
|
975
|
-
position: {
|
|
976
|
-
x: pxToInch(rect.left),
|
|
977
|
-
y: pxToInch(rect.top),
|
|
978
|
-
w: pxToInch(rect.width),
|
|
979
|
-
h: pxToInch(rect.height)
|
|
980
|
-
},
|
|
981
|
-
style: {
|
|
982
|
-
fontSize: pxToPoints(computed.fontSize),
|
|
983
|
-
fontFace: computed.fontFamily.split(',')[0].replace(/['"]/g, '').trim(),
|
|
984
|
-
color: rgbToHex(computed.color),
|
|
985
|
-
transparency: extractAlpha(computed.color),
|
|
986
|
-
align: computed.textAlign === 'start' ? 'left' : computed.textAlign,
|
|
987
|
-
lineSpacing: computed.lineHeight && computed.lineHeight !== 'normal' ? pxToPoints(computed.lineHeight) : null,
|
|
988
|
-
paraSpaceBefore: 0,
|
|
989
|
-
paraSpaceAfter: pxToPoints(computed.marginBottom),
|
|
990
|
-
// PptxGenJS margin array is [left, right, bottom, top]
|
|
991
|
-
margin: [marginLeft, 0, 0, 0]
|
|
992
|
-
}
|
|
993
|
-
});
|
|
543
|
+
if (allRuns.length === 0) return;
|
|
544
|
+
|
|
545
|
+
if (shapeEl) elements.push(shapeEl);
|
|
546
|
+
elements.push({
|
|
547
|
+
type: 'merged-text',
|
|
548
|
+
x: toIn(rect.x),
|
|
549
|
+
y: toIn(rect.y),
|
|
550
|
+
w: toIn(rect.w),
|
|
551
|
+
h: toIn(rect.h),
|
|
552
|
+
fontSize: baseFontSizePt,
|
|
553
|
+
fontFace: baseFontFace,
|
|
554
|
+
color: baseColorParsed ? baseColorParsed.hex : null,
|
|
555
|
+
transparency: baseColorParsed && baseColorParsed.hasAlpha ? Math.round((1 - baseColorParsed.alpha) * 100) : null,
|
|
556
|
+
align: baseAlign,
|
|
557
|
+
lineSpacing: baseLineSpacingPt,
|
|
558
|
+
marginArr,
|
|
559
|
+
items: allRuns,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
994
562
|
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
563
|
+
// ---- 4.6 列表 ----
|
|
564
|
+
function processList(listEl) {
|
|
565
|
+
const rect = rectOf(listEl);
|
|
566
|
+
if (rect.w === 0 || rect.h === 0) return;
|
|
567
|
+
|
|
568
|
+
const cs = getComputedStyle(listEl);
|
|
569
|
+
const paddingLeftPt = toPt(parsePx(cs.paddingLeft));
|
|
570
|
+
const half = paddingLeftPt / 2;
|
|
571
|
+
|
|
572
|
+
const liEls = Array.from(listEl.querySelectorAll('li'));
|
|
573
|
+
liEls.forEach((li) => consumed.add(li));
|
|
574
|
+
consumed.add(listEl);
|
|
575
|
+
if (liEls.length === 0) return;
|
|
576
|
+
|
|
577
|
+
const baseCs = getComputedStyle(liEls[0]);
|
|
578
|
+
const baseFontSizePt = parseFloat(baseCs.fontSize) * PT_PER_PX;
|
|
579
|
+
const baseFontFace = firstFontFamily(baseCs.fontFamily);
|
|
580
|
+
const baseColorParsed = parseColorString(baseCs.color);
|
|
581
|
+
const baseAlign = normalizeAlign(baseCs.textAlign);
|
|
582
|
+
const baseLineSpacingPt = parseLineHeightPt(baseCs.lineHeight, baseFontSizePt);
|
|
583
|
+
const paraSpaceAfterPt = toPt(parsePx(baseCs.marginBottom));
|
|
584
|
+
|
|
585
|
+
const allRuns = [];
|
|
586
|
+
liEls.forEach((li, idx) => {
|
|
587
|
+
const marginErrs = [];
|
|
588
|
+
const runs = buildRuns(li, marginErrs);
|
|
589
|
+
errors.push(...marginErrs);
|
|
590
|
+
if (runs.length === 0) return;
|
|
591
|
+
stripLeadingManualBullet(runs[0]);
|
|
592
|
+
runs[0].options.bullet = { indent: half };
|
|
593
|
+
if (idx < liEls.length - 1) {
|
|
594
|
+
runs[runs.length - 1].options.breakLine = true;
|
|
998
595
|
}
|
|
596
|
+
allRuns.push(...runs);
|
|
597
|
+
});
|
|
999
598
|
|
|
1000
|
-
|
|
1001
|
-
|
|
599
|
+
if (allRuns.length === 0) return;
|
|
600
|
+
|
|
601
|
+
elements.push({
|
|
602
|
+
type: 'list',
|
|
603
|
+
x: toIn(rect.x),
|
|
604
|
+
y: toIn(rect.y),
|
|
605
|
+
w: toIn(rect.w),
|
|
606
|
+
h: toIn(rect.h),
|
|
607
|
+
fontSize: baseFontSizePt,
|
|
608
|
+
fontFace: baseFontFace,
|
|
609
|
+
color: baseColorParsed ? baseColorParsed.hex : null,
|
|
610
|
+
transparency: baseColorParsed && baseColorParsed.hasAlpha ? Math.round((1 - baseColorParsed.alpha) * 100) : null,
|
|
611
|
+
align: baseAlign,
|
|
612
|
+
lineSpacing: baseLineSpacingPt,
|
|
613
|
+
paraSpaceAfter: paraSpaceAfterPt,
|
|
614
|
+
marginArr: [half, 0, 0, 0],
|
|
615
|
+
items: allRuns,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
1002
618
|
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
619
|
+
// ---- 4.5 单个文本标签(含 4.5 preamble 提到的 li 兜底分支)----
|
|
620
|
+
function processTextElement(el) {
|
|
621
|
+
const tag = el.tagName;
|
|
622
|
+
const rect = rectOf(el);
|
|
623
|
+
const collapsedFull = collapseWs(el.textContent).trim();
|
|
1006
624
|
|
|
1007
|
-
|
|
1008
|
-
|
|
625
|
+
if (rect.w === 0 || rect.h === 0 || collapsedFull === '') return;
|
|
626
|
+
|
|
627
|
+
if (tag !== 'LI') {
|
|
628
|
+
const m = /^([•\-*▪▸○●◆◇■□])\s/.exec(collapsedFull);
|
|
629
|
+
if (m) {
|
|
1009
630
|
errors.push(
|
|
1010
|
-
|
|
1011
|
-
'Use <ul> or <ol> lists instead of manual bullet symbols.'
|
|
631
|
+
`<${tag.toLowerCase()}> 开头手打了项目符号「${truncate(collapsedFull, 20)}」——列表请用 <ul>/<ol>,不要自己打圆点`
|
|
1012
632
|
);
|
|
1013
633
|
return;
|
|
1014
634
|
}
|
|
635
|
+
}
|
|
1015
636
|
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
637
|
+
const cs = getComputedStyle(el);
|
|
638
|
+
const rotation = computeRotationDeg(cs);
|
|
639
|
+
const geomPx = computeGeometryPx(el, rotation);
|
|
640
|
+
|
|
641
|
+
const fontSizePt = parseFloat(cs.fontSize) * PT_PER_PX;
|
|
642
|
+
const fontFace = firstFontFamily(cs.fontFamily);
|
|
643
|
+
const colorParsed = parseColorString(cs.color);
|
|
644
|
+
const align = normalizeAlign(cs.textAlign);
|
|
645
|
+
const lineSpacingPt = parseLineHeightPt(cs.lineHeight, fontSizePt);
|
|
646
|
+
const marginTopPt = toPt(parsePx(cs.marginTop));
|
|
647
|
+
const marginBottomPt = toPt(parsePx(cs.marginBottom));
|
|
648
|
+
const paddingArr = [
|
|
649
|
+
toPt(parsePx(cs.paddingLeft)),
|
|
650
|
+
toPt(parsePx(cs.paddingRight)),
|
|
651
|
+
toPt(parsePx(cs.paddingBottom)),
|
|
652
|
+
toPt(parsePx(cs.paddingTop)),
|
|
653
|
+
];
|
|
654
|
+
const hasPadding = paddingArr.some((v) => v !== 0);
|
|
655
|
+
|
|
656
|
+
let boldBase = cs.fontWeight === 'bold' || parseInt(cs.fontWeight, 10) >= 600;
|
|
657
|
+
if (isSingleWeightFont(fontFace)) boldBase = false;
|
|
658
|
+
const italicBase = cs.fontStyle === 'italic';
|
|
659
|
+
const decoBase = cs.textDecorationLine || cs.textDecoration || '';
|
|
660
|
+
const underlineBase = decoBase.indexOf('underline') !== -1;
|
|
661
|
+
|
|
662
|
+
let textOut;
|
|
663
|
+
let effectiveLineSpacing = lineSpacingPt;
|
|
664
|
+
|
|
665
|
+
if (hasInlineFormatting(el)) {
|
|
666
|
+
const marginErrs = [];
|
|
667
|
+
const runs = buildRuns(el, marginErrs);
|
|
668
|
+
errors.push(...marginErrs);
|
|
669
|
+
if (runs.length === 0) return;
|
|
670
|
+
textOut = runs;
|
|
671
|
+
const maxRunFontSize = Math.max.apply(
|
|
672
|
+
null,
|
|
673
|
+
runs.map((r) => r.options.fontSize)
|
|
674
|
+
);
|
|
675
|
+
if (maxRunFontSize > fontSizePt) {
|
|
676
|
+
effectiveLineSpacing = maxRunFontSize * (lineSpacingPt / fontSizePt);
|
|
677
|
+
}
|
|
678
|
+
} else {
|
|
679
|
+
textOut = applyTextTransform(collapsedFull, cs.textTransform);
|
|
680
|
+
}
|
|
1036
681
|
|
|
1037
|
-
|
|
1038
|
-
|
|
682
|
+
// 几何补偿(6.6):给单个 p/h1-h6 文本框的宽度加宽 2%,补偿 pptxgenjs/PowerPoint
|
|
683
|
+
// 对文字宽度的测量比浏览器偏窄这个已知误差。
|
|
684
|
+
const heightIn = geomPx.h / 96;
|
|
685
|
+
let xIn = geomPx.x / 96;
|
|
686
|
+
let yIn = geomPx.y / 96;
|
|
687
|
+
let wIn = geomPx.w / 96;
|
|
688
|
+
let hIn = heightIn;
|
|
689
|
+
{
|
|
690
|
+
const widen = wIn * 0.02;
|
|
691
|
+
if (align === 'right') {
|
|
692
|
+
xIn -= widen;
|
|
693
|
+
wIn += widen;
|
|
694
|
+
} else if (align === 'center') {
|
|
695
|
+
xIn -= widen / 2;
|
|
696
|
+
wIn += widen;
|
|
697
|
+
} else {
|
|
698
|
+
wIn += widen;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
1039
701
|
|
|
1040
|
-
|
|
702
|
+
const outEl = {
|
|
703
|
+
type: tag.toLowerCase(),
|
|
704
|
+
x: xIn,
|
|
705
|
+
y: yIn,
|
|
706
|
+
w: wIn,
|
|
707
|
+
h: hIn,
|
|
708
|
+
fontSize: fontSizePt,
|
|
709
|
+
fontFace,
|
|
710
|
+
color: colorParsed ? colorParsed.hex : null,
|
|
711
|
+
transparency: colorParsed && colorParsed.hasAlpha ? Math.round((1 - colorParsed.alpha) * 100) : null,
|
|
712
|
+
align,
|
|
713
|
+
lineSpacing: effectiveLineSpacing,
|
|
714
|
+
paraSpaceBefore: marginTopPt,
|
|
715
|
+
paraSpaceAfter: marginBottomPt,
|
|
716
|
+
marginArr: hasPadding ? paddingArr : null,
|
|
717
|
+
rotate: rotation !== 0 ? rotation : null,
|
|
718
|
+
text: textOut,
|
|
719
|
+
};
|
|
720
|
+
if (typeof textOut === 'string') {
|
|
721
|
+
outEl.bold = boldBase;
|
|
722
|
+
outEl.italic = italicBase;
|
|
723
|
+
outEl.underline = underlineBase;
|
|
724
|
+
}
|
|
725
|
+
elements.push(outEl);
|
|
726
|
+
}
|
|
1041
727
|
|
|
1042
|
-
|
|
728
|
+
// ---- 背景 ----
|
|
729
|
+
function extractBackground() {
|
|
730
|
+
const cs = getComputedStyle(document.body);
|
|
731
|
+
if (cs.backgroundImage && cs.backgroundImage !== 'none') {
|
|
732
|
+
const m = /url\((['"]?)(.*?)\1\)/.exec(cs.backgroundImage);
|
|
733
|
+
if (m) return { type: 'image', value: m[2] };
|
|
734
|
+
}
|
|
735
|
+
const c = parseColorString(cs.backgroundColor);
|
|
736
|
+
if (isVisibleColor(c)) return { type: 'color', value: c.hex };
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
1043
739
|
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
const transformStr = computed.textTransform;
|
|
1047
|
-
const runs = parseInlineFormatting(el, {}, [], (str) => applyTextTransform(str, transformStr));
|
|
740
|
+
// ---- 主遍历 ----
|
|
741
|
+
const allNodes = Array.from(document.body.querySelectorAll('*'));
|
|
1048
742
|
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
if (adjustedStyle.lineSpacing) {
|
|
1052
|
-
const maxFontSize = Math.max(
|
|
1053
|
-
adjustedStyle.fontSize,
|
|
1054
|
-
...runs.map(r => r.options?.fontSize || 0)
|
|
1055
|
-
);
|
|
1056
|
-
if (maxFontSize > adjustedStyle.fontSize) {
|
|
1057
|
-
const lineHeightMultiplier = adjustedStyle.lineSpacing / adjustedStyle.fontSize;
|
|
1058
|
-
adjustedStyle.lineSpacing = maxFontSize * lineHeightMultiplier;
|
|
1059
|
-
}
|
|
1060
|
-
}
|
|
743
|
+
for (const el of allNodes) {
|
|
744
|
+
const tag = el.tagName;
|
|
1061
745
|
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
// Plain text - inherit CSS formatting
|
|
1070
|
-
const textTransform = computed.textTransform;
|
|
1071
|
-
const transformedText = applyTextTransform(text, textTransform);
|
|
1072
|
-
|
|
1073
|
-
const isBold = computed.fontWeight === 'bold' || parseInt(computed.fontWeight) >= 600;
|
|
1074
|
-
|
|
1075
|
-
elements.push({
|
|
1076
|
-
type: el.tagName.toLowerCase(),
|
|
1077
|
-
text: transformedText,
|
|
1078
|
-
position: { x: pxToInch(x), y: pxToInch(y), w: pxToInch(w), h: pxToInch(h) },
|
|
1079
|
-
style: {
|
|
1080
|
-
...baseStyle,
|
|
1081
|
-
bold: isBold && !shouldSkipBold(computed.fontFamily),
|
|
1082
|
-
italic: computed.fontStyle === 'italic',
|
|
1083
|
-
underline: computed.textDecoration.includes('underline')
|
|
1084
|
-
}
|
|
1085
|
-
});
|
|
746
|
+
if (TEXT_TAG_BG_CHECK.has(tag)) {
|
|
747
|
+
const v = violationOf(el);
|
|
748
|
+
if (v) {
|
|
749
|
+
errors.push(
|
|
750
|
+
`文字标签 <${tag.toLowerCase()}> 上设置了 ${v}:背景、边框、阴影只能加在 <div> 上,请在外面套一层 <div>`
|
|
751
|
+
);
|
|
752
|
+
continue;
|
|
1086
753
|
}
|
|
754
|
+
}
|
|
1087
755
|
|
|
1088
|
-
|
|
1089
|
-
});
|
|
1090
|
-
|
|
1091
|
-
return { background, elements, placeholders, errors };
|
|
1092
|
-
});
|
|
1093
|
-
}
|
|
756
|
+
if (consumed.has(el)) continue;
|
|
1094
757
|
|
|
1095
|
-
|
|
1096
|
-
const {
|
|
1097
|
-
tmpDir = process.env.TMPDIR || '/tmp',
|
|
1098
|
-
slide = null
|
|
1099
|
-
} = options;
|
|
758
|
+
const classAttr = el.getAttribute('class') || '';
|
|
1100
759
|
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
760
|
+
if (el.getAttribute('data-pptx-merge') === 'true') {
|
|
761
|
+
processMergeContainer(el);
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
if (classAttr.indexOf('placeholder') !== -1) {
|
|
765
|
+
processPlaceholder(el);
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
if (tag === 'IMG') {
|
|
769
|
+
processImage(el);
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
if (tag === 'DIV') {
|
|
773
|
+
processDiv(el);
|
|
774
|
+
continue;
|
|
1106
775
|
}
|
|
776
|
+
if (tag === 'UL' || tag === 'OL') {
|
|
777
|
+
processList(el);
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
if (TEXT_ELEMENT_TAGS.has(tag)) {
|
|
781
|
+
processTextElement(el);
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
// 其它任何元素:忽略,不产出、不报错
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const bodyCs = getComputedStyle(document.body);
|
|
788
|
+
return {
|
|
789
|
+
background: extractBackground(),
|
|
790
|
+
elements,
|
|
791
|
+
placeholders,
|
|
792
|
+
errors,
|
|
793
|
+
body: {
|
|
794
|
+
width: parseFloat(bodyCs.width),
|
|
795
|
+
height: parseFloat(bodyCs.height),
|
|
796
|
+
scrollWidth: document.body.scrollWidth,
|
|
797
|
+
scrollHeight: document.body.scrollHeight,
|
|
798
|
+
},
|
|
799
|
+
};
|
|
800
|
+
}
|
|
1107
801
|
|
|
1108
|
-
|
|
802
|
+
// ============================================================================
|
|
803
|
+
// Node 侧:写入 pptxgenjs
|
|
804
|
+
// ============================================================================
|
|
1109
805
|
|
|
1110
|
-
|
|
1111
|
-
|
|
806
|
+
function stripFileProtocol(p) {
|
|
807
|
+
return p.replace(/^file:\/\//, '');
|
|
808
|
+
}
|
|
1112
809
|
|
|
1113
|
-
|
|
1114
|
-
|
|
810
|
+
function previewTextOf(el) {
|
|
811
|
+
if (typeof el.text === 'string') return el.text;
|
|
812
|
+
const arr = Array.isArray(el.text) ? el.text : Array.isArray(el.items) ? el.items : [];
|
|
813
|
+
for (const r of arr) {
|
|
814
|
+
if (r && typeof r.text === 'string' && r.text.trim() !== '') return r.text;
|
|
815
|
+
}
|
|
816
|
+
return '';
|
|
817
|
+
}
|
|
1115
818
|
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
819
|
+
function writeElement(slide, pres, el) {
|
|
820
|
+
switch (el.type) {
|
|
821
|
+
case 'image': {
|
|
822
|
+
slide.addImage({ path: stripFileProtocol(el.src), x: el.x, y: el.y, w: el.w, h: el.h });
|
|
823
|
+
break;
|
|
824
|
+
}
|
|
825
|
+
case 'line': {
|
|
826
|
+
slide.addShape(pres.ShapeType.line, {
|
|
827
|
+
x: el.x,
|
|
828
|
+
y: el.y,
|
|
829
|
+
w: el.w,
|
|
830
|
+
h: el.h,
|
|
831
|
+
line: { color: el.color, width: el.widthPt },
|
|
1121
832
|
});
|
|
833
|
+
break;
|
|
834
|
+
}
|
|
835
|
+
case 'shape': {
|
|
836
|
+
const shapeName = el.rectRadius > 0 ? pres.ShapeType.roundRect : pres.ShapeType.rect;
|
|
837
|
+
const opts = { x: el.x, y: el.y, w: el.w, h: el.h, shape: shapeName };
|
|
838
|
+
if (el.fill) opts.fill = el.fill;
|
|
839
|
+
if (el.line) opts.line = el.line;
|
|
840
|
+
if (el.rectRadius > 0) opts.rectRadius = el.rectRadius;
|
|
841
|
+
if (el.shadow) opts.shadow = el.shadow;
|
|
842
|
+
slide.addText('', opts);
|
|
843
|
+
break;
|
|
844
|
+
}
|
|
845
|
+
case 'list': {
|
|
846
|
+
const opts = {
|
|
847
|
+
x: el.x,
|
|
848
|
+
y: el.y,
|
|
849
|
+
w: el.w,
|
|
850
|
+
h: el.h,
|
|
851
|
+
fontSize: el.fontSize,
|
|
852
|
+
fontFace: el.fontFace,
|
|
853
|
+
color: el.color,
|
|
854
|
+
align: el.align,
|
|
855
|
+
valign: 'top',
|
|
856
|
+
lineSpacing: el.lineSpacing,
|
|
857
|
+
paraSpaceBefore: 0,
|
|
858
|
+
paraSpaceAfter: el.paraSpaceAfter,
|
|
859
|
+
margin: el.marginArr,
|
|
860
|
+
};
|
|
861
|
+
if (el.transparency != null) opts.transparency = el.transparency;
|
|
862
|
+
slide.addText(el.items, opts);
|
|
863
|
+
break;
|
|
864
|
+
}
|
|
865
|
+
case 'merged-text': {
|
|
866
|
+
const opts = {
|
|
867
|
+
x: el.x,
|
|
868
|
+
y: el.y,
|
|
869
|
+
w: el.w,
|
|
870
|
+
h: el.h,
|
|
871
|
+
fontSize: el.fontSize,
|
|
872
|
+
fontFace: el.fontFace,
|
|
873
|
+
color: el.color,
|
|
874
|
+
align: el.align,
|
|
875
|
+
valign: 'top',
|
|
876
|
+
lineSpacing: el.lineSpacing,
|
|
877
|
+
paraSpaceBefore: 0,
|
|
878
|
+
paraSpaceAfter: 0,
|
|
879
|
+
margin: el.marginArr,
|
|
880
|
+
};
|
|
881
|
+
if (el.transparency != null) opts.transparency = el.transparency;
|
|
882
|
+
slide.addText(el.items, opts);
|
|
883
|
+
break;
|
|
884
|
+
}
|
|
885
|
+
default: {
|
|
886
|
+
// p / h1-h6 / (兜底的 li):单个文本框
|
|
887
|
+
const opts = {
|
|
888
|
+
x: el.x,
|
|
889
|
+
y: el.y,
|
|
890
|
+
w: el.w,
|
|
891
|
+
h: el.h,
|
|
892
|
+
fontSize: el.fontSize,
|
|
893
|
+
fontFace: el.fontFace,
|
|
894
|
+
color: el.color,
|
|
895
|
+
align: el.align,
|
|
896
|
+
valign: 'top',
|
|
897
|
+
lineSpacing: el.lineSpacing,
|
|
898
|
+
paraSpaceBefore: el.paraSpaceBefore,
|
|
899
|
+
paraSpaceAfter: el.paraSpaceAfter,
|
|
900
|
+
inset: 0,
|
|
901
|
+
};
|
|
902
|
+
if (typeof el.text === 'string') {
|
|
903
|
+
opts.bold = el.bold;
|
|
904
|
+
opts.italic = el.italic;
|
|
905
|
+
opts.underline = el.underline;
|
|
906
|
+
}
|
|
907
|
+
if (el.marginArr) opts.margin = el.marginArr;
|
|
908
|
+
if (el.rotate) opts.rotate = el.rotate;
|
|
909
|
+
if (el.transparency != null) opts.transparency = el.transparency;
|
|
910
|
+
slide.addText(el.text, opts);
|
|
911
|
+
break;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
1122
915
|
|
|
1123
|
-
|
|
916
|
+
module.exports = async function html2pptx(htmlFile, pres, options) {
|
|
917
|
+
options = options || {};
|
|
918
|
+
const absHtmlPath = path.isAbsolute(htmlFile) ? htmlFile : path.resolve(process.cwd(), htmlFile);
|
|
919
|
+
const tmpDir = options.tmpDir || process.env.TMPDIR || '/tmp';
|
|
1124
920
|
|
|
1125
|
-
|
|
921
|
+
function finalizeError(rawMessage) {
|
|
922
|
+
let message = rawMessage;
|
|
923
|
+
if (!message.startsWith(htmlFile)) {
|
|
924
|
+
message = `${htmlFile}: ${message}`;
|
|
925
|
+
}
|
|
926
|
+
return new Error(message);
|
|
927
|
+
}
|
|
1126
928
|
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
929
|
+
let browser;
|
|
930
|
+
let extraction;
|
|
931
|
+
try {
|
|
932
|
+
const launchOptions = { env: Object.assign({}, process.env, { TMPDIR: tmpDir }) };
|
|
933
|
+
if (process.platform === 'darwin') launchOptions.channel = 'chrome';
|
|
934
|
+
browser = await chromium.launch(launchOptions);
|
|
935
|
+
const page = await browser.newPage();
|
|
936
|
+
await page.goto('file://' + absHtmlPath);
|
|
937
|
+
|
|
938
|
+
const initialBodySize = await page.evaluate(() => {
|
|
939
|
+
const cs = getComputedStyle(document.body);
|
|
940
|
+
return { width: parseFloat(cs.width), height: parseFloat(cs.height) };
|
|
941
|
+
});
|
|
942
|
+
await page.setViewportSize({
|
|
943
|
+
width: Math.max(1, Math.round(initialBodySize.width)),
|
|
944
|
+
height: Math.max(1, Math.round(initialBodySize.height)),
|
|
945
|
+
});
|
|
1131
946
|
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
947
|
+
extraction = await page.evaluate(extractPageDataInBrowser);
|
|
948
|
+
} finally {
|
|
949
|
+
if (browser) await browser.close();
|
|
950
|
+
}
|
|
1136
951
|
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
validationErrors.push(...bodyDimensions.errors);
|
|
1140
|
-
}
|
|
952
|
+
// ---- 整页级校验(第 5 节),和提取阶段收集到的错误合并 ----
|
|
953
|
+
const errors = extraction.errors.slice();
|
|
1141
954
|
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
}
|
|
955
|
+
const widthOverflowPx = Math.max(0, extraction.body.scrollWidth - extraction.body.width - 1);
|
|
956
|
+
const heightOverflowPx = Math.max(0, extraction.body.scrollHeight - extraction.body.height - 1);
|
|
957
|
+
if (widthOverflowPx > 0) {
|
|
958
|
+
errors.push(`内容横向超出页面 ${(widthOverflowPx * PT_PER_PX).toFixed(1)}pt`);
|
|
959
|
+
}
|
|
960
|
+
if (heightOverflowPx > 0) {
|
|
961
|
+
errors.push(
|
|
962
|
+
`内容纵向超出页面 ${(heightOverflowPx * PT_PER_PX).toFixed(1)}pt(页面底部要留出 0.5 英寸空白)`
|
|
963
|
+
);
|
|
964
|
+
}
|
|
1146
965
|
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
966
|
+
let slideHeightIn = null;
|
|
967
|
+
if (pres.presLayout) {
|
|
968
|
+
const layoutWIn = pres.presLayout.width / EMU_PER_INCH;
|
|
969
|
+
const layoutHIn = pres.presLayout.height / EMU_PER_INCH;
|
|
970
|
+
const bodyWIn = extraction.body.width * IN_PER_PX;
|
|
971
|
+
const bodyHIn = extraction.body.height * IN_PER_PX;
|
|
972
|
+
if (Math.abs(bodyWIn - layoutWIn) > 0.1 || Math.abs(bodyHIn - layoutHIn) > 0.1) {
|
|
973
|
+
errors.push(
|
|
974
|
+
`页面尺寸不一致:HTML 的 body 是 ${bodyWIn.toFixed(1)}×${bodyHIn.toFixed(1)} 英寸,PPT 版式是 ${layoutWIn.toFixed(1)}×${layoutHIn.toFixed(1)} 英寸`
|
|
975
|
+
);
|
|
1150
976
|
}
|
|
977
|
+
slideHeightIn = layoutHIn;
|
|
978
|
+
}
|
|
1151
979
|
|
|
1152
|
-
|
|
1153
|
-
|
|
980
|
+
if (slideHeightIn != null) {
|
|
981
|
+
const CHECK_TYPES = new Set(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'list', 'merged-text']);
|
|
982
|
+
for (const el of extraction.elements) {
|
|
983
|
+
if (!CHECK_TYPES.has(el.type)) continue;
|
|
984
|
+
if (!(el.fontSize > 12)) continue;
|
|
985
|
+
const distance = slideHeightIn - (el.y + el.h);
|
|
986
|
+
if (distance < 0.5) {
|
|
987
|
+
const preview = previewTextOf(el).slice(0, 50);
|
|
988
|
+
errors.push(`文本框「${preview}」离页面底边只有 ${distance.toFixed(2)} 英寸,至少要留 0.5 英寸`);
|
|
989
|
+
}
|
|
1154
990
|
}
|
|
991
|
+
}
|
|
1155
992
|
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
993
|
+
if (errors.length > 0) {
|
|
994
|
+
let message;
|
|
995
|
+
if (errors.length === 1) {
|
|
996
|
+
message = errors[0];
|
|
997
|
+
} else {
|
|
998
|
+
message = `发现 ${errors.length} 处问题:\n` + errors.map((e, i) => ` ${i + 1}. ${e}`).join('\n');
|
|
1162
999
|
}
|
|
1000
|
+
throw finalizeError(message);
|
|
1001
|
+
}
|
|
1163
1002
|
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
await addBackground(slideData, targetSlide, tmpDir);
|
|
1167
|
-
addElements(slideData, targetSlide, pres);
|
|
1003
|
+
// ---- 写入 slide ----
|
|
1004
|
+
const slide = options.slide || pres.addSlide();
|
|
1168
1005
|
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1006
|
+
if (extraction.background) {
|
|
1007
|
+
if (extraction.background.type === 'image') {
|
|
1008
|
+
slide.background = { path: stripFileProtocol(extraction.background.value) };
|
|
1009
|
+
} else {
|
|
1010
|
+
slide.background = { color: extraction.background.value };
|
|
1173
1011
|
}
|
|
1174
|
-
throw error;
|
|
1175
1012
|
}
|
|
1176
|
-
}
|
|
1177
1013
|
|
|
1178
|
-
|
|
1014
|
+
for (const el of extraction.elements) {
|
|
1015
|
+
writeElement(slide, pres, el);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
return { slide, placeholders: extraction.placeholders };
|
|
1019
|
+
};
|