wizz-method 2.12.0 → 2.14.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.
Files changed (119) hide show
  1. package/README.md +2 -1
  2. package/THIRD-PARTY-NOTICES.md +2 -0
  3. package/package.json +10 -3
  4. package/skills-registry.yaml +48 -48
  5. package/src/bmm-skills/1-analysis/research/wizz-domain-research/customize.toml +4 -3
  6. package/src/bmm-skills/1-analysis/research/wizz-market-research/customize.toml +4 -3
  7. package/src/bmm-skills/1-analysis/research/wizz-technical-research/customize.toml +3 -1
  8. package/src/bmm-skills/1-analysis/wizz-agent-analyst/customize.toml +3 -1
  9. package/src/bmm-skills/1-analysis/wizz-agent-tech-writer/customize.toml +4 -3
  10. package/src/bmm-skills/1-analysis/wizz-document-project/customize.toml +4 -3
  11. package/src/bmm-skills/1-analysis/wizz-prfaq/customize.toml +4 -3
  12. package/src/bmm-skills/1-analysis/wizz-product-brief/customize.toml +5 -6
  13. package/src/bmm-skills/2-plan-workflows/wizz-agent-pm/customize.toml +3 -1
  14. package/src/bmm-skills/2-plan-workflows/wizz-agent-ux-designer/customize.toml +3 -1
  15. package/src/bmm-skills/2-plan-workflows/wizz-create-prd/customize.toml +4 -3
  16. package/src/bmm-skills/2-plan-workflows/wizz-edit-prd/customize.toml +4 -3
  17. package/src/bmm-skills/2-plan-workflows/wizz-prd/customize.toml +5 -6
  18. package/src/bmm-skills/2-plan-workflows/wizz-ux/customize.toml +4 -3
  19. package/src/bmm-skills/2-plan-workflows/wizz-validate-prd/customize.toml +4 -3
  20. package/src/bmm-skills/3-solutioning/wizz-agent-architect/customize.toml +3 -1
  21. package/src/bmm-skills/3-solutioning/wizz-architecture/customize.toml +5 -6
  22. package/src/bmm-skills/3-solutioning/wizz-check-implementation-readiness/customize.toml +4 -3
  23. package/src/bmm-skills/3-solutioning/wizz-create-architecture/customize.toml +4 -3
  24. package/src/bmm-skills/3-solutioning/wizz-create-epics-and-stories/customize.toml +4 -3
  25. package/src/bmm-skills/3-solutioning/wizz-generate-project-context/steps/step-03-complete.md +7 -0
  26. package/src/bmm-skills/4-implementation/wizz-agent-dev/customize.toml +3 -1
  27. package/src/bmm-skills/4-implementation/wizz-checkpoint-preview/customize.toml +4 -3
  28. package/src/bmm-skills/4-implementation/wizz-code-review/customize.toml +4 -3
  29. package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-03-triage.md +23 -14
  30. package/src/bmm-skills/4-implementation/wizz-code-review/steps/step-04-present.md +2 -2
  31. package/src/bmm-skills/4-implementation/wizz-correct-course/customize.toml +4 -3
  32. package/src/bmm-skills/4-implementation/wizz-create-story/customize.toml +4 -3
  33. package/src/bmm-skills/4-implementation/wizz-dev-story/customize.toml +4 -3
  34. package/src/bmm-skills/4-implementation/wizz-investigate/customize.toml +4 -3
  35. package/src/bmm-skills/4-implementation/wizz-qa-generate-e2e-tests/customize.toml +4 -3
  36. package/src/bmm-skills/4-implementation/wizz-quick-dev/customize.toml +4 -3
  37. package/src/bmm-skills/4-implementation/wizz-quick-dev/spec-template.md +6 -0
  38. package/src/bmm-skills/4-implementation/wizz-quick-dev/step-04-review.md +27 -16
  39. package/src/bmm-skills/4-implementation/wizz-quick-dev/step-oneshot.md +27 -6
  40. package/src/bmm-skills/4-implementation/wizz-retrospective/customize.toml +4 -3
  41. package/src/bmm-skills/4-implementation/wizz-sprint-planning/customize.toml +4 -3
  42. package/src/bmm-skills/4-implementation/wizz-sprint-status/customize.toml +4 -3
  43. package/src/bmm-skills/module.yaml +1 -1
  44. package/src/core-skills/wizz-brainstorming/customize.toml +5 -6
  45. package/src/core-skills/wizz-brainstorming/scripts/brain.py +19 -0
  46. package/src/core-skills/wizz-brainstorming/scripts/tests/test_brain.py +33 -0
  47. package/src/core-skills/wizz-forge-idea/scripts/resolve_personas.py +3 -1
  48. package/src/core-skills/wizz-forge-idea/scripts/tests/test_resolve_personas.py +21 -0
  49. package/src/core-skills/wizz-help/SKILL.md +5 -3
  50. package/src/core-skills/wizz-party-mode/customize.toml +5 -4
  51. package/src/core-skills/wizz-party-mode/scripts/resolve_party.py +3 -1
  52. package/src/core-skills/wizz-party-mode/scripts/tests/test_resolve_party.py +21 -0
  53. package/src/core-skills/wizz-spec/customize.toml +4 -5
  54. package/src/modules/wizz/_shared/model-ladder.md +4 -4
  55. package/src/modules/wizz/_shared/video-pipeline.md +1 -1
  56. package/src/modules/wizz/agents/wizz-maestro/SKILL.md +1 -1
  57. package/src/modules/wizz/subagents/opencode/wizz-exec-review.md +2 -2
  58. package/src/modules/wizz/subagents/opencode/wizz-exec-standard.md +2 -2
  59. package/src/modules/wizz/subagents/opencode/wizz-exec-strong.md +2 -2
  60. package/src/modules/wizz/subagents/spec.yaml +3 -3
  61. package/src/scripts/resolve_config.py +8 -0
  62. package/src/scripts/resolve_customization.py +24 -3
  63. package/src/scripts/tests/test_resolve_config.py +42 -0
  64. package/src/scripts/tests/test_resolve_customization.py +45 -0
  65. package/src/skills-lib/ctc-align/SKILL.md +1 -1
  66. package/src/skills-lib/wizz-agent-browser/SKILL.md +5 -0
  67. package/src/skills-lib/wizz-huashu-design/scripts/html2pptx.js +902 -1061
  68. package/src/skills-lib/wizz-humanizer/README.md +134 -71
  69. package/src/skills-lib/wizz-humanizer/SKILL.md +51 -37
  70. package/src/skills-lib/wizz-humanizer/references/ai-writing-patterns.md +208 -190
  71. package/src/skills-lib/wizz-router/references/routing-table-flat.md +2 -2
  72. package/tools/installer/banner.js +80 -29
  73. package/tools/installer/commands/catalog.js +87 -0
  74. package/tools/installer/commands/install.js +9 -0
  75. package/tools/installer/core/doctor.js +31 -1
  76. package/tools/installer/core/installer.js +32 -0
  77. package/tools/installer/core/project-instructions.js +199 -0
  78. package/tools/installer/guided/detect-tools.js +53 -0
  79. package/tools/installer/guided/flow.js +203 -0
  80. package/tools/installer/guided/labels.js +185 -0
  81. package/tools/installer/guided/messages.js +132 -0
  82. package/tools/installer/guided/outro.js +32 -0
  83. package/tools/installer/guided/profiles.js +95 -0
  84. package/tools/installer/guided/steps.js +154 -0
  85. package/tools/installer/install-messages.yaml +3 -3
  86. package/tools/installer/modules/external-manager.js +4 -0
  87. package/tools/installer/ui.js +57 -22
  88. package/wizz-modules.yaml +1 -0
  89. package/src/bmm-skills/3-solutioning/wizz-architecture/scripts/__pycache__/lint_spine.cpython-313.pyc +0 -0
  90. package/src/bmm-skills/3-solutioning/wizz-architecture/scripts/tests/__pycache__/test_lint_spine.cpython-313-pytest-9.1.1.pyc +0 -0
  91. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/__pycache__/sprint_status.cpython-313.pyc +0 -0
  92. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/tests/__pycache__/test_git_evidence.cpython-313-pytest-9.1.1.pyc +0 -0
  93. package/src/bmm-skills/4-implementation/wizz-retrospective/scripts/tests/__pycache__/test_sprint_status.cpython-313-pytest-9.1.1.pyc +0 -0
  94. package/src/bmm-skills/4-implementation/wizz-sprint-planning/scripts/__pycache__/sprint_plan.cpython-313.pyc +0 -0
  95. package/src/bmm-skills/4-implementation/wizz-sprint-planning/scripts/tests/__pycache__/test_sprint_plan.cpython-313-pytest-9.1.1.pyc +0 -0
  96. package/src/core-skills/wizz-advanced-elicitation/scripts/__pycache__/pick_methods.cpython-313.pyc +0 -0
  97. package/src/core-skills/wizz-advanced-elicitation/scripts/tests/__pycache__/test_pick_methods.cpython-313-pytest-9.1.1.pyc +0 -0
  98. package/src/core-skills/wizz-brainstorming/scripts/__pycache__/brain.cpython-313.pyc +0 -0
  99. package/src/core-skills/wizz-brainstorming/scripts/tests/__pycache__/test_brain.cpython-313-pytest-9.1.1.pyc +0 -0
  100. package/src/core-skills/wizz-brainstorming/scripts/tests/__pycache__/test_brain.cpython-314.pyc +0 -0
  101. package/src/core-skills/wizz-customize/scripts/__pycache__/list_customizable_skills.cpython-313.pyc +0 -0
  102. package/src/core-skills/wizz-customize/scripts/tests/__pycache__/test_list_customizable_skills.cpython-313-pytest-9.1.1.pyc +0 -0
  103. package/src/core-skills/wizz-forge-idea/scripts/__pycache__/resolve_personas.cpython-313.pyc +0 -0
  104. package/src/core-skills/wizz-forge-idea/scripts/__pycache__/resolve_personas.cpython-314.pyc +0 -0
  105. package/src/core-skills/wizz-forge-idea/scripts/tests/__pycache__/test_resolve_personas.cpython-313-pytest-9.1.1.pyc +0 -0
  106. package/src/core-skills/wizz-forge-idea/scripts/tests/__pycache__/test_resolve_personas.cpython-314.pyc +0 -0
  107. package/src/core-skills/wizz-party-mode/scripts/__pycache__/resolve_party.cpython-313.pyc +0 -0
  108. package/src/core-skills/wizz-party-mode/scripts/__pycache__/resolve_party.cpython-314.pyc +0 -0
  109. package/src/core-skills/wizz-party-mode/scripts/tests/__pycache__/test_resolve_party.cpython-313-pytest-9.1.1.pyc +0 -0
  110. package/src/core-skills/wizz-party-mode/scripts/tests/__pycache__/test_resolve_party.cpython-314.pyc +0 -0
  111. package/src/scripts/__pycache__/resolve_customization.cpython-314.pyc +0 -0
  112. package/src/skills-lib/wizz-huashu-design/package-lock.json +0 -846
  113. package/src/skills-lib/wizz-humanizer/WARP.md +0 -53
  114. package/src/skills-lib/wizz-humanizer/references/full-example.md +0 -72
  115. package/src/skills-lib/wizz-humanizer/references/personality-and-soul.md +0 -36
  116. package/src/skills-lib/wizz-react-components/package-lock.json +0 -231
  117. package/src/skills-lib/wizz-supabase-postgres-best-practices/CLAUDE.md +0 -68
  118. package/src/skills-lib/wizz-ui-ux-pro-max/scripts/__pycache__/core.cpython-314.pyc +0 -0
  119. package/src/skills-lib/wizz-ui-ux-pro-max/scripts/__pycache__/design_system.cpython-314.pyc +0 -0
@@ -1,1178 +1,1019 @@
1
- /**
2
- * html2pptx - Convert HTML slide to pptxgenjs slide with positioned elements
3
- *
4
- * USAGE:
5
- * const pptx = new pptxgen();
6
- * pptx.layout = 'LAYOUT_16x9'; // Must match HTML body dimensions
7
- *
8
- * const { slide, placeholders } = await html2pptx('slide.html', pptx);
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
- const { chromium } = require('playwright');
29
- const path = require('path');
30
- const sharp = require('sharp');
10
+ 'use strict';
31
11
 
32
- const PT_PER_PX = 0.75;
33
- const PX_PER_IN = 96;
34
- const EMU_PER_IN = 914400;
12
+ const path = require('path');
13
+ const { chromium } = require('playwright');
35
14
 
36
- // Helper: Get body dimensions and check for overflow
37
- async function getBodyDimensions(page) {
38
- const bodyDimensions = await page.evaluate(() => {
39
- const body = document.body;
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
- return {
43
- width: parseFloat(style.width),
44
- height: parseFloat(style.height),
45
- scrollWidth: body.scrollWidth,
46
- scrollHeight: body.scrollHeight
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
- const errors = [];
51
- const widthOverflowPx = Math.max(0, bodyDimensions.scrollWidth - bodyDimensions.width - 1);
52
- const heightOverflowPx = Math.max(0, bodyDimensions.scrollHeight - bodyDimensions.height - 1);
53
-
54
- const widthOverflowPt = widthOverflowPx * PT_PER_PX;
55
- const heightOverflowPt = heightOverflowPx * PT_PER_PX;
56
-
57
- if (widthOverflowPt > 0 || heightOverflowPt > 0) {
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 widthInches = bodyDimensions.width / PX_PER_IN;
72
- const heightInches = bodyDimensions.height / PX_PER_IN;
73
-
74
- if (pres.presLayout) {
75
- const layoutWidth = pres.presLayout.width / EMU_PER_IN;
76
- const layoutHeight = pres.presLayout.height / EMU_PER_IN;
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
- if (Math.abs(layoutWidth - widthInches) > 0.1 || Math.abs(layoutHeight - heightInches) > 0.1) {
79
- errors.push(
80
- `HTML dimensions (${widthInches.toFixed(1)}" × ${heightInches.toFixed(1)}") ` +
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 validateTextBoxPosition(slideData, bodyDimensions) {
89
- const errors = [];
90
- const slideHeightInches = bodyDimensions.height / PX_PER_IN;
91
- const minBottomMargin = 0.5; // 0.5 inches from bottom
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
- errors.push(
110
- `Text box "${textPrefix}" ends too close to bottom edge ` +
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
- return errors;
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
- // Helper: Add background to slide
121
- async function addBackground(slideData, targetSlide, tmpDir) {
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
- // Helper: Add elements to slide
133
- function addElements(slideData, targetSlide, pres) {
134
- for (const el of slideData.elements) {
135
- if (el.type === 'image') {
136
- let imagePath = el.src.startsWith('file://') ? el.src.replace('file://', '') : el.src;
137
- targetSlide.addImage({
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
- if (el.shape.fill) {
162
- shapeOptions.fill = { color: el.shape.fill };
163
- if (el.shape.transparency != null) shapeOptions.fill.transparency = el.shape.transparency;
164
- }
165
- if (el.shape.line) shapeOptions.line = el.shape.line;
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
- const textOptions = {
236
- x: adjustedX,
237
- y: el.position.y,
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
- if (el.style.align) textOptions.align = el.style.align;
254
- if (el.style.margin) textOptions.margin = el.style.margin;
255
- if (el.style.rotate !== undefined) textOptions.rotate = el.style.rotate;
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
- targetSlide.addText(el.text, textOptions);
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
- // Unit conversion helpers
281
- const pxToInch = (px) => px / PX_PER_IN;
282
- const pxToPoints = (pxStr) => parseFloat(pxStr) * PT_PER_PX;
283
- const rgbToHex = (rgbStr) => {
284
- // Handle transparent backgrounds by defaulting to white
285
- if (rgbStr === 'rgba(0, 0, 0, 0)' || rgbStr === 'transparent') return 'FFFFFF';
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
- const match = rgbStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
288
- if (!match) return 'FFFFFF';
289
- return match.slice(1).map(n => parseInt(n).toString(16).padStart(2, '0')).join('');
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
- const extractAlpha = (rgbStr) => {
293
- const match = rgbStr.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)/);
294
- if (!match || !match[4]) return null;
295
- const alpha = parseFloat(match[4]);
296
- return Math.round((1 - alpha) * 100);
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
- const applyTextTransform = (text, textTransform) => {
300
- if (textTransform === 'uppercase') return text.toUpperCase();
301
- if (textTransform === 'lowercase') return text.toLowerCase();
302
- if (textTransform === 'capitalize') {
303
- return text.replace(/\b\w/g, c => c.toUpperCase());
304
- }
305
- return text;
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
- // Extract rotation angle from CSS transform and writing-mode
309
- const getRotation = (transform, writingMode) => {
310
- let angle = 0;
311
-
312
- // Handle writing-mode first
313
- // PowerPoint: 90° = text rotated 90° clockwise (reads top to bottom, letters upright)
314
- // PowerPoint: 270° = text rotated 270° clockwise (reads bottom to top, letters upright)
315
- if (writingMode === 'vertical-rl') {
316
- // vertical-rl alone = text reads top to bottom = 90° in PowerPoint
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
- // Then add any transform rotation
324
- if (transform && transform !== 'none') {
325
- // Try to match rotate() function
326
- const rotateMatch = transform.match(/rotate\((-?\d+(?:\.\d+)?)deg\)/);
327
- if (rotateMatch) {
328
- angle += parseFloat(rotateMatch[1]);
329
- } else {
330
- // Browser may compute as matrix - extract rotation from matrix
331
- const matrixMatch = transform.match(/matrix\(([^)]+)\)/);
332
- if (matrixMatch) {
333
- const values = matrixMatch[1].split(',').map(parseFloat);
334
- // matrix(a, b, c, d, e, f) where rotation = atan2(b, a)
335
- const matrixAngle = Math.atan2(values[1], values[0]) * (180 / Math.PI);
336
- angle += Math.round(matrixAngle);
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
- // Normalize to 0-359 range
342
- angle = angle % 360;
343
- if (angle < 0) angle += 360;
344
-
345
- return angle === 0 ? null : angle;
346
- };
347
-
348
- // Get position/dimensions accounting for rotation
349
- const getPositionAndSize = (el, rect, rotation) => {
350
- if (rotation === null) {
351
- return { x: rect.left, y: rect.top, w: rect.width, h: rect.height };
352
- }
353
-
354
- // For 90° or 270° rotations, swap width and height
355
- // because PowerPoint applies rotation to the original (unrotated) box
356
- const isVertical = rotation === 90 || rotation === 270;
357
-
358
- if (isVertical) {
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
- // Extract numeric values (handles both px and pt units)
401
- const parts = boxShadow.match(/([-\d.]+)(px|pt)/g);
221
+ // ---- 4.5.3 内联富文本通用解析 ----
222
+ function hasInlineFormatting(el) {
223
+ return !!el.querySelector('span,b,strong,i,em,u,br');
224
+ }
402
225
 
403
- if (!parts || parts.length < 2) return null;
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
- const offsetX = parseFloat(parts[0]);
406
- const offsetY = parseFloat(parts[1]);
407
- const blur = parts.length > 2 ? parseFloat(parts[2]) : 0;
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
- // Calculate angle from offsets (in degrees, 0 = right, 90 = down)
410
- let angle = 0;
411
- if (offsetX !== 0 || offsetY !== 0) {
412
- angle = Math.atan2(offsetY, offsetX) * (180 / Math.PI);
413
- if (angle < 0) angle += 360;
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
- // Calculate offset distance (hypotenuse)
417
- const offset = Math.sqrt(offsetX * offsetX + offsetY * offsetY) * PT_PER_PX;
418
-
419
- // Extract opacity from rgba
420
- let opacity = 0.5;
421
- if (colorMatch) {
422
- const opacityMatch = colorMatch[0].match(/[\d.]+\)$/);
423
- if (opacityMatch) {
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
- return {
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
- // Parse inline formatting tags (<b>, <i>, <u>, <strong>, <em>, <span>) into text runs
439
- const parseInlineFormatting = (element, baseOptions = {}, runs = [], baseTextTransform = (x) => x) => {
440
- let prevNodeIsText = false;
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
- prevNodeIsText = isText;
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
- // Trim leading space from first run and trailing space from last run
501
- if (runs.length > 0) {
502
- runs[0].text = runs[0].text.replace(/^\s+/, '');
503
- runs[runs.length - 1].text = runs[runs.length - 1].text.replace(/\s+$/, '');
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
- return runs.filter(r => r.text.length > 0);
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
- // Extract background from body (image or color)
510
- const body = document.body;
511
- const bodyStyle = window.getComputedStyle(body);
512
- const bgImage = bodyStyle.backgroundImage;
513
- const bgColor = bodyStyle.backgroundColor;
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
- // Collect validation errors
516
- const errors = [];
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
- // Validate: Check for CSS gradients
519
- if (bgImage && (bgImage.includes('linear-gradient') || bgImage.includes('radial-gradient'))) {
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
- 'CSS gradients are not supported. Use Sharp to rasterize gradients as PNG images first, ' +
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
- let background;
527
- if (bgImage && bgImage !== 'none') {
528
- // Extract URL from url("...") or url(...)
529
- const urlMatch = bgImage.match(/url\(["']?([^"')]+)["']?\)/);
530
- if (urlMatch) {
531
- background = {
532
- type: 'image',
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
- background = {
537
- type: 'color',
538
- value: rgbToHex(bgColor)
539
- };
407
+ errors.push(
408
+ '<div> 不能用 background-image:图片请改成 <img> 标签,或用 slide.addImage() 单独叠一层'
409
+ );
540
410
  }
541
- } else {
542
- background = {
543
- type: 'color',
544
- value: rgbToHex(bgColor)
545
- };
411
+ return;
546
412
  }
547
413
 
548
- // Process all elements
549
- const elements = [];
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
- const mergeComputed = window.getComputedStyle(el);
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
- // Container background image — same restriction as regular divs.
582
- if (mergeComputed.backgroundImage && mergeComputed.backgroundImage !== 'none') {
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
- // Emit a shape for the container's bg/uniform-border (mirrors the regular div branch).
591
- const mHasBg = mergeComputed.backgroundColor && mergeComputed.backgroundColor !== 'rgba(0, 0, 0, 0)';
592
- const mBorders = [
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
- // Use the first text element's computed style as the textbox-level base
647
- // (align / lineSpacing / paraSpace are paragraph/textbox-level in pptxgenjs, not per-run).
648
- const firstComputed = window.getComputedStyle(textDescendants[0]);
649
- const baseStyle = {
650
- fontSize: pxToPoints(firstComputed.fontSize),
651
- fontFace: firstComputed.fontFamily.split(',')[0].replace(/['"]/g, '').trim(),
652
- color: rgbToHex(firstComputed.color),
653
- align: firstComputed.textAlign === 'start' ? 'left' : firstComputed.textAlign,
654
- lineSpacing: firstComputed.lineHeight && firstComputed.lineHeight !== 'normal'
655
- ? pxToPoints(firstComputed.lineHeight)
656
- : null,
657
- paraSpaceBefore: 0,
658
- paraSpaceAfter: 0,
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
- // Validate text elements don't have backgrounds, borders, or shadows
737
- if (textTags.includes(el.tagName)) {
738
- const computed = window.getComputedStyle(el);
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
- // Extract placeholder elements (for charts, etc.)
757
- if (el.className && el.className.includes('placeholder')) {
758
- const rect = el.getBoundingClientRect();
759
- if (rect.width === 0 || rect.height === 0) {
760
- errors.push(
761
- `Placeholder "${el.id || 'unnamed'}" has ${rect.width === 0 ? 'width: 0' : 'height: 0'}. Check the layout CSS.`
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
- // Extract images
777
- if (el.tagName === 'IMG') {
778
- const rect = el.getBoundingClientRect();
779
- if (rect.width > 0 && rect.height > 0) {
780
- elements.push({
781
- type: 'image',
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
- // Extract DIVs with backgrounds/borders as shapes
796
- const isContainer = el.tagName === 'DIV' && !textTags.includes(el.tagName);
797
- if (isContainer) {
798
- const computed = window.getComputedStyle(el);
799
- const hasBg = computed.backgroundColor && computed.backgroundColor !== 'rgba(0, 0, 0, 0)';
800
-
801
- // Validate: Check for unwrapped text content in DIV
802
- for (const node of el.childNodes) {
803
- if (node.nodeType === Node.TEXT_NODE) {
804
- const text = node.textContent.trim();
805
- if (text) {
806
- errors.push(
807
- `DIV element contains unwrapped text "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}". ` +
808
- 'All text must be wrapped in <p>, <h1>-<h6>, <ul>, or <ol> tags to appear in PowerPoint.'
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
- // Check for background images on shapes
815
- const bgImage = computed.backgroundImage;
816
- if (bgImage && bgImage !== 'none') {
817
- errors.push(
818
- 'Background images on DIV elements are not supported. ' +
819
- 'Use solid colors or borders for shapes, or use slide.addImage() in PptxGenJS to layer images.'
820
- );
821
- return;
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
- // Check for borders - both uniform and partial
825
- const borderTop = computed.borderTopWidth;
826
- const borderRight = computed.borderRightWidth;
827
- const borderBottom = computed.borderBottomWidth;
828
- const borderLeft = computed.borderLeftWidth;
829
- const borders = [borderTop, borderRight, borderBottom, borderLeft].map(b => parseFloat(b) || 0);
830
- const hasBorder = borders.some(b => b > 0);
831
- const hasUniformBorder = hasBorder && borders.every(b => b === borders[0]);
832
- const borderLines = [];
833
-
834
- if (hasBorder && !hasUniformBorder) {
835
- const rect = el.getBoundingClientRect();
836
- const x = pxToInch(rect.left);
837
- const y = pxToInch(rect.top);
838
- const w = pxToInch(rect.width);
839
- const h = pxToInch(rect.height);
840
-
841
- // Collect lines to add after shape (inset by half the line width to center on edge)
842
- if (parseFloat(borderTop) > 0) {
843
- const widthPt = pxToPoints(borderTop);
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
- if (hasBg || hasBorder) {
885
- const rect = el.getBoundingClientRect();
886
- if (rect.width > 0 && rect.height > 0) {
887
- const shadow = parseBoxShadow(computed.boxShadow);
888
-
889
- // Only add shape if there's background or uniform border
890
- if (hasBg || hasUniformBorder) {
891
- elements.push({
892
- type: 'shape',
893
- text: '', // Shape only - child text elements render on top
894
- position: {
895
- x: pxToInch(rect.left),
896
- y: pxToInch(rect.top),
897
- w: pxToInch(rect.width),
898
- h: pxToInch(rect.height)
899
- },
900
- shape: {
901
- fill: hasBg ? rgbToHex(computed.backgroundColor) : null,
902
- transparency: hasBg ? extractAlpha(computed.backgroundColor) : null,
903
- line: hasUniformBorder ? {
904
- color: rgbToHex(computed.borderColor),
905
- width: pxToPoints(computed.borderWidth)
906
- } : null,
907
- // Convert border-radius to rectRadius (in inches)
908
- // % values: 50%+ = circle (1), <50% = percentage of min dimension
909
- // pt values: divide by 72 (72pt = 1 inch)
910
- // px values: divide by 96 (96px = 1 inch)
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
- // Extract bullet lists as single text block
941
- if (el.tagName === 'UL' || el.tagName === 'OL') {
942
- const rect = el.getBoundingClientRect();
943
- if (rect.width === 0 || rect.height === 0) return;
944
-
945
- const liElements = Array.from(el.querySelectorAll('li'));
946
- const items = [];
947
- const ulComputed = window.getComputedStyle(el);
948
- const ulPaddingLeftPt = pxToPoints(ulComputed.paddingLeft);
949
-
950
- // Split: margin-left for bullet position, indent for text position
951
- // margin-left + indent = ul padding-left
952
- const marginLeft = ulPaddingLeftPt * 0.5;
953
- const textIndent = ulPaddingLeftPt * 0.5;
954
-
955
- liElements.forEach((li, idx) => {
956
- const isLast = idx === liElements.length - 1;
957
- const runs = parseInlineFormatting(li, { breakLine: false });
958
- // Clean manual bullets from first run
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
- liElements.forEach(li => processed.add(li));
996
- processed.add(el);
997
- return;
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
- // Extract text elements (P, H1, H2, etc.)
1001
- if (!textTags.includes(el.tagName)) return;
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
- const rect = el.getBoundingClientRect();
1004
- const text = el.textContent.trim();
1005
- if (rect.width === 0 || rect.height === 0 || !text) return;
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
- // Validate: Check for manual bullet symbols in text elements (not in lists)
1008
- if (el.tagName !== 'LI' && /^[•\-\*▪▸○●◆◇■□]\s/.test(text.trimStart())) {
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
- `Text element <${el.tagName.toLowerCase()}> starts with bullet symbol "${text.substring(0, 20)}...". ` +
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
- const computed = window.getComputedStyle(el);
1017
- const rotation = getRotation(computed.transform, computed.writingMode);
1018
- const { x, y, w, h } = getPositionAndSize(el, rect, rotation);
1019
-
1020
- const baseStyle = {
1021
- fontSize: pxToPoints(computed.fontSize),
1022
- fontFace: computed.fontFamily.split(',')[0].replace(/['"]/g, '').trim(),
1023
- color: rgbToHex(computed.color),
1024
- align: computed.textAlign === 'start' ? 'left' : computed.textAlign,
1025
- lineSpacing: pxToPoints(computed.lineHeight),
1026
- paraSpaceBefore: pxToPoints(computed.marginTop),
1027
- paraSpaceAfter: pxToPoints(computed.marginBottom),
1028
- // PptxGenJS margin array is [left, right, bottom, top] (not [top, right, bottom, left] as documented)
1029
- margin: [
1030
- pxToPoints(computed.paddingLeft),
1031
- pxToPoints(computed.paddingRight),
1032
- pxToPoints(computed.paddingBottom),
1033
- pxToPoints(computed.paddingTop)
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
- const transparency = extractAlpha(computed.color);
1038
- if (transparency !== null) baseStyle.transparency = transparency;
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
- if (rotation !== null) baseStyle.rotate = rotation;
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
- const hasFormatting = el.querySelector('b, i, u, strong, em, span, br');
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
- if (hasFormatting) {
1045
- // Text with inline formatting
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
- // Adjust lineSpacing based on largest fontSize in runs
1050
- const adjustedStyle = { ...baseStyle };
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
- elements.push({
1063
- type: el.tagName.toLowerCase(),
1064
- text: runs,
1065
- position: { x: pxToInch(x), y: pxToInch(y), w: pxToInch(w), h: pxToInch(h) },
1066
- style: adjustedStyle
1067
- });
1068
- } else {
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
- processed.add(el);
1089
- });
1090
-
1091
- return { background, elements, placeholders, errors };
1092
- });
1093
- }
756
+ if (consumed.has(el)) continue;
1094
757
 
1095
- async function html2pptx(htmlFile, pres, options = {}) {
1096
- const {
1097
- tmpDir = process.env.TMPDIR || '/tmp',
1098
- slide = null
1099
- } = options;
758
+ const classAttr = el.getAttribute('class') || '';
1100
759
 
1101
- try {
1102
- // Use Chrome on macOS, default Chromium on Unix
1103
- const launchOptions = { env: { TMPDIR: tmpDir } };
1104
- if (process.platform === 'darwin') {
1105
- launchOptions.channel = 'chrome';
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
- const browser = await chromium.launch(launchOptions);
802
+ // ============================================================================
803
+ // Node 侧:写入 pptxgenjs
804
+ // ============================================================================
1109
805
 
1110
- let bodyDimensions;
1111
- let slideData;
806
+ function stripFileProtocol(p) {
807
+ return p.replace(/^file:\/\//, '');
808
+ }
1112
809
 
1113
- const filePath = path.isAbsolute(htmlFile) ? htmlFile : path.join(process.cwd(), htmlFile);
1114
- const validationErrors = [];
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
- try {
1117
- const page = await browser.newPage();
1118
- page.on('console', (msg) => {
1119
- // Log the message text to your test runner's console
1120
- console.log(`Browser console: ${msg.text()}`);
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
- await page.goto(`file://${filePath}`);
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
- bodyDimensions = await getBodyDimensions(page);
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
- await page.setViewportSize({
1128
- width: Math.round(bodyDimensions.width),
1129
- height: Math.round(bodyDimensions.height)
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
- slideData = await extractSlideData(page);
1133
- } finally {
1134
- await browser.close();
1135
- }
947
+ extraction = await page.evaluate(extractPageDataInBrowser);
948
+ } finally {
949
+ if (browser) await browser.close();
950
+ }
1136
951
 
1137
- // Collect all validation errors
1138
- if (bodyDimensions.errors && bodyDimensions.errors.length > 0) {
1139
- validationErrors.push(...bodyDimensions.errors);
1140
- }
952
+ // ---- 整页级校验(第 5 节),和提取阶段收集到的错误合并 ----
953
+ const errors = extraction.errors.slice();
1141
954
 
1142
- const dimensionErrors = validateDimensions(bodyDimensions, pres);
1143
- if (dimensionErrors.length > 0) {
1144
- validationErrors.push(...dimensionErrors);
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
- const textBoxPositionErrors = validateTextBoxPosition(slideData, bodyDimensions);
1148
- if (textBoxPositionErrors.length > 0) {
1149
- validationErrors.push(...textBoxPositionErrors);
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
- if (slideData.errors && slideData.errors.length > 0) {
1153
- validationErrors.push(...slideData.errors);
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
- // Throw all errors at once if any exist
1157
- if (validationErrors.length > 0) {
1158
- const errorMessage = validationErrors.length === 1
1159
- ? validationErrors[0]
1160
- : `Multiple validation errors found:\n${validationErrors.map((e, i) => ` ${i + 1}. ${e}`).join('\n')}`;
1161
- throw new Error(errorMessage);
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
- const targetSlide = slide || pres.addSlide();
1165
-
1166
- await addBackground(slideData, targetSlide, tmpDir);
1167
- addElements(slideData, targetSlide, pres);
1003
+ // ---- 写入 slide ----
1004
+ const slide = options.slide || pres.addSlide();
1168
1005
 
1169
- return { slide: targetSlide, placeholders: slideData.placeholders };
1170
- } catch (error) {
1171
- if (!error.message.startsWith(htmlFile)) {
1172
- throw new Error(`${htmlFile}: ${error.message}`);
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
- module.exports = html2pptx;
1014
+ for (const el of extraction.elements) {
1015
+ writeElement(slide, pres, el);
1016
+ }
1017
+
1018
+ return { slide, placeholders: extraction.placeholders };
1019
+ };