
Huashu Data Pro
- 643 installs
- 1.3k repo stars
- Updated August 2, 2026
- alchaincyf/huashu-skills
huashu-data-pro is a Claude Code skill that delivers expert data analysis, visualization, report writing, and presentation creation from Excel spreadsheets and campaign datasets.
About
huashu-data-pro is an alchaincyf/huashu-skills workflow that covers end-to-end data processing, insight generation, report writing, PPT creation, and visualization from Excel and ad campaign data. The skill defaults to interactive HTML reports with ECharts charts and PDF export, switches to slide-style HTML for PPT requests, and enforces honest data handling with zero baselines and cited sources. Developers and analysts reach for huashu-data-pro when triggers mention 分析数据, Excel, ROI, 周报, or 可视化 and they need structured deliverables instead of ad hoc chart snippets. Use it for campaign postmortems, formula generation, and executive-ready reporting from messy tables.
- Always starts by asking clarifying questions to understand true user intent
- Delivers interactive HTML reports with ECharts by default, HTML-to-PPTX on explicit request, or terminal+Markdown for qu
- Applies 5 core principles including expert perspective, proactive extra insights, data honesty, and visual quality stand
- Follows strict anti-misleading chart rules such as zero-baseline columns and semantic color usage
- Triggers on keywords like Excel, ROI,投放分析, 周报, 月报, 数据可视化, 做报告, 做PPT
Huashu Data Pro by the numbers
- 643 all-time installs (skills.sh)
- Ranked #643 of 3,282 Productivity & Planning skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/alchaincyf/huashu-skills --skill huashu-data-proAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 643 |
|---|---|
| repo stars | ★ 1.3k |
| Last updated | August 2, 2026 |
| Repository | alchaincyf/huashu-skills ↗ |
How do you turn Excel campaign data into reports?
Get expert-level data analysis, visualization, report writing, and presentation creation from messy spreadsheets or campaign data.
Who is it for?
Developers and analysts transforming Excel or ad campaign exports into interactive HTML reports, ROI summaries, and presentation decks.
Skip if: Pure software engineering tasks with no spreadsheet analysis, reporting, or visualization deliverables required.
When should I use this skill?
The user mentions Excel analysis, campaign ROI review, weekly reports, charts, 数据可视化, or PPT creation from tabular data.
What you get
Interactive HTML reports with ECharts charts, ROI calculations, written analysis, and optional slide-style or PDF-exportable presentations.
- HTML analysis report
- ECharts visualizations
- Slide-style presentation output
Files
数据分析与办公提效助手
帮用户多想一步——不只完成任务,更提供专家洞察。
核心哲学
1. 先理解,后执行 — 拿到任务先问「用户真正需要什么」 2. 专家视角 — 从最合适的角色出发(分析师/投放优化师/设计师/写作专家) 3. 多想一步 — 完成后主动指出用户可能没注意到的问题、趋势或机会 4. 数据诚实 — 绝不编造数据,图表不误导(零基线、绝对比例、标注来源) 5. 视觉品质 — 所有可视化遵循经验证的设计系统,不做丑图
输出格式决策
拿到数据呈现需求时,先判断格式:
| 用户意图 | 输出格式 | 何时用 |
|---|---|---|
| 分析/报告/可视化 | 交互式HTML报告 | 默认选择。ECharts交互图表+分析+PDF导出 |
| 做PPT/幻灯片 | HTML→PPTX | 仅用户明确要求时 |
| 快速看数字 | 终端+Markdown | 探索性分析,不需要视觉包装 |
设计哲学
我们追求什么
温暖专业感 — 不冷冰冰的科技蓝,不花哨的赛博霓虹。暖色调(奶油、珊瑚、暗金)传递专业但有温度的感觉,像一本设计精良的杂志。
信息优先 — 设计服务于数据。每个视觉元素都必须帮助理解数据,而非装饰。标题是结论而非描述,颜色有语义(红=问题,绿=健康,灰=参考),只标注关键数据点。
10米可读 — 为投影/培训场景设计。标题占幅面15-30%,辅助文字≥10pt,表格有斑马纹防串行,排名从大到小。
数据不说谎 — 柱状图Y轴从0开始(除非明确标注),条形图用绝对比例,极小值有最小宽度保护,堆叠图<3%合并为「其他」。
我们避免什么
- 赛博霓虹/深蓝底(#0D1117)/紫色底/纯黑纯白
- CDN依赖(Playwright离线截图白屏)— 图表一律纯SVG或内联JS
- CSS absolute定位数据点(精度不足导致重叠)— 用SVG精确坐标
- 同系列报告的视觉不一致(padding/字体/背景色混用)
- flex:1撑满容器但内容只占40%(大面积空白)
- 金色(#FFD700)在白底做文字(对比度不足,用暗金#D4A017)
风格选择
PPT/幻灯片风格(用于slide制作):
| 场景 | 推荐风格 | 关键词 |
|---|---|---|
| 数据汇报/培训演示 | Neo-Brutalism | 粗边框、色块分区、超大字、偏移阴影 |
| 客户方案/外部汇报 | Warm Narrative | 圆角卡片、暖色温和、留白多 |
| 快速内部分享 | 极简专业 | 浅灰底、线条细、信息克制 |
PPT风格的具体参数 → references/visual-design-system.md
数据报告风格(用于HTML可视化报告):
用户未指定风格时,从以下5种中随机选择,让每次产出都有新鲜感。选择后简短告知用户。
| 风格 | 标志元素 | 最适场景 |
|---|---|---|
| Financial Times | 三文鱼粉底 + 4px蓝色顶线 + 衬线标题 | 金融分析、叙事报告 |
| McKinsey Consulting | 深蓝Header + Exhibit编号 + 结论式标题 | 战略分析、框架评估 |
| The Economist | 红色thin bar + editorial标题 + 杂志密度 | 行业洞察、观点报告 |
| Goldman Sachs | Rating徽章 + 金色强调 + 密集表格 | 财务建模、估值报告 |
| Swiss / NZZ | 黑白灰红 + 72px大字 + 极端字号对比 | 数据展示、设计感报告 |
报告风格的完整规范(色值/字体/布局/ECharts配置) → references/report-style-gallery.md
生成后自检
生成HTML报告/图表后,过一遍: 1. 图表是否纯SVG/内联JS?(CDN = 截图白屏) 2. SVG标注是否在viewBox内?(越界 = 被裁剪) 3. 辅助文字是否≥10pt?(小于 = 投影不可读) 4. 同系列视觉是否统一?(padding/字体/背景色) 5. 数据是否诚实?(基线/比例/极小值保护)
分析哲学
报告写作
- 结论先行 — 先说好还是不好,再说为什么
- 数据说话 — 每个观点有数据支撑
- 具体可执行 — 建议能直接执行,不说「需要进一步研究」
- 不说废话 — 删掉「总而言之」「需要指出的是」
- 使用「」引号
分析输出结构
核心结论(1-3句,管理层看这段就够了)
→ 数据支撑(具体数字、对比、趋势)
→ 异常/风险
→ 可执行建议(3-5条,按优先级)
→ 下一步(多想一步:还能深挖什么)不确定时必须问
- 数据字段含义不明 → 错误理解字段导致整个分析偏了
- 分析维度选择 → 不同维度得出不同结论
- 报告受众不明 → CEO和执行层需要的详略完全不同
- 涉及业务判断 → AI不了解业务上下文
工具与脚本
内置脚本
| 脚本 | 用途 |
|---|---|
scripts/html2pptx.js | HTML幻灯片→PPTX转换引擎 |
scripts/build_pptx.js | 多页HTML→单个PPTX |
scripts/read_excel.py | Excel读取(markdown/csv/json输出) |
scripts/read_pptx.py | PPTX结构读取 |
依赖
PPT制作需要:pptxgenjs, playwright, sharp(Node.js) Excel分析需要:pandas, openpyxl(Python) 缺失时自动安装,不让用户手动处理。
截图
npx playwright screenshot "file:///path/to/file.html" output.png \
--viewport-size=1200,675 --wait-for-timeout=2000参考文件索引
| 需要什么 | 去哪找 |
|---|---|
| PPT风格参数、色值、CSS模板 | references/visual-design-system.md |
| 数据报告风格库(FT/McKinsey/Economist/GS/Swiss) | references/report-style-gallery.md |
| HTML可视化模板(KPI看板/表格/图表/诊断卡/流程图) | references/html-templates.md |
| 详细工作流(数据分析/Excel/报告/HTML报告/PPT制作) | references/workflows.md |
| 投放/广告分析领域知识(ROI公式/维度/法则) | references/ad-analytics.md |
| 18种经验证的视觉风格库 | ~/.claude/skills/image-to-slides/references/proven-styles-gallery.md |
| 20种设计哲学参考 | design-philosophy skill |
---
花叔出品 | AI Native Coder · 独立开发者
公众号「花叔」| 30万+粉丝 | AI工具与效率提升
代表作:小猫补光灯(AppStore付费榜Top1)·《一本书玩转DeepSeek》
投放/广告分析领域知识
投放优化师视角的速查手册。分析广告/投放数据时参考。
核心指标公式
| 指标 | 公式 | 健康值参考 |
|---|---|---|
| ROI | GMV ÷ 消耗 | >3.0(自播),>2.0(达人) |
| 转化率 | 订单数 ÷ 点击数 | >2%(信息流) |
| CPM | (消耗 ÷ 展现) × 1000 | <30元(一般品类) |
| 客单价 | GMV ÷ 订单数 | 因品类而异 |
| 消耗占比 | 千川消耗 ÷ GMV | <18%(品牌自播) |
| 实收GMV | GMV × (1-退货率) | — |
| 退货率 | 退货数 ÷ 订单数 | <30%(美妆),<45%(服饰) |
分析维度
| 维度 | 切入点 |
|---|---|
| 时间 | 日/周/月趋势、同比环比、时段分布 |
| 渠道 | 千川/自然流/达人分销对比 |
| 素材 | 口播/剧情/产品展示效果对比 |
| 人群 | 年龄/性别/地域转化差异 |
| 商品 | SKU/品类/价格带表现 |
数据分析黄金法则
1. AI生成公式,Excel验证数字 — AI不是计算器,复杂计算让它出公式,在Excel中跑 2. 退货率必须纳入ROI — 不扣退货的ROI是假ROI 3. 异常值要问为什么 — 不只报「异常」,要尝试解释原因 4. 同比比环比有意义 — 广告数据有周期性,环比波动可能只是周末效应 5. 关注边际效益 — 消耗翻倍但ROI不变,说明在吃量而非优化 6. 分层看数据 — 整体ROI健康可能掩盖某品类在亏损
HTML可视化模板库
即用型HTML模板,用Playwright截图生成高品质数据可视化图片。
所有模板已适配深色模式(避免纯白/纯黑),遵循Neo-Brutalism和Warm Narrative双风格体系。
---
使用方法
1. 复制模板,替换数据和标题 2. 保存为 .html 文件 3. 用 Playwright 截图:
npx playwright screenshot "file:///path/to/template.html" output.png \
--viewport-size=[宽],[高] --wait-for-timeout=2000---
模板1:KPI指标看板(4指标)
尺寸:1200×400 | 风格:Neo-Brutalism
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1200px; height: 400px; background: #F5E6D3;
padding: 30px; font-family: "PingFang SC", Arial, sans-serif;
display: flex; flex-direction: column;
}
.title { font-size: 24px; font-weight: 800; color: #1A1A1A; margin-bottom: 20px; }
.row { display: flex; gap: 20px; flex: 1; }
.card {
flex: 1; background: #FFFDF7; border: 4px solid #1A1A1A;
border-radius: 12px; padding: 20px;
box-shadow: 6px 6px 0 #1A1A1A;
display: flex; flex-direction: column; justify-content: center;
text-align: center;
}
.card-value { font-size: 52px; font-weight: 900; }
.card-label { font-size: 16px; color: #888; margin-top: 6px; }
.card-change { font-size: 14px; margin-top: 4px; }
.v-coral { color: #E17055; }
.v-mint { color: #45B7AA; }
.v-gold { color: #D4A017; }
.v-olive { color: #5B8C5A; }
.up { color: #4CAF50; }
.down { color: #FF3B4F; }
.neutral { color: #888; }
</style>
</head>
<body>
<p class="title">2026年1月 投放数据概览</p>
<div class="row">
<div class="card">
<p class="card-value v-coral">3.2</p>
<p class="card-label">整体ROI</p>
<p class="card-change up">↑ 0.4 vs 上月</p>
</div>
<div class="card">
<p class="card-value v-mint">730万</p>
<p class="card-label">总GMV</p>
<p class="card-change up">↑ 12%</p>
</div>
<div class="card">
<p class="card-value v-gold">16%</p>
<p class="card-label">消耗占比</p>
<p class="card-change up">↓ 2pt</p>
</div>
<div class="card">
<p class="card-value v-olive">28%</p>
<p class="card-label">退货率</p>
<p class="card-change down">↑ 3pt</p>
</div>
</div>
</body>
</html>截图:--viewport-size=1200,400
---
模板2:多板块对比表格
尺寸:1200×600 | 风格:Neo-Brutalism
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1200px; height: 600px; background: #F5E6D3;
padding: 30px; font-family: "PingFang SC", Arial, sans-serif;
}
.title { font-size: 24px; font-weight: 800; color: #1A1A1A; margin-bottom: 20px; }
table {
width: 100%; border-collapse: separate; border-spacing: 0;
border: 4px solid #1A1A1A; border-radius: 12px;
overflow: hidden; box-shadow: 6px 6px 0 #1A1A1A;
background: #FFFDF7;
}
th {
background: #1A1A1A; color: #FFD700;
padding: 14px 20px; font-size: 16px; font-weight: 700;
text-align: left;
}
td {
padding: 14px 20px; font-size: 15px; color: #1A1A1A;
border-bottom: 2px solid #E8DDD0;
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: #FFF8E7; }
.tag {
display: inline-block; padding: 3px 10px;
border-radius: 6px; font-size: 13px; font-weight: 700;
}
.tag-good { background: #D4EDDA; color: #155724; }
.tag-warn { background: #FFF3CD; color: #856404; }
.tag-bad { background: #F8D7DA; color: #721C24; }
.num-highlight { font-weight: 900; font-size: 18px; }
</style>
</head>
<body>
<p class="title">各板块ROI与盈亏分析</p>
<table>
<tr>
<th><p>板块</p></th>
<th><p>GMV</p></th>
<th><p>消耗</p></th>
<th><p>ROI</p></th>
<th><p>退货率</p></th>
<th><p>状态</p></th>
</tr>
<tr>
<td><p>美妆·护肤</p></td>
<td><p class="num-highlight">200万</p></td>
<td><p>45万</p></td>
<td><p class="num-highlight" style="color: #4CAF50;">3.8</p></td>
<td><p>28%</p></td>
<td><p><span class="tag tag-good">盈利</span></p></td>
</tr>
<tr>
<td><p>食品</p></td>
<td><p class="num-highlight">150万</p></td>
<td><p>30万</p></td>
<td><p class="num-highlight" style="color: #45B7AA;">2.5</p></td>
<td><p>15%</p></td>
<td><p><span class="tag tag-good">盈利</span></p></td>
</tr>
<tr>
<td><p>服饰</p></td>
<td><p class="num-highlight">300万</p></td>
<td><p>80万</p></td>
<td><p class="num-highlight" style="color: #FF3B4F;">1.1</p></td>
<td><p style="color: #FF3B4F; font-weight: 700;">45%</p></td>
<td><p><span class="tag tag-bad">亏损</span></p></td>
</tr>
<tr>
<td><p>个护</p></td>
<td><p class="num-highlight">120万</p></td>
<td><p>25万</p></td>
<td><p class="num-highlight" style="color: #D4A017;">2.0</p></td>
<td><p>20%</p></td>
<td><p><span class="tag tag-warn">持平</span></p></td>
</tr>
</table>
</body>
</html>截图:--viewport-size=1200,600
---
模板3:趋势折线图(CSS纯实现)
尺寸:1200×500 | 风格:Warm Narrative
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1200px; height: 500px; background: #FDF6EC;
padding: 40px; font-family: "PingFang SC", Arial, sans-serif;
}
.title { font-size: 24px; font-weight: 700; color: #3D3D3D; margin-bottom: 6px; }
.subtitle { font-size: 14px; color: #999; margin-bottom: 30px; }
.chart-area {
background: #FFFFFF; border: 1px solid #E8DDD0;
border-radius: 16px; padding: 30px;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
height: 340px; position: relative;
}
/* 简单柱状图用CSS实现 */
.bar-chart { display: flex; align-items: flex-end; gap: 20px; height: 250px; padding-top: 20px; }
.bar-group { flex: 1; display: flex; flex-direction: column; align-items: center; }
.bar {
width: 60px; border-radius: 8px 8px 0 0;
transition: height 0.3s;
}
.bar-label { font-size: 13px; color: #888; margin-top: 8px; }
.bar-value { font-size: 12px; font-weight: 700; color: #3D3D3D; margin-bottom: 4px; }
.baseline {
position: absolute; left: 30px; right: 30px; bottom: 70px;
border-bottom: 2px dashed #E8DDD0;
}
.baseline-label {
position: absolute; right: 30px; bottom: 73px;
font-size: 11px; color: #E17055;
}
</style>
</head>
<body>
<p class="title">各板块ROI表现</p>
<p class="subtitle">2026年1月 | 目标线:3.0</p>
<div class="chart-area">
<div class="baseline"></div>
<p class="baseline-label">目标 3.0</p>
<div class="bar-chart">
<div class="bar-group">
<p class="bar-value">3.8</p>
<div class="bar" style="height: 190px; background: #4CAF50;"></div>
<p class="bar-label">美妆</p>
</div>
<div class="bar-group">
<p class="bar-value">2.5</p>
<div class="bar" style="height: 125px; background: #45B7AA;"></div>
<p class="bar-label">食品</p>
</div>
<div class="bar-group">
<p class="bar-value">1.1</p>
<div class="bar" style="height: 55px; background: #FF3B4F;"></div>
<p class="bar-label">服饰</p>
</div>
<div class="bar-group">
<p class="bar-value">2.0</p>
<div class="bar" style="height: 100px; background: #D4A017;"></div>
<p class="bar-label">个护</p>
</div>
</div>
</div>
</body>
</html>截图:--viewport-size=1200,500
---
模板4:问题诊断卡片
尺寸:1200×700 | 风格:Neo-Brutalism
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1200px; height: 700px; background: #F5E6D3;
padding: 30px; font-family: "PingFang SC", Arial, sans-serif;
}
.title { font-size: 24px; font-weight: 800; color: #1A1A1A; margin-bottom: 20px; }
.cards { display: flex; gap: 20px; }
.diagnosis-card {
flex: 1; border: 4px solid #1A1A1A; border-radius: 12px;
overflow: hidden; box-shadow: 6px 6px 0 #1A1A1A;
}
.card-header {
padding: 16px 20px;
}
.header-red { background: #FF3B4F; }
.header-gold { background: #FFD700; }
.header-green { background: #4CAF50; }
.card-header h2 { color: #1A1A1A; font-size: 18px; font-weight: 800; }
.header-red h2 { color: #FFFDF7; }
.card-body {
background: #FFFDF7; padding: 20px;
}
.card-body ul { list-style: none; padding: 0; }
.card-body li {
font-size: 14px; color: #1A1A1A; line-height: 1.8;
padding: 6px 0; border-bottom: 1px solid #F0E8DD;
}
.card-body li:last-child { border-bottom: none; }
.priority-tag {
display: inline-block; background: #1A1A1A; color: #FFD700;
padding: 2px 8px; border-radius: 4px; font-size: 11px;
font-weight: 700; margin-right: 6px;
}
</style>
</head>
<body>
<p class="title">投放问题诊断与优化建议</p>
<div class="cards">
<div class="diagnosis-card">
<div class="card-header header-red">
<h2>问题(需立即处理)</h2>
</div>
<div class="card-body">
<ul>
<li><p><span class="priority-tag">P0</span>服饰板块ROI 1.1,低于盈亏线</p></li>
<li><p><span class="priority-tag">P0</span>退货率45%导致实际GMV缩水近半</p></li>
<li><p><span class="priority-tag">P1</span>晚8-10点时段CPM偏高,ROI反而最低</p></li>
</ul>
</div>
</div>
<div class="diagnosis-card">
<div class="card-header header-gold">
<h2>建议(本周执行)</h2>
</div>
<div class="card-body">
<ul>
<li><p><span class="priority-tag">1</span>服饰板块暂停ROI<1的计划,预计月省8万</p></li>
<li><p><span class="priority-tag">2</span>美妆板块预算上调20%(ROI有余量)</p></li>
<li><p><span class="priority-tag">3</span>测试避开晚8-10点,转投午间12-14点</p></li>
</ul>
</div>
</div>
<div class="diagnosis-card">
<div class="card-header header-green">
<h2>亮点(继续保持)</h2>
</div>
<div class="card-body">
<ul>
<li><p>美妆ROI 3.8,超目标27%</p></li>
<li><p>食品退货率仅15%,利润率最高</p></li>
<li><p>整体消耗占比16%,控制在18%红线内</p></li>
</ul>
</div>
</div>
</div>
</body>
</html>截图:--viewport-size=1200,700
---
模板5:流程/工作流图
尺寸:1200×400 | 风格:Neo-Brutalism
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1200px; height: 400px; background: #F5E6D3;
padding: 30px; font-family: "PingFang SC", Arial, sans-serif;
}
.title { font-size: 24px; font-weight: 800; color: #1A1A1A; margin-bottom: 25px; }
.flow { display: flex; align-items: center; gap: 0; }
.step {
border: 4px solid #1A1A1A; border-radius: 12px;
padding: 20px; width: 200px; text-align: center;
box-shadow: 4px 4px 0 #1A1A1A;
}
.step-num {
font-size: 32px; font-weight: 900; margin-bottom: 6px;
}
.step-title { font-size: 16px; font-weight: 700; color: #1A1A1A; margin-bottom: 4px; }
.step-desc { font-size: 12px; color: #666; line-height: 1.4; }
.s1 { background: #FF3B4F; }
.s1 .step-num, .s1 .step-title { color: #FFFDF7; }
.s1 .step-desc { color: rgba(255,255,255,0.8); }
.s2 { background: #FFD700; }
.s3 { background: #45B7AA; }
.s3 .step-num, .s3 .step-title { color: #FFFDF7; }
.s3 .step-desc { color: rgba(255,255,255,0.8); }
.s4 { background: #FFFDF7; }
.arrow {
font-size: 36px; color: #1A1A1A; font-weight: 900;
padding: 0 15px;
}
</style>
</head>
<body>
<p class="title">投放数据分析工作流</p>
<div class="flow">
<div class="step s1">
<p class="step-num">01</p>
<p class="step-title">拉数据</p>
<p class="step-desc">导出千川后台数据到Excel</p>
</div>
<p class="arrow">→</p>
<div class="step s2">
<p class="step-num">02</p>
<p class="step-title">AI分析</p>
<p class="step-desc">DeepSeek做关联分析和异常检测</p>
</div>
<p class="arrow">→</p>
<div class="step s3">
<p class="step-num">03</p>
<p class="step-title">生成报告</p>
<p class="step-desc">AI撰写复盘报告初稿</p>
</div>
<p class="arrow">→</p>
<div class="step s4">
<p class="step-num">04</p>
<p class="step-title">人工校验</p>
<p class="step-desc">核对数据、补充业务判断</p>
</div>
</div>
</body>
</html>截图:--viewport-size=1200,400
---
模板6:Before / After 对比
尺寸:1200×500 | 风格:Neo-Brutalism
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1200px; height: 500px; background: #F5E6D3;
padding: 30px; font-family: "PingFang SC", Arial, sans-serif;
}
.title { font-size: 24px; font-weight: 800; color: #1A1A1A; margin-bottom: 20px; }
.compare { display: flex; gap: 30px; }
.panel {
flex: 1; border: 4px solid #1A1A1A; border-radius: 12px;
overflow: hidden; box-shadow: 6px 6px 0 #1A1A1A;
}
.panel-header { padding: 14px 20px; }
.before-header { background: #888888; }
.after-header { background: #4CAF50; }
.panel-header h2 { font-size: 20px; font-weight: 800; color: #FFFDF7; }
.panel-body {
background: #FFFDF7; padding: 20px;
}
.panel-body ul { list-style: none; padding: 0; }
.panel-body li {
font-size: 15px; color: #1A1A1A; line-height: 2;
padding-left: 24px; position: relative;
}
.before-item::before { content: "✗"; position: absolute; left: 0; color: #FF3B4F; font-weight: 700; }
.after-item::before { content: "✓"; position: absolute; left: 0; color: #4CAF50; font-weight: 700; }
.vs-label {
display: flex; align-items: center; justify-content: center;
font-size: 48px; font-weight: 900; color: #1A1A1A;
width: 60px;
}
</style>
</head>
<body>
<p class="title">AI提效前后对比:周度复盘流程</p>
<div class="compare">
<div class="panel">
<div class="panel-header before-header"><h2>Before(传统方式)</h2></div>
<div class="panel-body">
<ul>
<li class="before-item"><p>手动从后台导出4个板块数据(30min)</p></li>
<li class="before-item"><p>Excel透视表逐个做分析(2h)</p></li>
<li class="before-item"><p>手写复盘报告(1.5h)</p></li>
<li class="before-item"><p>做PPT汇报材料(2h)</p></li>
<li class="before-item"><p>总计:约6小时</p></li>
</ul>
</div>
</div>
<div class="vs-label"><p>→</p></div>
<div class="panel">
<div class="panel-header after-header"><h2>After(AI辅助)</h2></div>
<div class="panel-body">
<ul>
<li class="after-item"><p>导出数据 + AI自动分析(30min)</p></li>
<li class="after-item"><p>AI生成复盘报告初稿(10min)</p></li>
<li class="after-item"><p>人工校验 + 补充判断(30min)</p></li>
<li class="after-item"><p>AI生成PPT + 微调(30min)</p></li>
<li class="after-item"><p>总计:约1.5小时(省75%)</p></li>
</ul>
</div>
</div>
</div>
</body>
</html>截图:--viewport-size=1200,500
---
模板使用建议
| 场景 | 推荐模板 | 截图尺寸 |
|---|---|---|
| 数据概览/周报开头 | 模板1:KPI看板 | 1200×400 |
| 多维度对比 | 模板2:对比表格 | 1200×600 |
| 趋势展示 | 模板3:柱状/折线图 | 1200×500 |
| 问题诊断 | 模板4:诊断卡片 | 1200×700 |
| 流程说明 | 模板5:工作流图 | 1200×400 |
| 方案展示 | 模板6:Before/After | 1200×500 |
标准底部PDF导出提示
每个HTML报告页面底部添加PDF导出提示。要求:不干扰主体内容,极细小,仅浏览器查看时注意到。
<!-- 放在 </body> 前 -->
<p style="position:fixed;bottom:2pt;right:12pt;font-size:6.5pt;color:#BBB;margin:0;letter-spacing:0.3pt;">
Ctrl/Cmd + P 导出PDF
</p>特点:
position:fixed固定在视口底部,不占文档流- 6.5pt + #BBB 极低存在感,不影响截图视觉效果
- 打印时浏览器自带PDF功能,无需额外JS
自定义提示
- 替换数据时保持颜色语义(红=问题、绿=正常、金=关注)
- 标题始终是结论而非描述
- 中文字体用
"PingFang SC", Arial, sans-serif - 深色模式:背景避免纯白(用 #F5F5F5)和纯黑(用 #1A1A2E)
可视化报告风格库
5种经实战验证的数据报告视觉风格。用于 HTML 数据报告、分析报告、研究报告等场景。
每种风格都有完整的色彩、字体、布局、图表规范,可直接用于生成。
使用方式
当用户需要生成可视化数据报告(非PPT)时: 1. 如果用户指定了风格 → 使用指定风格 2. 如果用户未指定 → 从以下5种风格中随机选择一种,让每次产出都有新鲜感 3. 选择后在回复开头简短说明所用风格(如「本次使用 Financial Times 风格」)
所有风格均适用于 1200×675pt(16:9报告页)或 1200×900px(信息图)的 Playwright 截图场景。
---
风格 1:Financial Times(三文鱼粉)
一句话:「我们不需要花哨的视觉来证明自己的价值,信息本身就是价值。」
色彩
| 用途 | 色值 | 说明 |
|---|---|---|
| 页面背景 | #FFF1E5 | FT标志性三文鱼粉 |
| 卡片/内容区 | #FFFFFF | 纯白,与粉色背景柔和对比 |
| 主要文字 | #33302E | 深棕灰,非纯黑 |
| 次要文字 | #6B6B6B | 中灰色,标注/来源/元信息 |
| 强调蓝 | #0F5499 | FT品牌蓝,链接/标签/重点数据 |
| 警告红 | #CC0000 | 负面数据、风险提示 |
| 正向绿 | #09823A | 正面数据、增长指标 |
| 边框 | #E0D3C3 | 暖色调细边框 |
| 图表辅助 | #996600 | 金棕色,第三数据系列 |
字体
| 场景 | 字体 |
|---|---|
| 标题/章节名 | Georgia, 'Times New Roman', serif |
| 正文/数据 | -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif |
| 数字 | font-variant-numeric: tabular-nums |
关键设计元素
- 4px蓝色顶线(
#0F5499):页面最顶部,FT最标志性的视觉元素 - 边框:1px实线
#E0D3C3,不用box-shadow,不用粗线 - 圆角:无或极小(最多4px)
- 间距:大量留白。Section间距48px,段落16px,卡片内边距24px
- 分隔线:40px短线或全宽1px细线
- 衬线标题 + 无衬线正文:传递传统金融媒体的权威感
- 数据内嵌叙事:关键数字嵌入文字段落,不孤立在图表里
ECharts配置
- 关闭动画(
animation: false) - 线条宽度2px
- 网格线虚线
#F5EDE3 - 图例矩形小方块(
icon: 'rect'),12px - 图表下方必须有source line,11px灰色字
- Tooltip白色背景 + 细边框,不用阴影
---
风格 2:McKinsey Consulting(深蓝权威)
一句话:每个图表都是「证据」,每个标题都是「结论」。
色彩
| 用途 | 色值 |
|---|---|
| 页面背景 | #FFFFFF |
| Header | #003366(McKinsey深蓝) |
| 主文字 | #1A1A2E |
| 次文字 | #666680 |
| 强调蓝 | #4472C4 |
| 强调橙 | #ED7D31(警告/风险) |
| 强调绿 | #70AD47(正向) |
| 弱化填充 | #A5B4C8 |
| 边框 | #D6D6D6 |
| 斑马纹行 | #F7F8FA |
| Takeaway框 | #F4F6F9 |
字体
全部使用 'Helvetica Neue', Arial, sans-serif
关键设计元素
- Exhibit编号:图表标注为「Exhibit 1:」「Exhibit 2:」,不是「图1」
- 结论式标题:标题是结论不是描述(如「Cloud revenue drives 60% of incremental growth」而非「Revenue by segment」)
- Key Takeaway框:4px左边框
#003366,浅灰背景#F4F6F9,「KEY TAKEAWAY」大写标签 - KPI Strip:紧贴Header下方,同色深蓝背景,5个指标并排
- Source Line:每个Exhibit必须有来源标注
- 无渐变、无阴影、无圆角
页面结构
[深蓝Header: 标题 + 副标题 + Confidential]
[深蓝KPI Strip: 5个指标并排]
[白色内容区]
Exhibit 1: [结论式标题]
[图表]
[Source Line]
[Key Takeaway框]
─────────────
Exhibit 2: ...
[深蓝结论框]
[Footer: Disclaimer]ECharts色板
['#003366', '#4472C4', '#70AD47', '#ED7D31', '#A5B4C8']---
风格 3:The Economist(杂志红线)
一句话:红色是信号不是装饰,每个图表标题都带观点和态度。
色彩
| 用途 | 色值 |
|---|---|
| 页面背景 | #FFFFFF |
| Header标识线 | #E3120B(6px红线) |
| 主文字 | #1D1D1B |
| 次文字 | #616161 |
| 强调红 | #E3120B(图表主线、KPI强调) |
| 辅助蓝绿 | #0A5E87 |
| 正向绿 | #3B7A57 |
| 边框 | #DBDBDB |
| 浅背景 | #F5F5F5 / #FAFAFA |
字体
| 场景 | 字体 |
|---|---|
| 标题 | Georgia, 'Times New Roman', serif |
| 正文 | 'Helvetica Neue', Arial, sans-serif |
关键设计元素
- 6px红色顶线:页面和KPI区顶部
- 3px红色细线:每个图表区块顶部,The Economist图表的视觉签名
- Editorial标题:图表标题带观点(「AI的胃口」而非「资本开支趋势」)
- 副标题:通俗语言解释图表内容
- Pull Quote:大号衬线字体 + 左侧红色竖线
- 信息密度高如杂志:双栏布局优先,图表紧凑(260-280px高度)
- Small Multiples:多个小图表优于一个巨型图表
红色使用原则
- 每个section最多一个红色元素作为焦点
- 用于顶线、图表thin bar、最重要数据线、关键数字强调、Pull quote竖线
- 不滥用:作为焦点而非填充
ECharts色板
['#E3120B', '#0A5E87', '#3B7A57', '#B8B8B8']---
风格 4:Goldman Sachs(投行报告)
一句话:Tables are king — 数据表格承载分析,图表只是辅助。
色彩
| 用途 | 色值 |
|---|---|
| 页面背景 | #F8F9FA |
| 卡片/表格 | #FFFFFF |
| Header | #00338D(GS深蓝) |
| 主文字 | #1B1B1B |
| 次文字 | #5F6368 |
| 强调金 | #D4AF37(投资论点、Base Case) |
| 负面红 | #C62828 |
| 正向绿 | #2E7D32 |
| 边框 | #DEE2E6 |
| 斑马纹行 | #FAFBFC |
| Bottom Line | #F0F4F8 |
字体
全部使用 'Helvetica Neue', Arial, sans-serif
- 表头:11px, 600, 白色, 大写, 0.5px letter-spacing
- 行标签:11px, 500, 灰色, 大写, 0.3px spacing
- 数据:12px, 400
关键设计元素
- Rating Strip:BUY(绿)/NEUTRAL(金)/SELL(红) 评级徽章,报告顶部最先看到
- Investment Thesis框:白色卡片 + 4px金色左边框
#D4AF37 - 密集金融表格:深蓝表头、斑马纹、hover态、关键值高亮
- Bottom Line Strip:浅灰蓝背景 + 3px深蓝左边框,一句话总结
- DCF Sensitivity Table:Base Case用金色边框高亮,高于/低于现价分别用绿/红
- Risk Badges:HIGH(红边框), MED(金边框), LOW(绿边框)
- 无渐变、无阴影、无圆角:锐利边缘传递机构权威感
GS设计原则
1. Tables are king — 数据表格承载分析 2. Navy + Gold 双色签名 — 深蓝是结构,金色是强调 3. Bottom line clarity — 每个section都以一句话结论收尾 4. Dense but scannable — 数据密集但层级清晰 5. Rating-first — 评级判断最先呈现
ECharts色板
['#00338D', '#5B9BD5', '#A5A5A5', '#C62828']---
风格 5:Swiss / NZZ Minimal(瑞士极简)
一句话:只用黑白灰红四种色彩、Helvetica一种字体、极端字号对比和大量留白,让数据自己说话。
色彩
| 用途 | 色值 |
|---|---|
| 页面背景 | #FFFFFF(纯白,不接受其他底色) |
| 主文字 | #000000 |
| 次文字 | #767676 |
| 强调红 | #FF0000(Swiss Red,极度克制使用) |
| 边框主 | #000000(1px黑线) |
| 边框次 | #E5E5E5 |
色彩使用原则
- 红色是「信号」不是「装饰」— 每个section最多一个红色元素
- 整页红色元素不超过3-5个
- 图表用 黑+深灰+中灰,仅一个系列用红色强调
- 永远不使用渐变、阴影、发光效果
字体
全部使用 'Helvetica Neue', Helvetica, Arial, sans-serif
| 层级 | 字号 | 字重 |
|---|---|---|
| Display | 72px | 900 (Black) |
| Section Title | 48px | 900 (Black) |
| Key Number | 36-64px | 700-900 |
| Subtitle | 24px | 300 (Light) |
| Body | 16px | 400 (Regular) |
| Label | 13-14px | 400 |
- 标题与正文字号比至少 4:1
- 大字号 letter-spacing 负值(-1px 到 -4px),小字号正值(0.5px-2px)
关键设计元素
- 极端字号对比:72px标题 vs 13px标签
- 黑色分隔线:
1px solid #000000,章节间的唯一分隔手段 - 严格网格:5列等分指标条,4列护城河,3列判断区间
- Grid列间分隔:
border-right: 1px solid #E5E5E5 - 大量留白:页面内边距60-80px,Section间距60-64px
绝对禁止
圆角、阴影、渐变、背景色块(除纯白)、图标/Emoji、装饰性元素、动画
ECharts色板
['#000000', '#444444', '#999999', '#FF0000']---
风格速查对比
| 维度 | FT | McKinsey | Economist | Goldman Sachs | Swiss/NZZ |
|---|---|---|---|---|---|
| 背景色 | 三文鱼粉 | 纯白 | 纯白 | 浅灰 | 纯白 |
| 品牌色 | 蓝 #0F5499 | 深蓝 #003366 | 红 #E3120B | 深蓝+金 | 红 #FF0000 |
| 标题字体 | 衬线(Georgia) | 无衬线(Helvetica) | 衬线(Georgia) | 无衬线(Helvetica) | 无衬线(Helvetica) |
| 装饰度 | 极简 | 结构化 | 杂志感 | 机构感 | 极度克制 |
| 信息密度 | 中等 | 中高 | 高 | 高 | 中等 |
| 最适场景 | 金融分析、叙事报告 | 战略分析、框架评估 | 行业洞察、观点报告 | 财务建模、估值报告 | 数据展示、设计感报告 |
| 标志元素 | 4px蓝色顶线 | Exhibit编号+结论标题 | 3px红色thin bar | Rating徽章+金色边框 | 72px黑色大字 |
视觉设计系统 — 数据分析与办公场景
经过131页PPT实战验证的设计系统。用于数据可视化、报告、PPT制作。
---
风格A:Neo-Brutalism(数据密集场景首选)
核心CSS变量
:root {
/* 配色 */
--bg-cream: #F5E6D3;
--accent-red: #FF3B4F;
--accent-gold: #FFD700;
--text-black: #1A1A1A;
--card-white: #FFFDF7;
/* 边框 */
--border-thick: 4px solid #1A1A1A;
--border-extra: 6px solid #1A1A1A;
/* 阴影(纯实色,无模糊) */
--shadow-sm: 4px 4px 0 #1A1A1A;
--shadow-md: 8px 8px 0 #1A1A1A;
--shadow-lg: 12px 12px 0 #1A1A1A;
/* 圆角 */
--radius: 12pt;
/* 字体 */
--font-heading: 'Arial Black', 'Helvetica Neue', Arial, sans-serif;
--font-body: Arial, Helvetica, sans-serif;
}PPT幻灯片模板
<!DOCTYPE html>
<html>
<head>
<style>
html { background: #F5E6D3; }
body {
width: 720pt; height: 405pt; margin: 0; padding: 30pt;
background: #F5E6D3;
font-family: Arial, Helvetica, sans-serif;
display: flex; flex-direction: column;
}
/* 标题区 */
.title-bar {
background: #FF3B4F; border: 4px solid #1A1A1A;
border-radius: 12pt; padding: 15pt 25pt;
box-shadow: 6px 6px 0 #1A1A1A;
margin-bottom: 20pt;
}
.title-bar h1 {
color: #FFFDF7; font-size: 28pt; font-weight: 900;
margin: 0;
}
/* 内容卡片 */
.card {
background: #FFFDF7; border: 4px solid #1A1A1A;
border-radius: 12pt; padding: 20pt;
box-shadow: 6px 6px 0 #1A1A1A;
flex: 1;
}
/* 数据高亮 */
.metric {
background: #FFD700; border: 3px solid #1A1A1A;
border-radius: 8pt; padding: 8pt 16pt;
display: inline-block; box-shadow: 4px 4px 0 #1A1A1A;
}
.metric p {
font-size: 24pt; font-weight: 900; color: #1A1A1A; margin: 0;
}
/* 列表 */
.card ul { list-style: none; padding: 0; margin: 0; }
.card li {
font-size: 14pt; color: #1A1A1A; line-height: 1.8;
padding-left: 20pt; position: relative;
}
.card li::before {
content: "▸"; position: absolute; left: 0;
color: #FF3B4F; font-weight: bold;
}
</style>
</head>
<body>
<div class="title-bar">
<h1>Q1投放ROI达3.2,超目标7%</h1>
</div>
<div class="card">
<div style="display: flex; gap: 15pt; margin-bottom: 15pt;">
<div class="metric"><p>ROI 3.2</p></div>
<div class="metric"><p>GMV 730万</p></div>
<div class="metric"><p>消耗占比 16%</p></div>
</div>
<ul>
<li><p>美妆板块ROI 3.8,贡献整体GMV的35%</p></li>
<li><p>服饰板块退货率45%,实际ROI仅1.1,是主要拖累</p></li>
<li><p>建议:服饰板块缩减20%预算,转投美妆和食品</p></li>
</ul>
<!-- 图表预留区域 -->
<div id="roi-chart" class="placeholder" style="width: 300pt; height: 150pt; margin-top: 10pt;"></div>
</div>
</body>
</html>Neo-Brutalism设计要点
1. 粗黑边框 — 所有卡片/按钮/模块都有 4-6px 黑色实线边框 2. 高饱和色块 — 每个模块一个主色,色块面积大 3. 超大字排版 — 标题占幅面15-30% 4. 偏移实色阴影 — 向右下偏移6-10px,无模糊 5. 无渐变 — 纯色填充 + 锐利边缘
Neo-Brutalism注意事项
- 避免蓝色或紫色底(容易变成赛博风)
- 文字必须黑色或深色,不要白色文字在彩色底上(除了标题栏)
- 内容溢出时减少内容,不缩小字号 — 大字是灵魂
- 色块之间边界清晰,无模糊过渡
---
风格B:Warm Narrative(客户汇报场景)
核心CSS变量
:root {
--bg-warm: #FDF6EC;
--accent-coral: #E17055;
--accent-mint: #45B7AA;
--accent-olive: #5B8C5A;
--text-dark: #3D3D3D;
--text-light: #666666;
--card-white: #FFFFFF;
--border-subtle: 1px solid #E8DDD0;
--shadow-soft: 0 4px 12px rgba(0, 0, 0, 0.08);
--radius: 16pt;
}PPT幻灯片模板
<!DOCTYPE html>
<html>
<head>
<style>
html { background: #FDF6EC; }
body {
width: 720pt; height: 405pt; margin: 0; padding: 40pt;
background: #FDF6EC;
font-family: Arial, Helvetica, sans-serif;
display: flex; flex-direction: column;
}
.header {
margin-bottom: 25pt;
}
.header h1 {
color: #3D3D3D; font-size: 26pt; font-weight: 700;
margin: 0 0 8pt 0;
}
.header p {
color: #999999; font-size: 11pt; margin: 0;
}
.content {
display: flex; gap: 20pt; flex: 1;
}
.main-card {
background: #FFFFFF; border: 1px solid #E8DDD0;
border-radius: 16pt; padding: 25pt;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
flex: 2;
}
.side-card {
background: #E17055; border-radius: 16pt;
padding: 25pt; flex: 1;
}
.side-card h2 { color: #FFFFFF; font-size: 16pt; margin: 0 0 10pt 0; }
.side-card p { color: rgba(255,255,255,0.9); font-size: 12pt; line-height: 1.6; margin: 0; }
.main-card h2 { color: #E17055; font-size: 16pt; margin: 0 0 15pt 0; }
.main-card ul { padding-left: 18pt; margin: 0; }
.main-card li { font-size: 12pt; color: #3D3D3D; line-height: 1.8; }
</style>
</head>
<body>
<div class="header">
<h1>1月投放效果复盘</h1>
<p>2026年1月 | 数据截至1月31日</p>
</div>
<div class="content">
<div class="main-card">
<h2>核心发现</h2>
<ul>
<li><p>整体ROI 2.2,距目标3.0仍有差距</p></li>
<li><p>美妆板块表现最优(ROI 3.8),建议加大投入</p></li>
<li><p>服饰板块退货率偏高(45%),建议调整素材策略</p></li>
</ul>
</div>
<div class="side-card">
<h2>关键行动</h2>
<p>1. 美妆预算上调20%</p>
<p>2. 服饰暂停ROI<1的计划</p>
<p>3. 下周出素材A/B测试方案</p>
</div>
</div>
</body>
</html>---
风格C:极简专业(内部快速分享)
核心CSS变量
:root {
--bg-light: #F5F5F5;
--accent-blue: #4A90D9;
--accent-orange: #FF6B35;
--text-dark: #333333;
--text-gray: #888888;
--card-white: #FFFFFF;
--border-light: 1px solid #E0E0E0;
--shadow-minimal: 0 2px 8px rgba(0, 0, 0, 0.06);
--radius: 8pt;
}---
图表设计规范
配色方案
系列1(主): #E17055 珊瑚(PptxGenJS: "E17055")
系列2(对比):#45B7AA 薄荷绿(PptxGenJS: "45B7AA")
系列3: #5B8C5A 橄榄绿(PptxGenJS: "5B8C5A")
系列4: #FFD700 金色(PptxGenJS: "FFD700")
系列5: #9B7EDE 薰衣草紫(PptxGenJS: "9B7EDE")
负面/警告: #FF3B4F 红色(PptxGenJS: "FF3B4F")
正面/增长: #4CAF50 绿色(PptxGenJS: "4CAF50")
参考线/轴线: #CCCCCC 浅灰
网格线: #EEEEEE 极淡灰(或不显示)图表类型选择指南
| 要讲的故事 | 推荐图表 | 不推荐 |
|---|---|---|
| 比较大小 | 柱状图(bar/col) | 饼图(>5个类别时) |
| 时间趋势 | 折线图(line) | 柱状图(>12个时间点) |
| 占比构成 | 饼图(≤5类)/ 堆叠柱状图 | 折线图 |
| 两变量关系 | 散点图(scatter) | 柱状图 |
| 排名 | 水平柱状图(bar) | 折线图 |
| 分布 | 直方图 / 箱线图 | 饼图 |
PptxGenJS图表配置速查
// 柱状图
slide.addChart(pptx.charts.BAR, [{
name: "ROI", labels: ["美妆","食品","服饰","个护"],
values: [3.8, 2.5, 1.1, 2.0]
}], {
...placeholders[0],
barDir: 'col',
chartColors: ["E17055"], // 无#前缀!
showTitle: true, title: '各板块ROI对比',
showCatAxisTitle: true, catAxisTitle: '板块',
showValAxisTitle: true, valAxisTitle: 'ROI',
valAxisMinVal: 0, valAxisMaxVal: 5,
dataLabelPosition: 'outEnd', dataLabelColor: '333333'
});
// 折线图
slide.addChart(pptx.charts.LINE, [{
name: "整体ROI",
labels: ["W1","W2","W3","W4"],
values: [2.1, 2.3, 2.0, 2.4]
}], {
...placeholders[0],
lineSize: 3, lineSmooth: true,
chartColors: ["E17055"],
showTitle: true, title: '周度ROI趋势',
showCatAxisTitle: true, catAxisTitle: '周',
showValAxisTitle: true, valAxisTitle: 'ROI'
});
// 饼图
slide.addChart(pptx.charts.PIE, [{
name: "GMV占比",
labels: ["美妆","食品","服饰","个护"],
values: [35, 20, 30, 15]
}], {
...placeholders[0],
showPercent: true, showLegend: true, legendPos: 'r',
chartColors: ["E17055", "45B7AA", "5B8C5A", "FFD700"]
});---
HTML信息图截图模板
数据看板卡片(1200×900)
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1200px; height: 900px;
background: #F5F0EB; padding: 40px;
font-family: "PingFang SC", Arial, sans-serif;
}
.dashboard-title {
font-size: 32px; font-weight: 800; color: #1A1A1A;
margin-bottom: 30px;
}
.metrics-row {
display: flex; gap: 20px; margin-bottom: 30px;
}
.metric-card {
background: #FFFDF7; border: 4px solid #1A1A1A;
border-radius: 12px; padding: 24px;
box-shadow: 6px 6px 0 #1A1A1A; flex: 1;
text-align: center;
}
.metric-value {
font-size: 48px; font-weight: 900; color: #E17055;
}
.metric-label {
font-size: 16px; color: #888; margin-top: 8px;
}
.metric-change {
font-size: 14px; margin-top: 4px;
}
.up { color: #4CAF50; }
.down { color: #FF3B4F; }
</style>
</head>
<body>
<p class="dashboard-title">1月投放数据看板</p>
<div class="metrics-row">
<div class="metric-card">
<p class="metric-value">3.2</p>
<p class="metric-label">整体ROI</p>
<p class="metric-change up">↑ 0.4 vs 上月</p>
</div>
<div class="metric-card">
<p class="metric-value">730万</p>
<p class="metric-label">总GMV</p>
<p class="metric-change up">↑ 12%</p>
</div>
<div class="metric-card">
<p class="metric-value">16%</p>
<p class="metric-label">消耗占比</p>
<p class="metric-change up">↓ 2pt(健康)</p>
</div>
<div class="metric-card">
<p class="metric-value">28%</p>
<p class="metric-label">退货率</p>
<p class="metric-change down">↑ 3pt(注意)</p>
</div>
</div>
<!-- 下方区域放图表或详细数据 -->
</body>
</html>截图命令:
npx playwright screenshot "file:///path/to/dashboard.html" dashboard.png \
--viewport-size=1200,900 --wait-for-timeout=2000---
HTML数据报告设计规范(实战验证)
基于7份金融数据报告(共22页HTML)的视觉审查,提炼的设计规范。
适用于:1200×675pt 数据报告页、信息图、数据看板等 Playwright 截图场景。
画布与基础
| 属性 | 规范 | 说明 |
|---|---|---|
| 画布尺寸 | 1200pt × 675pt(16:9报告页) | 或 1200px × 900px(信息图) |
| body padding | 同一系列统一,推荐 24pt | 禁止同系列报告不同padding(如20pt vs 28pt) |
| 卡片背景 | 统一用 #FFFAF5 或 #FFFDF7 | 同系列不混用 #fff 和 #FFFAF5 |
| 字体栈 | Arial, "PingFang SC", Helvetica, sans-serif | 全系列统一,不能有的页面有PingFang有的没有 |
| CSS单位 | 同一文件只用 pt 或只用 px | 禁止 pt 和 px 混用 |
必须做(DO)
图表实现 1. 所有图表用纯SVG或内联JS — 禁止依赖外部CDN(Chart.js/ECharts等),Playwright离线截图时CDN不可用,图表会完全空白 2. SVG标注必须在viewBox范围内 — 检查所有文字/标注的x/y坐标不为负值、不超出viewBox边界,否则Playwright截图会裁剪 3. 标注文字做避让处理 — 当两个标注水平距离<80pt时,错开垂直位置或合并。密集数据区域(如牛市顶部)尤其注意 4. 散点图/气泡图用SVG精确坐标 — 不用CSS position: absolute手动定位,精度不足会导致气泡重叠
柱状图/条形图 5. Y轴基线从0开始(金额/数量/占比场景) — 如果数据范围很小(如评分4.25~4.39),必须在图表标题或注脚说明「非零基线」,否则会过度放大差异误导观众 6. 条形图用绝对比例 — 19.5%的市占率就应该是46%宽度的42%。如果用相对排名比例(第一名=满宽),必须在图表中明确标注 7. 堆叠图<3%的segment合并为「其他」 — 或不在极小segment上显示百分比标签,避免文字不可见/重叠 8. 柱状图柱子宽度≥12pt — 低于12pt在截图后肉眼无法辨识,29组柱子就需要更大的图表宽度
排版与空间 9. flex容器与内容匹配 — 如果表格只有7行数据,不要用 flex:1 撑满整个卡片再留出50%空白。宁可缩小卡片高度 10. 同系列报告header风格统一 — 全部用黑底金字,或全部用明底大字,不混用 11. 表格必须有斑马纹 — tr:nth-child(even) { background: #FDF6EC; } 或高亮当前焦点行,防止串行 12. 条形图极小值设最小宽度 — min-width: 6pt,避免1.1%的值在视觉上完全消失
字号(投影/培训场景) 13. 辅助文字最小10pt — 图例、轴标签、数据来源、气泡缩写等,<10pt在投影时不可读 14. 表头最小10pt — 不要用8pt表头 15. 洞察/分析文字最小11pt — insight-box内的解读文字行高≥1.6
配色细节 16. 金色在白底/浅底用暗金 `#D4A017` 或 `#B8860B` — #FFD700 在白底对比度仅1.94:1,不符WCAG标准 17. 品类/系列配色保持语义一致 — 同一报告系列中,NVDA始终是同一个颜色,不要在不同页面换色
禁止做(DON'T)
| 编号 | 禁止事项 | 原因 |
|---|---|---|
| D1 | 引用CDN资源(Chart.js/ECharts/D3等) | 离线截图白屏 |
| D2 | SVG坐标超出viewBox(负值y/超宽x) | 截图裁剪 |
| D3 | CSS position: absolute 定位数据点 | 精度不足,重叠不可控 |
| D4 | 同系列报告混用不同padding/字体/卡片背景 | 视觉不统一 |
| D5 | 同一文件混用pt和px单位 | 渲染不一致 |
| D6 | 辅助文字<10pt(图例/轴标签/来源/缩写) | 投影不可读 |
| D7 | 不说明的非零基线柱状图 | 误导观众 |
| D8 | 条形图极小值无最小宽度保护 | 视觉消失 |
| D9 | flex:1撑满容器但内容只占40% | 大面积空白 |
| D10 | #FFD700金色直接用在白底文字上 | 对比度不足 |
| D11 | 堆叠图在<3%的segment上放标签 | 文字不可见 |
| D12 | 气泡图内用缩写代替品牌全名且无图例对照 | 辨识度差 |
| D13 | 表格无斑马纹、无高亮行 | 容易串行 |
| D14 | 市值/排名从小到大排列 | 反直觉,应从大到小 |
数据报告画布模板(1200×675pt)
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html { background: #F5E6D3; }
body {
width: 1200pt; height: 675pt; margin: 0; padding: 24pt;
background: #F5E6D3;
font-family: Arial, "PingFang SC", Helvetica, sans-serif;
display: flex; flex-direction: column;
}
/* 标题栏 */
.header {
background: #1A1A1A; border-radius: 12pt;
padding: 14pt 24pt; margin-bottom: 16pt;
display: flex; align-items: center; justify-content: space-between;
}
.header h1 { color: #FFD700; font-size: 22pt; font-weight: 800; }
.header .subtitle { color: rgba(255,255,255,0.6); font-size: 10pt; }
/* 内容卡片 */
.card {
background: #FFFAF5; border: 3pt solid #1A1A1A;
border-radius: 12pt; padding: 18pt;
box-shadow: 5pt 5pt 0 #1A1A1A;
}
/* KPI行 */
.kpi-row { display: flex; gap: 12pt; margin-bottom: 14pt; }
.kpi-box {
flex: 1; background: #FFFAF5; border: 2pt solid #1A1A1A;
border-radius: 8pt; padding: 10pt 14pt; text-align: center;
box-shadow: 3pt 3pt 0 #1A1A1A;
}
.kpi-value { font-size: 24pt; font-weight: 900; }
.kpi-label { font-size: 10pt; color: #888; margin-top: 4pt; }
/* 表格 */
table { width: 100%; border-collapse: collapse; }
th {
background: #1A1A1A; color: #FFD700;
padding: 10pt 14pt; font-size: 11pt; text-align: left;
}
td { padding: 8pt 14pt; font-size: 11pt; border-bottom: 1pt solid #E8DDD0; }
tr:nth-child(even) td { background: #FDF6EC; }
tr.highlight td { background: #FFF3CD; }
/* 洞察区 */
.insight-box {
background: #1A1A1A; border-radius: 10pt;
padding: 14pt 20pt; margin-top: 12pt;
}
.insight-box p { color: #FFFAF5; font-size: 11pt; line-height: 1.65; }
.insight-box .highlight-text { color: #FFD700; font-weight: 700; }
/* 底部来源 */
.footer {
margin-top: auto; padding-top: 8pt;
font-size: 8pt; color: #999; text-align: right;
}
</style>
</head>
<body>
<div class="header">
<h1>报告标题</h1>
<p class="subtitle">数据来源 · 日期</p>
</div>
<!-- 内容区域 -->
<div class="footer"><p>数据来源:XXX · 截至YYYY-MM-DD</p></div>
</body>
</html>截图命令:
npx playwright screenshot "file:///path/to/report.html" output.png \
--viewport-size=1200,675 --wait-for-timeout=2000SVG图表实现要点
<!-- 正确:内联SVG折线图 -->
<svg viewBox="0 0 800 300" width="800pt" height="300pt">
<!-- 网格线 -->
<line x1="60" y1="20" x2="60" y2="270" stroke="#DDD" stroke-width="1"/>
<!-- 数据线 -->
<polyline points="60,250 160,220 260,180 360,100 460,60"
fill="none" stroke="#E17055" stroke-width="3"/>
<!-- 面积填充 -->
<polygon points="60,250 160,220 260,180 360,100 460,60 460,270 60,270"
fill="#E17055" fill-opacity="0.1"/>
<!-- 标注(确保在viewBox内) -->
<circle cx="460" cy="60" r="5" fill="#E17055"/>
<text x="460" y="50" text-anchor="middle" font-size="11"
font-weight="700" fill="#1A1A1A">最高点 $950</text>
<!-- 轴标签(≥10pt) -->
<text x="60" y="290" font-size="10" fill="#888">2020</text>
</svg>标注避让算法(伪代码):
for each annotation:
检查与其他标注的距离
if 水平距离 < 80pt:
if 当前标注在上方: 上移20pt
else: 下移20pt
if y < 0 或 y > viewBox.height:
翻转到数据点另一侧---
审美原则总结
1. 配色:暖色为主(奶油、珊瑚、暗金)+ 高对比黑,避免冷色系 2. 字体:超大标题(占15-30%),无衬线粗体,辅助文字≥10pt 3. 空间:色块分区组织信息,内容填满容器(不空也不挤) 4. 一致性:同系列报告的padding/字体/卡片背景/header/footer全部统一 5. 图表:纯SVG内联实现,禁止CDN依赖,标注不重叠不溢出 6. 数据诚实:柱状图零基线、条形图绝对比例、极小值有保护 7. 核心理念:用最简单的方式传达最多信息,在投影场景10米外仍可读
详细工作流参考
SKILL.md 的执行层。按需查阅,不必全读。
数据分析工作流
Step 1: 读取数据
# Excel(推荐pandas)
import pandas as pd
df = pd.read_excel('数据.xlsx', sheet_name='Sheet1')
print(f"维度:{df.shape[0]}行 × {df.shape[1]}列")
print(df.describe())
# CSV(注意编码)
df = pd.read_csv('数据.csv', encoding='utf-8') # 中文Windows试 encoding='gbk'也可用内置脚本:
SKILL_DIR="$(dirname "$(find ~/.claude/skills -name 'SKILL.md' -path '*/data-office-pro/*' 2>/dev/null | head -1)")"
python3 "$SKILL_DIR/scripts/read_excel.py" data.xlsx --summaryStep 2: 数据概览(拿到数据后自动执行)
输出5项: 1. 数据维度(行×列) 2. 字段清单(名称、类型、缺失率) 3. 基础统计(均值/中位数/极值) 4. 数据质量问题(缺失值、异常值、格式不一致) 5. 初步发现(1-2个趋势或问题)
然后问用户分析方向,给出2-3个建议供选。
Step 3: 执行分析
输出格式:
核心结论(1-3句)
→ 数据支撑(具体数字、对比、趋势)
→ 异常/风险
→ 可执行建议(3-5条,按优先级)
→ 下一步(多想一步)Step 4: 可视化
分析完成后主动询问是否需要可视化。选择路径见「数据可视化工作流」。
---
Excel操作工作流
公式生成
始终包含错误处理(IFERROR)。输出格式:
公式:=IFERROR(D2/C2, 0)
含义:计算ROI(GMV÷消耗),除零时返回0
适用范围:H2:H[最后一行]模板设计
Sheet 1: 原始数据 — 字段定义、数据验证规则
Sheet 2: 计算层 — 衍生指标公式、条件格式
Sheet 3: 汇总视图 — 透视表、关键指标看板
Sheet 4: 图表 — 数据可视化数据处理脚本
当公式无法满足时(大数据量、复杂逻辑),用Python处理。 原则:优先用Excel公式(用户可维护),复杂场景才用脚本。
---
报告生成工作流
管理层汇报(1页纸原则)
1. 核心结论(3句以内) 2. 关键指标汇总(表格) 3. 问题诊断(数据支撑) 4. 优化建议(按优先级) 5. 风险提示
详细分析报告
1. 摘要(半页) 2. 数据概览(关键指标面板) 3. 分维度分析(每个维度一节) 4. 异常值与风险 5. 建议与行动计划 6. 附录:数据来源与方法论
---
HTML交互报告工作流
默认输出格式。使用ECharts(内联,非CDN)+ 分析文字 + PDF导出。
关键约束
- 零CDN依赖 — 所有JS/CSS内联或纯SVG,Playwright离线截图不会白屏
- 图表用SVG/Canvas绑定数据,不用CSS absolute定位模拟
- 每页底部加PDF导出提示(见
html-templates.md标准组件) - 风格参数 →
visual-design-system.md - 模板 →
html-templates.md
截图命令
# 标准图表
npx playwright screenshot "file:///path/to/chart.html" output.png \
--viewport-size=1200,675 --wait-for-timeout=2000
# 宽幅信息图
npx playwright screenshot "file:///path/to/infographic.html" output.png \
--viewport-size=1920,1080 --wait-for-timeout=2000---
PPT制作工作流(HTML→PPTX)
流程
确认受众与风格 → 生成大纲 → 逐页创建HTML → 转PPTX → 预览确认必须问清楚的3个问题
1. 受众是谁?(管理层/客户/内部团队) 2. 用途是什么?(汇报/比稿/培训/记录) 3. 时间多长?(约1分钟/页)
断言式标题
| 差标题 | 好标题 |
|---|---|
| Q1投放数据 | Q1整体ROI达3.2,超目标7% |
| 问题分析 | 服饰板块退货率45%是亏损主因 |
HTML幻灯片规则
画布:width: 720pt; height: 405pt(16:9)
关键:
- 文字必须在
<p>/<h1>-<h6>/<ul>/<ol>内 —<div>裸文字不进PPT - 背景/边框/阴影只用在
<div>上 - 禁止CSS渐变(需渐变先用Sharp渲染PNG)
- 只用web安全字体:Arial, Helvetica, Georgia, Verdana, Tahoma
- 列表用
<ul>/<ol>,禁止手动 •/-/* 符号 - 图表颜色hex不带#前缀(PptxGenJS规则)
构建命令
SKILL_DIR="$(dirname "$(find ~/.claude/skills -name 'SKILL.md' -path '*/data-office-pro/*' 2>/dev/null | head -1)")"
# 从多个HTML构建
node "$SKILL_DIR/scripts/build_pptx.js" --slides slide1.html slide2.html --output report.pptx
# 从目录加载
node "$SKILL_DIR/scripts/build_pptx.js" --dir ./slides/ --output report.pptx
# 带图表
node "$SKILL_DIR/scripts/build_pptx.js" --slides slide1.html --output report.pptx --chart 0:col:chart_data.json图表数据JSON格式
{
"title": "各板块ROI对比",
"catAxisTitle": "板块",
"valAxisTitle": "ROI",
"colors": ["E17055", "45B7AA", "5B8C5A", "FFD700"],
"series": [{"name": "ROI", "labels": ["美妆","食品","服饰"], "values": [3.8, 2.5, 1.1]}]
}预览
npx playwright screenshot "file:///path/to/slide.html" preview.png \
--viewport-size=960,540 --wait-for-timeout=1000---
数据可视化工作流
| 场景 | 方式 |
|---|---|
| 快速看趋势 | Python matplotlib |
| 放进报告/PPT | HTML → Playwright截图 |
| PPT内原生图表 | PptxGenJS |
| 独立分享 | HTML → 截图 → 上传图床 |
模板 → html-templates.md,风格参数 → visual-design-system.md
---
PPTX/Excel读取
SKILL_DIR="$(dirname "$(find ~/.claude/skills -name 'SKILL.md' -path '*/data-office-pro/*' 2>/dev/null | head -1)")"
# 读取PPT
python3 "$SKILL_DIR/scripts/read_pptx.py" presentation.pptx --format markdown
python3 "$SKILL_DIR/scripts/read_pptx.py" presentation.pptx --inventory # 仅结构
# 读取Excel
python3 "$SKILL_DIR/scripts/read_excel.py" data.xlsx --summary
python3 "$SKILL_DIR/scripts/read_excel.py" data.xlsx --sheet "Sheet1" --head 20
python3 "$SKILL_DIR/scripts/read_excel.py" data.xlsx --format csv---
依赖管理
缺失时自动安装,不让用户手动处理。
# Node.js(PPT制作)
npm install pptxgenjs playwright sharp
npx playwright install chromium
# Python(Excel/PPT读取)
pip install openpyxl pandas python-pptx Pillow遇到版本冲突时用uv隔离:
# /// script
# requires-python = ">=3.10"
# dependencies = ["pandas>=2.0", "openpyxl>=3.1"]
# ///执行:uv run script.py
---
文件输出约定
| 类型 | 命名规范 |
|---|---|
| 分析报告 | 分析报告-[主题]-[YYYYMMDD].md |
| 可视化图片 | chart-[描述].png |
| PPT文件 | [主题]-[YYYYMMDD].pptx |
| 处理后的Excel | [原文件名]-processed.xlsx |
| HTML临时文件 | /tmp/,自动清理 |
#!/usr/bin/env node
/**
* build_pptx.js — 将多个 HTML 幻灯片文件构建为一个 PPTX 文件
*
* 用法:
* node build_pptx.js --slides slide1.html slide2.html --output report.pptx
* node build_pptx.js --dir ./slides/ --output report.pptx
* node build_pptx.js --slides slide1.html --output report.pptx --chart 0:bar:data.json
*
* 参数:
* --slides 指定 HTML 文件列表(按顺序)
* --dir 指定包含 HTML 文件的目录(按文件名排序)
* --output 输出 PPTX 文件名(默认:output.pptx)
* --chart 在指定幻灯片的 placeholder 中插入图表
* 格式:幻灯片序号:图表类型:数据JSON文件
* 例:0:bar:chart_data.json
*
* 依赖安装(自动检测,缺失时提示):
* npm install pptxgenjs playwright sharp
*
* HTML 文件规范:
* - body 尺寸必须是 width: 720pt; height: 405pt(16:9)
* - 所有文字必须在 <p>/<h1>-<h6>/<ul>/<ol> 标签内
* - 图表预留区域用 <div class="placeholder" id="chart-1"></div>
* - 不支持 CSS 渐变(需预渲染为 PNG)
* - 颜色使用 hex 格式
*/
const path = require('path');
const fs = require('fs');
// 检查依赖
function checkDependencies() {
const missing = [];
for (const dep of ['pptxgenjs', 'playwright', 'sharp']) {
try {
require.resolve(dep);
} catch {
missing.push(dep);
}
}
if (missing.length > 0) {
console.error(`缺少依赖: ${missing.join(', ')}`);
console.error(`请运行: npm install ${missing.join(' ')}`);
process.exit(1);
}
}
checkDependencies();
const pptxgen = require('pptxgenjs');
const html2pptx = require('./html2pptx.js');
// 解析命令行参数
function parseArgs() {
const args = process.argv.slice(2);
const config = { slides: [], output: 'output.pptx', charts: [] };
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--slides':
while (i + 1 < args.length && !args[i + 1].startsWith('--')) {
config.slides.push(args[++i]);
}
break;
case '--dir':
const dir = args[++i];
if (!fs.existsSync(dir)) {
console.error(`目录不存在: ${dir}`);
process.exit(1);
}
config.slides = fs.readdirSync(dir)
.filter(f => f.endsWith('.html'))
.sort()
.map(f => path.join(dir, f));
break;
case '--output':
config.output = args[++i];
break;
case '--chart':
// 格式: slideIndex:chartType:dataFile
const parts = args[++i].split(':');
config.charts.push({
slideIndex: parseInt(parts[0]),
chartType: parts[1],
dataFile: parts[2]
});
break;
case '--help':
console.log(`
用法:node build_pptx.js [选项]
选项:
--slides file1.html file2.html 指定HTML幻灯片文件
--dir ./slides/ 从目录加载所有HTML文件
--output report.pptx 输出文件名
--chart 0:bar:data.json 插入图表到指定幻灯片
--help 显示帮助
`);
process.exit(0);
}
}
if (config.slides.length === 0) {
console.error('请指定至少一个HTML文件。使用 --help 查看帮助。');
process.exit(1);
}
return config;
}
// 图表类型映射
function getChartType(pptx, typeName) {
const map = {
'bar': pptx.charts.BAR,
'col': pptx.charts.BAR,
'line': pptx.charts.LINE,
'pie': pptx.charts.PIE,
'scatter': pptx.charts.SCATTER,
'doughnut': pptx.charts.DOUGHNUT
};
return map[typeName.toLowerCase()] || pptx.charts.BAR;
}
// 默认图表配色(不带 # 前缀!PptxGenJS 规则)
const CHART_COLORS = ['E17055', '45B7AA', '5B8C5A', 'FFD700', '9B7EDE'];
async function build() {
const config = parseArgs();
console.log(`构建 PPTX: ${config.slides.length} 页幻灯片`);
const pptx = new pptxgen();
pptx.layout = 'LAYOUT_16x9';
// 存储每页的 placeholders 以便后续插入图表
const slideResults = [];
for (let i = 0; i < config.slides.length; i++) {
const htmlFile = config.slides[i];
const absPath = path.isAbsolute(htmlFile) ? htmlFile : path.join(process.cwd(), htmlFile);
if (!fs.existsSync(absPath)) {
console.error(`文件不存在: ${absPath}`);
process.exit(1);
}
console.log(` [${i + 1}/${config.slides.length}] ${path.basename(htmlFile)}`);
try {
const result = await html2pptx(absPath, pptx);
slideResults.push(result);
} catch (error) {
console.error(` ❌ 转换失败: ${error.message}`);
process.exit(1);
}
}
// 插入图表
for (const chartConfig of config.charts) {
const { slideIndex, chartType, dataFile } = chartConfig;
if (slideIndex >= slideResults.length) {
console.error(`图表配置错误: 幻灯片 ${slideIndex} 不存在(共 ${slideResults.length} 页)`);
continue;
}
const { slide, placeholders } = slideResults[slideIndex];
if (placeholders.length === 0) {
console.error(`幻灯片 ${slideIndex} 没有 placeholder 区域`);
continue;
}
const dataPath = path.isAbsolute(dataFile) ? dataFile : path.join(process.cwd(), dataFile);
if (!fs.existsSync(dataPath)) {
console.error(`图表数据文件不存在: ${dataPath}`);
continue;
}
const chartData = JSON.parse(fs.readFileSync(dataPath, 'utf-8'));
const type = getChartType(pptx, chartType);
// 使用第一个 placeholder 的位置
const pos = placeholders[0];
const chartOptions = {
x: pos.x,
y: pos.y,
w: pos.w,
h: pos.h,
chartColors: chartData.colors || CHART_COLORS,
showTitle: !!chartData.title,
title: chartData.title || '',
showCatAxisTitle: !!chartData.catAxisTitle,
catAxisTitle: chartData.catAxisTitle || '',
showValAxisTitle: !!chartData.valAxisTitle,
valAxisTitle: chartData.valAxisTitle || ''
};
// 柱状图特有配置
if (chartType === 'col') chartOptions.barDir = 'col';
if (chartType === 'bar') chartOptions.barDir = 'bar';
// 折线图特有配置
if (chartType === 'line') {
chartOptions.lineSize = 3;
chartOptions.lineSmooth = true;
}
// 饼图特有配置
if (chartType === 'pie' || chartType === 'doughnut') {
chartOptions.showPercent = true;
chartOptions.showLegend = true;
chartOptions.legendPos = 'r';
}
slide.addChart(type, chartData.series, chartOptions);
console.log(` 📊 已插入图表到幻灯片 ${slideIndex}`);
}
// 输出文件
const outputPath = path.isAbsolute(config.output) ? config.output : path.join(process.cwd(), config.output);
await pptx.writeFile({ fileName: outputPath });
console.log(`\n✅ 已生成: ${outputPath}`);
}
build().catch(err => {
console.error('构建失败:', err.message);
process.exit(1);
});
/**
* html2pptx - Convert HTML slide to pptxgenjs slide with positioned elements
*
* USAGE:
* const pptx = new pptxgen();
* pptx.layout = 'LAYOUT_16x9'; // Must match HTML body dimensions
*
* const { slide, placeholders } = await html2pptx('slide.html', pptx);
* slide.addChart(pptx.charts.LINE, data, placeholders[0]);
*
* await pptx.writeFile('output.pptx');
*
* FEATURES:
* - Converts HTML to PowerPoint with accurate positioning
* - Supports text, images, shapes, and bullet lists
* - Extracts placeholder elements (class="placeholder") with positions
* - Handles CSS gradients, borders, and margins
*
* VALIDATION:
* - Uses body width/height from HTML for viewport sizing
* - Throws error if HTML dimensions don't match presentation layout
* - Throws error if content overflows body (with overflow details)
*
* RETURNS:
* { slide, placeholders } where placeholders is an array of { id, x, y, w, h }
*/
const { chromium } = require('playwright');
const path = require('path');
const sharp = require('sharp');
const PT_PER_PX = 0.75;
const PX_PER_IN = 96;
const EMU_PER_IN = 914400;
// Helper: Get body dimensions and check for overflow
async function getBodyDimensions(page) {
const bodyDimensions = await page.evaluate(() => {
const body = document.body;
const style = window.getComputedStyle(body);
return {
width: parseFloat(style.width),
height: parseFloat(style.height),
scrollWidth: body.scrollWidth,
scrollHeight: body.scrollHeight
};
});
const errors = [];
const widthOverflowPx = Math.max(0, bodyDimensions.scrollWidth - bodyDimensions.width - 1);
const heightOverflowPx = Math.max(0, bodyDimensions.scrollHeight - bodyDimensions.height - 1);
const widthOverflowPt = widthOverflowPx * PT_PER_PX;
const heightOverflowPt = heightOverflowPx * PT_PER_PX;
if (widthOverflowPt > 0 || heightOverflowPt > 0) {
const directions = [];
if (widthOverflowPt > 0) directions.push(`${widthOverflowPt.toFixed(1)}pt horizontally`);
if (heightOverflowPt > 0) directions.push(`${heightOverflowPt.toFixed(1)}pt vertically`);
const reminder = heightOverflowPt > 0 ? ' (Remember: leave 0.5" margin at bottom of slide)' : '';
errors.push(`HTML content overflows body by ${directions.join(' and ')}${reminder}`);
}
return { ...bodyDimensions, errors };
}
// Helper: Validate dimensions match presentation layout
function validateDimensions(bodyDimensions, pres) {
const errors = [];
const widthInches = bodyDimensions.width / PX_PER_IN;
const heightInches = bodyDimensions.height / PX_PER_IN;
if (pres.presLayout) {
const layoutWidth = pres.presLayout.width / EMU_PER_IN;
const layoutHeight = pres.presLayout.height / EMU_PER_IN;
if (Math.abs(layoutWidth - widthInches) > 0.1 || Math.abs(layoutHeight - heightInches) > 0.1) {
errors.push(
`HTML dimensions (${widthInches.toFixed(1)}" × ${heightInches.toFixed(1)}") ` +
`don't match presentation layout (${layoutWidth.toFixed(1)}" × ${layoutHeight.toFixed(1)}")`
);
}
}
return errors;
}
function validateTextBoxPosition(slideData, bodyDimensions) {
const errors = [];
const slideHeightInches = bodyDimensions.height / PX_PER_IN;
const minBottomMargin = 0.5; // 0.5 inches from bottom
for (const el of slideData.elements) {
// Check text elements (p, h1-h6, list)
if (['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'list'].includes(el.type)) {
const fontSize = el.style?.fontSize || 0;
const bottomEdge = el.position.y + el.position.h;
const distanceFromBottom = slideHeightInches - bottomEdge;
if (fontSize > 12 && distanceFromBottom < minBottomMargin) {
const getText = () => {
if (typeof el.text === 'string') return el.text;
if (Array.isArray(el.text)) return el.text.find(t => t.text)?.text || '';
if (Array.isArray(el.items)) return el.items.find(item => item.text)?.text || '';
return '';
};
const textPrefix = getText().substring(0, 50) + (getText().length > 50 ? '...' : '');
errors.push(
`Text box "${textPrefix}" ends too close to bottom edge ` +
`(${distanceFromBottom.toFixed(2)}" from bottom, minimum ${minBottomMargin}" required)`
);
}
}
}
return errors;
}
// Helper: Add background to slide
async function addBackground(slideData, targetSlide, tmpDir) {
if (slideData.background.type === 'image' && slideData.background.path) {
let imagePath = slideData.background.path.startsWith('file://')
? slideData.background.path.replace('file://', '')
: slideData.background.path;
targetSlide.background = { path: imagePath };
} else if (slideData.background.type === 'color' && slideData.background.value) {
targetSlide.background = { color: slideData.background.value };
}
}
// Helper: Add elements to slide
function addElements(slideData, targetSlide, pres) {
for (const el of slideData.elements) {
if (el.type === 'image') {
let imagePath = el.src.startsWith('file://') ? el.src.replace('file://', '') : el.src;
targetSlide.addImage({
path: imagePath,
x: el.position.x,
y: el.position.y,
w: el.position.w,
h: el.position.h
});
} else if (el.type === 'line') {
targetSlide.addShape(pres.ShapeType.line, {
x: el.x1,
y: el.y1,
w: el.x2 - el.x1,
h: el.y2 - el.y1,
line: { color: el.color, width: el.width }
});
} else if (el.type === 'shape') {
const shapeOptions = {
x: el.position.x,
y: el.position.y,
w: el.position.w,
h: el.position.h,
shape: el.shape.rectRadius > 0 ? pres.ShapeType.roundRect : pres.ShapeType.rect
};
if (el.shape.fill) {
shapeOptions.fill = { color: el.shape.fill };
if (el.shape.transparency != null) shapeOptions.fill.transparency = el.shape.transparency;
}
if (el.shape.line) shapeOptions.line = el.shape.line;
if (el.shape.rectRadius > 0) shapeOptions.rectRadius = el.shape.rectRadius;
if (el.shape.shadow) shapeOptions.shadow = el.shape.shadow;
targetSlide.addText(el.text || '', shapeOptions);
} else if (el.type === 'list') {
const listOptions = {
x: el.position.x,
y: el.position.y,
w: el.position.w,
h: el.position.h,
fontSize: el.style.fontSize,
fontFace: el.style.fontFace,
color: el.style.color,
align: el.style.align,
valign: 'top',
lineSpacing: el.style.lineSpacing,
paraSpaceBefore: el.style.paraSpaceBefore,
paraSpaceAfter: el.style.paraSpaceAfter,
margin: el.style.margin
};
if (el.style.margin) listOptions.margin = el.style.margin;
targetSlide.addText(el.items, listOptions);
} else {
// Check if text is single-line (height suggests one line)
const lineHeight = el.style.lineSpacing || el.style.fontSize * 1.2;
const isSingleLine = el.position.h <= lineHeight * 1.5;
let adjustedX = el.position.x;
let adjustedW = el.position.w;
// Make single-line text 2% wider to account for underestimate
if (isSingleLine) {
const widthIncrease = el.position.w * 0.02;
const align = el.style.align;
if (align === 'center') {
// Center: expand both sides
adjustedX = el.position.x - (widthIncrease / 2);
adjustedW = el.position.w + widthIncrease;
} else if (align === 'right') {
// Right: expand to the left
adjustedX = el.position.x - widthIncrease;
adjustedW = el.position.w + widthIncrease;
} else {
// Left (default): expand to the right
adjustedW = el.position.w + widthIncrease;
}
}
const textOptions = {
x: adjustedX,
y: el.position.y,
w: adjustedW,
h: el.position.h,
fontSize: el.style.fontSize,
fontFace: el.style.fontFace,
color: el.style.color,
bold: el.style.bold,
italic: el.style.italic,
underline: el.style.underline,
valign: 'top',
lineSpacing: el.style.lineSpacing,
paraSpaceBefore: el.style.paraSpaceBefore,
paraSpaceAfter: el.style.paraSpaceAfter,
inset: 0 // Remove default PowerPoint internal padding
};
if (el.style.align) textOptions.align = el.style.align;
if (el.style.margin) textOptions.margin = el.style.margin;
if (el.style.rotate !== undefined) textOptions.rotate = el.style.rotate;
if (el.style.transparency !== null && el.style.transparency !== undefined) textOptions.transparency = el.style.transparency;
targetSlide.addText(el.text, textOptions);
}
}
}
// Helper: Extract slide data from HTML page
async function extractSlideData(page) {
return await page.evaluate(() => {
const PT_PER_PX = 0.75;
const PX_PER_IN = 96;
// Fonts that are single-weight and should not have bold applied
// (applying bold causes PowerPoint to use faux bold which makes text wider)
const SINGLE_WEIGHT_FONTS = ['impact'];
// Helper: Check if a font should skip bold formatting
const shouldSkipBold = (fontFamily) => {
if (!fontFamily) return false;
const normalizedFont = fontFamily.toLowerCase().replace(/['"]/g, '').split(',')[0].trim();
return SINGLE_WEIGHT_FONTS.includes(normalizedFont);
};
// Unit conversion helpers
const pxToInch = (px) => px / PX_PER_IN;
const pxToPoints = (pxStr) => parseFloat(pxStr) * PT_PER_PX;
const rgbToHex = (rgbStr) => {
// Handle transparent backgrounds by defaulting to white
if (rgbStr === 'rgba(0, 0, 0, 0)' || rgbStr === 'transparent') return 'FFFFFF';
const match = rgbStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (!match) return 'FFFFFF';
return match.slice(1).map(n => parseInt(n).toString(16).padStart(2, '0')).join('');
};
const extractAlpha = (rgbStr) => {
const match = rgbStr.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)/);
if (!match || !match[4]) return null;
const alpha = parseFloat(match[4]);
return Math.round((1 - alpha) * 100);
};
const applyTextTransform = (text, textTransform) => {
if (textTransform === 'uppercase') return text.toUpperCase();
if (textTransform === 'lowercase') return text.toLowerCase();
if (textTransform === 'capitalize') {
return text.replace(/\b\w/g, c => c.toUpperCase());
}
return text;
};
// Extract rotation angle from CSS transform and writing-mode
const getRotation = (transform, writingMode) => {
let angle = 0;
// Handle writing-mode first
// PowerPoint: 90° = text rotated 90° clockwise (reads top to bottom, letters upright)
// PowerPoint: 270° = text rotated 270° clockwise (reads bottom to top, letters upright)
if (writingMode === 'vertical-rl') {
// vertical-rl alone = text reads top to bottom = 90° in PowerPoint
angle = 90;
} else if (writingMode === 'vertical-lr') {
// vertical-lr alone = text reads bottom to top = 270° in PowerPoint
angle = 270;
}
// Then add any transform rotation
if (transform && transform !== 'none') {
// Try to match rotate() function
const rotateMatch = transform.match(/rotate\((-?\d+(?:\.\d+)?)deg\)/);
if (rotateMatch) {
angle += parseFloat(rotateMatch[1]);
} else {
// Browser may compute as matrix - extract rotation from matrix
const matrixMatch = transform.match(/matrix\(([^)]+)\)/);
if (matrixMatch) {
const values = matrixMatch[1].split(',').map(parseFloat);
// matrix(a, b, c, d, e, f) where rotation = atan2(b, a)
const matrixAngle = Math.atan2(values[1], values[0]) * (180 / Math.PI);
angle += Math.round(matrixAngle);
}
}
}
// Normalize to 0-359 range
angle = angle % 360;
if (angle < 0) angle += 360;
return angle === 0 ? null : angle;
};
// Get position/dimensions accounting for rotation
const getPositionAndSize = (el, rect, rotation) => {
if (rotation === null) {
return { x: rect.left, y: rect.top, w: rect.width, h: rect.height };
}
// For 90° or 270° rotations, swap width and height
// because PowerPoint applies rotation to the original (unrotated) box
const isVertical = rotation === 90 || rotation === 270;
if (isVertical) {
// The browser shows us the rotated dimensions (tall box for vertical text)
// But PowerPoint needs the pre-rotation dimensions (wide box that will be rotated)
// So we swap: browser's height becomes PPT's width, browser's width becomes PPT's height
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
return {
x: centerX - rect.height / 2,
y: centerY - rect.width / 2,
w: rect.height,
h: rect.width
};
}
// For other rotations, use element's offset dimensions
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
return {
x: centerX - el.offsetWidth / 2,
y: centerY - el.offsetHeight / 2,
w: el.offsetWidth,
h: el.offsetHeight
};
};
// Parse CSS box-shadow into PptxGenJS shadow properties
const parseBoxShadow = (boxShadow) => {
if (!boxShadow || boxShadow === 'none') return null;
// Browser computed style format: "rgba(0, 0, 0, 0.3) 2px 2px 8px 0px [inset]"
// CSS format: "[inset] 2px 2px 8px 0px rgba(0, 0, 0, 0.3)"
const insetMatch = boxShadow.match(/inset/);
// IMPORTANT: PptxGenJS/PowerPoint doesn't properly support inset shadows
// Only process outer shadows to avoid file corruption
if (insetMatch) return null;
// Extract color first (rgba or rgb at start)
const colorMatch = boxShadow.match(/rgba?\([^)]+\)/);
// Extract numeric values (handles both px and pt units)
const parts = boxShadow.match(/([-\d.]+)(px|pt)/g);
if (!parts || parts.length < 2) return null;
const offsetX = parseFloat(parts[0]);
const offsetY = parseFloat(parts[1]);
const blur = parts.length > 2 ? parseFloat(parts[2]) : 0;
// Calculate angle from offsets (in degrees, 0 = right, 90 = down)
let angle = 0;
if (offsetX !== 0 || offsetY !== 0) {
angle = Math.atan2(offsetY, offsetX) * (180 / Math.PI);
if (angle < 0) angle += 360;
}
// Calculate offset distance (hypotenuse)
const offset = Math.sqrt(offsetX * offsetX + offsetY * offsetY) * PT_PER_PX;
// Extract opacity from rgba
let opacity = 0.5;
if (colorMatch) {
const opacityMatch = colorMatch[0].match(/[\d.]+\)$/);
if (opacityMatch) {
opacity = parseFloat(opacityMatch[0].replace(')', ''));
}
}
return {
type: 'outer',
angle: Math.round(angle),
blur: blur * 0.75, // Convert to points
color: colorMatch ? rgbToHex(colorMatch[0]) : '000000',
offset: offset,
opacity
};
};
// Parse inline formatting tags (<b>, <i>, <u>, <strong>, <em>, <span>) into text runs
const parseInlineFormatting = (element, baseOptions = {}, runs = [], baseTextTransform = (x) => x) => {
let prevNodeIsText = false;
element.childNodes.forEach((node) => {
let textTransform = baseTextTransform;
const isText = node.nodeType === Node.TEXT_NODE || node.tagName === 'BR';
if (isText) {
const text = node.tagName === 'BR' ? '\n' : textTransform(node.textContent.replace(/\s+/g, ' '));
const prevRun = runs[runs.length - 1];
if (prevNodeIsText && prevRun) {
prevRun.text += text;
} else {
runs.push({ text, options: { ...baseOptions } });
}
} else if (node.nodeType === Node.ELEMENT_NODE && node.textContent.trim()) {
const options = { ...baseOptions };
const computed = window.getComputedStyle(node);
// Handle inline elements with computed styles
if (node.tagName === 'SPAN' || node.tagName === 'B' || node.tagName === 'STRONG' || node.tagName === 'I' || node.tagName === 'EM' || node.tagName === 'U') {
const isBold = computed.fontWeight === 'bold' || parseInt(computed.fontWeight) >= 600;
if (isBold && !shouldSkipBold(computed.fontFamily)) options.bold = true;
if (computed.fontStyle === 'italic') options.italic = true;
if (computed.textDecoration && computed.textDecoration.includes('underline')) options.underline = true;
if (computed.color && computed.color !== 'rgb(0, 0, 0)') {
options.color = rgbToHex(computed.color);
const transparency = extractAlpha(computed.color);
if (transparency !== null) options.transparency = transparency;
}
if (computed.fontSize) options.fontSize = pxToPoints(computed.fontSize);
// Apply text-transform on the span element itself
if (computed.textTransform && computed.textTransform !== 'none') {
const transformStr = computed.textTransform;
textTransform = (text) => applyTextTransform(text, transformStr);
}
// Validate: Check for margins on inline elements
if (computed.marginLeft && parseFloat(computed.marginLeft) > 0) {
errors.push(`Inline element <${node.tagName.toLowerCase()}> has margin-left which is not supported in PowerPoint. Remove margin from inline elements.`);
}
if (computed.marginRight && parseFloat(computed.marginRight) > 0) {
errors.push(`Inline element <${node.tagName.toLowerCase()}> has margin-right which is not supported in PowerPoint. Remove margin from inline elements.`);
}
if (computed.marginTop && parseFloat(computed.marginTop) > 0) {
errors.push(`Inline element <${node.tagName.toLowerCase()}> has margin-top which is not supported in PowerPoint. Remove margin from inline elements.`);
}
if (computed.marginBottom && parseFloat(computed.marginBottom) > 0) {
errors.push(`Inline element <${node.tagName.toLowerCase()}> has margin-bottom which is not supported in PowerPoint. Remove margin from inline elements.`);
}
// Recursively process the child node. This will flatten nested spans into multiple runs.
parseInlineFormatting(node, options, runs, textTransform);
}
}
prevNodeIsText = isText;
});
// Trim leading space from first run and trailing space from last run
if (runs.length > 0) {
runs[0].text = runs[0].text.replace(/^\s+/, '');
runs[runs.length - 1].text = runs[runs.length - 1].text.replace(/\s+$/, '');
}
return runs.filter(r => r.text.length > 0);
};
// Extract background from body (image or color)
const body = document.body;
const bodyStyle = window.getComputedStyle(body);
const bgImage = bodyStyle.backgroundImage;
const bgColor = bodyStyle.backgroundColor;
// Collect validation errors
const errors = [];
// Validate: Check for CSS gradients
if (bgImage && (bgImage.includes('linear-gradient') || bgImage.includes('radial-gradient'))) {
errors.push(
'CSS gradients are not supported. Use Sharp to rasterize gradients as PNG images first, ' +
'then reference with background-image: url(\'gradient.png\')'
);
}
let background;
if (bgImage && bgImage !== 'none') {
// Extract URL from url("...") or url(...)
const urlMatch = bgImage.match(/url\(["']?([^"')]+)["']?\)/);
if (urlMatch) {
background = {
type: 'image',
path: urlMatch[1]
};
} else {
background = {
type: 'color',
value: rgbToHex(bgColor)
};
}
} else {
background = {
type: 'color',
value: rgbToHex(bgColor)
};
}
// Process all elements
const elements = [];
const placeholders = [];
const textTags = ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'UL', 'OL', 'LI'];
const processed = new Set();
document.querySelectorAll('*').forEach((el) => {
if (processed.has(el)) return;
// Validate text elements don't have backgrounds, borders, or shadows
if (textTags.includes(el.tagName)) {
const computed = window.getComputedStyle(el);
const hasBg = computed.backgroundColor && computed.backgroundColor !== 'rgba(0, 0, 0, 0)';
const hasBorder = (computed.borderWidth && parseFloat(computed.borderWidth) > 0) ||
(computed.borderTopWidth && parseFloat(computed.borderTopWidth) > 0) ||
(computed.borderRightWidth && parseFloat(computed.borderRightWidth) > 0) ||
(computed.borderBottomWidth && parseFloat(computed.borderBottomWidth) > 0) ||
(computed.borderLeftWidth && parseFloat(computed.borderLeftWidth) > 0);
const hasShadow = computed.boxShadow && computed.boxShadow !== 'none';
if (hasBg || hasBorder || hasShadow) {
errors.push(
`Text element <${el.tagName.toLowerCase()}> has ${hasBg ? 'background' : hasBorder ? 'border' : 'shadow'}. ` +
'Backgrounds, borders, and shadows are only supported on <div> elements, not text elements.'
);
return;
}
}
// Extract placeholder elements (for charts, etc.)
if (el.className && el.className.includes('placeholder')) {
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
errors.push(
`Placeholder "${el.id || 'unnamed'}" has ${rect.width === 0 ? 'width: 0' : 'height: 0'}. Check the layout CSS.`
);
} else {
placeholders.push({
id: el.id || `placeholder-${placeholders.length}`,
x: pxToInch(rect.left),
y: pxToInch(rect.top),
w: pxToInch(rect.width),
h: pxToInch(rect.height)
});
}
processed.add(el);
return;
}
// Extract images
if (el.tagName === 'IMG') {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
elements.push({
type: 'image',
src: el.src,
position: {
x: pxToInch(rect.left),
y: pxToInch(rect.top),
w: pxToInch(rect.width),
h: pxToInch(rect.height)
}
});
processed.add(el);
return;
}
}
// Extract DIVs with backgrounds/borders as shapes
const isContainer = el.tagName === 'DIV' && !textTags.includes(el.tagName);
if (isContainer) {
const computed = window.getComputedStyle(el);
const hasBg = computed.backgroundColor && computed.backgroundColor !== 'rgba(0, 0, 0, 0)';
// Validate: Check for unwrapped text content in DIV
for (const node of el.childNodes) {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent.trim();
if (text) {
errors.push(
`DIV element contains unwrapped text "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}". ` +
'All text must be wrapped in <p>, <h1>-<h6>, <ul>, or <ol> tags to appear in PowerPoint.'
);
}
}
}
// Check for background images on shapes
const bgImage = computed.backgroundImage;
if (bgImage && bgImage !== 'none') {
errors.push(
'Background images on DIV elements are not supported. ' +
'Use solid colors or borders for shapes, or use slide.addImage() in PptxGenJS to layer images.'
);
return;
}
// Check for borders - both uniform and partial
const borderTop = computed.borderTopWidth;
const borderRight = computed.borderRightWidth;
const borderBottom = computed.borderBottomWidth;
const borderLeft = computed.borderLeftWidth;
const borders = [borderTop, borderRight, borderBottom, borderLeft].map(b => parseFloat(b) || 0);
const hasBorder = borders.some(b => b > 0);
const hasUniformBorder = hasBorder && borders.every(b => b === borders[0]);
const borderLines = [];
if (hasBorder && !hasUniformBorder) {
const rect = el.getBoundingClientRect();
const x = pxToInch(rect.left);
const y = pxToInch(rect.top);
const w = pxToInch(rect.width);
const h = pxToInch(rect.height);
// Collect lines to add after shape (inset by half the line width to center on edge)
if (parseFloat(borderTop) > 0) {
const widthPt = pxToPoints(borderTop);
const inset = (widthPt / 72) / 2; // Convert points to inches, then half
borderLines.push({
type: 'line',
x1: x, y1: y + inset, x2: x + w, y2: y + inset,
width: widthPt,
color: rgbToHex(computed.borderTopColor)
});
}
if (parseFloat(borderRight) > 0) {
const widthPt = pxToPoints(borderRight);
const inset = (widthPt / 72) / 2;
borderLines.push({
type: 'line',
x1: x + w - inset, y1: y, x2: x + w - inset, y2: y + h,
width: widthPt,
color: rgbToHex(computed.borderRightColor)
});
}
if (parseFloat(borderBottom) > 0) {
const widthPt = pxToPoints(borderBottom);
const inset = (widthPt / 72) / 2;
borderLines.push({
type: 'line',
x1: x, y1: y + h - inset, x2: x + w, y2: y + h - inset,
width: widthPt,
color: rgbToHex(computed.borderBottomColor)
});
}
if (parseFloat(borderLeft) > 0) {
const widthPt = pxToPoints(borderLeft);
const inset = (widthPt / 72) / 2;
borderLines.push({
type: 'line',
x1: x + inset, y1: y, x2: x + inset, y2: y + h,
width: widthPt,
color: rgbToHex(computed.borderLeftColor)
});
}
}
if (hasBg || hasBorder) {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
const shadow = parseBoxShadow(computed.boxShadow);
// Only add shape if there's background or uniform border
if (hasBg || hasUniformBorder) {
elements.push({
type: 'shape',
text: '', // Shape only - child text elements render on top
position: {
x: pxToInch(rect.left),
y: pxToInch(rect.top),
w: pxToInch(rect.width),
h: pxToInch(rect.height)
},
shape: {
fill: hasBg ? rgbToHex(computed.backgroundColor) : null,
transparency: hasBg ? extractAlpha(computed.backgroundColor) : null,
line: hasUniformBorder ? {
color: rgbToHex(computed.borderColor),
width: pxToPoints(computed.borderWidth)
} : null,
// Convert border-radius to rectRadius (in inches)
// % values: 50%+ = circle (1), <50% = percentage of min dimension
// pt values: divide by 72 (72pt = 1 inch)
// px values: divide by 96 (96px = 1 inch)
rectRadius: (() => {
const radius = computed.borderRadius;
const radiusValue = parseFloat(radius);
if (radiusValue === 0) return 0;
if (radius.includes('%')) {
if (radiusValue >= 50) return 1;
// Calculate percentage of smaller dimension
const minDim = Math.min(rect.width, rect.height);
return (radiusValue / 100) * pxToInch(minDim);
}
if (radius.includes('pt')) return radiusValue / 72;
return radiusValue / PX_PER_IN;
})(),
shadow: shadow
}
});
}
// Add partial border lines
elements.push(...borderLines);
processed.add(el);
return;
}
}
}
// Extract bullet lists as single text block
if (el.tagName === 'UL' || el.tagName === 'OL') {
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const liElements = Array.from(el.querySelectorAll('li'));
const items = [];
const ulComputed = window.getComputedStyle(el);
const ulPaddingLeftPt = pxToPoints(ulComputed.paddingLeft);
// Split: margin-left for bullet position, indent for text position
// margin-left + indent = ul padding-left
const marginLeft = ulPaddingLeftPt * 0.5;
const textIndent = ulPaddingLeftPt * 0.5;
liElements.forEach((li, idx) => {
const isLast = idx === liElements.length - 1;
const runs = parseInlineFormatting(li, { breakLine: false });
// Clean manual bullets from first run
if (runs.length > 0) {
runs[0].text = runs[0].text.replace(/^[•\-\*▪▸]\s*/, '');
runs[0].options.bullet = { indent: textIndent };
}
// Set breakLine on last run
if (runs.length > 0 && !isLast) {
runs[runs.length - 1].options.breakLine = true;
}
items.push(...runs);
});
const computed = window.getComputedStyle(liElements[0] || el);
elements.push({
type: 'list',
items: items,
position: {
x: pxToInch(rect.left),
y: pxToInch(rect.top),
w: pxToInch(rect.width),
h: pxToInch(rect.height)
},
style: {
fontSize: pxToPoints(computed.fontSize),
fontFace: computed.fontFamily.split(',')[0].replace(/['"]/g, '').trim(),
color: rgbToHex(computed.color),
transparency: extractAlpha(computed.color),
align: computed.textAlign === 'start' ? 'left' : computed.textAlign,
lineSpacing: computed.lineHeight && computed.lineHeight !== 'normal' ? pxToPoints(computed.lineHeight) : null,
paraSpaceBefore: 0,
paraSpaceAfter: pxToPoints(computed.marginBottom),
// PptxGenJS margin array is [left, right, bottom, top]
margin: [marginLeft, 0, 0, 0]
}
});
liElements.forEach(li => processed.add(li));
processed.add(el);
return;
}
// Extract text elements (P, H1, H2, etc.)
if (!textTags.includes(el.tagName)) return;
const rect = el.getBoundingClientRect();
const text = el.textContent.trim();
if (rect.width === 0 || rect.height === 0 || !text) return;
// Validate: Check for manual bullet symbols in text elements (not in lists)
if (el.tagName !== 'LI' && /^[•\-\*▪▸○●◆◇■□]\s/.test(text.trimStart())) {
errors.push(
`Text element <${el.tagName.toLowerCase()}> starts with bullet symbol "${text.substring(0, 20)}...". ` +
'Use <ul> or <ol> lists instead of manual bullet symbols.'
);
return;
}
const computed = window.getComputedStyle(el);
const rotation = getRotation(computed.transform, computed.writingMode);
const { x, y, w, h } = getPositionAndSize(el, rect, rotation);
const baseStyle = {
fontSize: pxToPoints(computed.fontSize),
fontFace: computed.fontFamily.split(',')[0].replace(/['"]/g, '').trim(),
color: rgbToHex(computed.color),
align: computed.textAlign === 'start' ? 'left' : computed.textAlign,
lineSpacing: pxToPoints(computed.lineHeight),
paraSpaceBefore: pxToPoints(computed.marginTop),
paraSpaceAfter: pxToPoints(computed.marginBottom),
// PptxGenJS margin array is [left, right, bottom, top] (not [top, right, bottom, left] as documented)
margin: [
pxToPoints(computed.paddingLeft),
pxToPoints(computed.paddingRight),
pxToPoints(computed.paddingBottom),
pxToPoints(computed.paddingTop)
]
};
const transparency = extractAlpha(computed.color);
if (transparency !== null) baseStyle.transparency = transparency;
if (rotation !== null) baseStyle.rotate = rotation;
const hasFormatting = el.querySelector('b, i, u, strong, em, span, br');
if (hasFormatting) {
// Text with inline formatting
const transformStr = computed.textTransform;
const runs = parseInlineFormatting(el, {}, [], (str) => applyTextTransform(str, transformStr));
// Adjust lineSpacing based on largest fontSize in runs
const adjustedStyle = { ...baseStyle };
if (adjustedStyle.lineSpacing) {
const maxFontSize = Math.max(
adjustedStyle.fontSize,
...runs.map(r => r.options?.fontSize || 0)
);
if (maxFontSize > adjustedStyle.fontSize) {
const lineHeightMultiplier = adjustedStyle.lineSpacing / adjustedStyle.fontSize;
adjustedStyle.lineSpacing = maxFontSize * lineHeightMultiplier;
}
}
elements.push({
type: el.tagName.toLowerCase(),
text: runs,
position: { x: pxToInch(x), y: pxToInch(y), w: pxToInch(w), h: pxToInch(h) },
style: adjustedStyle
});
} else {
// Plain text - inherit CSS formatting
const textTransform = computed.textTransform;
const transformedText = applyTextTransform(text, textTransform);
const isBold = computed.fontWeight === 'bold' || parseInt(computed.fontWeight) >= 600;
elements.push({
type: el.tagName.toLowerCase(),
text: transformedText,
position: { x: pxToInch(x), y: pxToInch(y), w: pxToInch(w), h: pxToInch(h) },
style: {
...baseStyle,
bold: isBold && !shouldSkipBold(computed.fontFamily),
italic: computed.fontStyle === 'italic',
underline: computed.textDecoration.includes('underline')
}
});
}
processed.add(el);
});
return { background, elements, placeholders, errors };
});
}
async function html2pptx(htmlFile, pres, options = {}) {
const {
tmpDir = process.env.TMPDIR || '/tmp',
slide = null
} = options;
try {
// Use Chrome on macOS, default Chromium on Unix
const launchOptions = { env: { TMPDIR: tmpDir } };
if (process.platform === 'darwin') {
launchOptions.channel = 'chrome';
}
const browser = await chromium.launch(launchOptions);
let bodyDimensions;
let slideData;
const filePath = path.isAbsolute(htmlFile) ? htmlFile : path.join(process.cwd(), htmlFile);
const validationErrors = [];
try {
const page = await browser.newPage();
page.on('console', (msg) => {
// Log the message text to your test runner's console
console.log(`Browser console: ${msg.text()}`);
});
await page.goto(`file://${filePath}`);
bodyDimensions = await getBodyDimensions(page);
await page.setViewportSize({
width: Math.round(bodyDimensions.width),
height: Math.round(bodyDimensions.height)
});
slideData = await extractSlideData(page);
} finally {
await browser.close();
}
// Collect all validation errors
if (bodyDimensions.errors && bodyDimensions.errors.length > 0) {
validationErrors.push(...bodyDimensions.errors);
}
const dimensionErrors = validateDimensions(bodyDimensions, pres);
if (dimensionErrors.length > 0) {
validationErrors.push(...dimensionErrors);
}
const textBoxPositionErrors = validateTextBoxPosition(slideData, bodyDimensions);
if (textBoxPositionErrors.length > 0) {
validationErrors.push(...textBoxPositionErrors);
}
if (slideData.errors && slideData.errors.length > 0) {
validationErrors.push(...slideData.errors);
}
// Throw all errors at once if any exist
if (validationErrors.length > 0) {
const errorMessage = validationErrors.length === 1
? validationErrors[0]
: `Multiple validation errors found:\n${validationErrors.map((e, i) => ` ${i + 1}. ${e}`).join('\n')}`;
throw new Error(errorMessage);
}
const targetSlide = slide || pres.addSlide();
await addBackground(slideData, targetSlide, tmpDir);
addElements(slideData, targetSlide, pres);
return { slide: targetSlide, placeholders: slideData.placeholders };
} catch (error) {
if (!error.message.startsWith(htmlFile)) {
throw new Error(`${htmlFile}: ${error.message}`);
}
throw error;
}
}
module.exports = html2pptx;#!/usr/bin/env python3
"""
read_excel.py — 读取 Excel 文件并输出结构化摘要
用法:
python3 read_excel.py data.xlsx
python3 read_excel.py data.xlsx --sheet "Sheet1"
python3 read_excel.py data.xlsx --format csv
python3 read_excel.py data.xlsx --summary
python3 read_excel.py data.xlsx --head 20
参数:
文件路径 Excel 文件(.xlsx / .xls)
--sheet NAME 指定工作表名称(默认读取所有)
--format FMT 输出格式:markdown(默认)/ csv / json
--summary 仅输出数据概览(行列数、字段类型、空值统计)
--head N 仅输出前 N 行(默认全部)
依赖:
pip install openpyxl pandas
"""
import sys
import json
import argparse
def ensure_dependencies():
"""检查并提示安装依赖"""
missing = []
try:
import pandas
except ImportError:
missing.append('pandas')
try:
import openpyxl
except ImportError:
missing.append('openpyxl')
if missing:
print(f"缺少依赖: {', '.join(missing)}", file=sys.stderr)
print(f"请运行: pip install {' '.join(missing)}", file=sys.stderr)
sys.exit(1)
ensure_dependencies()
import pandas as pd
def read_excel(filepath, sheet_name=None, output_format='markdown', summary_only=False, head=None):
"""读取 Excel 并格式化输出"""
try:
# 读取所有工作表或指定工作表
if sheet_name:
sheets = {sheet_name: pd.read_excel(filepath, sheet_name=sheet_name)}
else:
sheets = pd.read_excel(filepath, sheet_name=None)
except Exception as e:
print(f"读取失败: {e}", file=sys.stderr)
sys.exit(1)
for name, df in sheets.items():
print(f"\n{'='*60}")
print(f"工作表: {name}")
print(f"{'='*60}")
print(f"行数: {len(df)} | 列数: {len(df.columns)}")
if summary_only:
print(f"\n字段信息:")
print(f"{'字段名':<30} {'类型':<15} {'非空数':<10} {'空值率'}")
print(f"{'-'*30} {'-'*15} {'-'*10} {'-'*10}")
for col in df.columns:
dtype = str(df[col].dtype)
non_null = df[col].count()
null_pct = f"{(1 - non_null/len(df))*100:.1f}%" if len(df) > 0 else "N/A"
print(f"{str(col):<30} {dtype:<15} {non_null:<10} {null_pct}")
# 数值列的基础统计
numeric_cols = df.select_dtypes(include=['number']).columns
if len(numeric_cols) > 0:
print(f"\n数值统计:")
desc = df[numeric_cols].describe()
print(desc.to_string())
continue
# 限制行数
display_df = df.head(head) if head else df
if output_format == 'markdown':
print(f"\n{display_df.to_markdown(index=False)}")
elif output_format == 'csv':
print(f"\n{display_df.to_csv(index=False)}")
elif output_format == 'json':
print(f"\n{display_df.to_json(orient='records', force_ascii=False, indent=2)}")
if head and head < len(df):
print(f"\n... 已显示前 {head} 行,共 {len(df)} 行")
def main():
parser = argparse.ArgumentParser(description='读取 Excel 文件')
parser.add_argument('filepath', help='Excel 文件路径')
parser.add_argument('--sheet', help='指定工作表名称')
parser.add_argument('--format', choices=['markdown', 'csv', 'json'], default='markdown', help='输出格式')
parser.add_argument('--summary', action='store_true', help='仅输出数据概览')
parser.add_argument('--head', type=int, help='仅输出前 N 行')
args = parser.parse_args()
read_excel(args.filepath, args.sheet, args.format, args.summary, args.head)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
read_pptx.py — 读取 PPTX 文件并输出结构化内容
用法:
python3 read_pptx.py presentation.pptx
python3 read_pptx.py presentation.pptx --format markdown
python3 read_pptx.py presentation.pptx --thumbnails ./thumbs/
python3 read_pptx.py presentation.pptx --inventory
参数:
文件路径 PPTX 文件
--format FMT 输出格式:text(默认)/ markdown / json
--thumbnails DIR 将每页导出为缩略图到指定目录
--inventory 仅输出结构信息(页数、布局、尺寸)
--slide N 仅输出第 N 页(从1开始)
依赖:
pip install python-pptx Pillow
"""
import sys
import json
import argparse
import os
def ensure_dependencies():
"""检查并提示安装依赖"""
missing = []
try:
import pptx
except ImportError:
missing.append('python-pptx')
if missing:
print(f"缺少依赖: {', '.join(missing)}", file=sys.stderr)
print(f"请运行: pip install {' '.join(missing)}", file=sys.stderr)
sys.exit(1)
ensure_dependencies()
from pptx import Presentation
from pptx.util import Inches, Pt, Emu
def read_pptx(filepath, output_format='text', inventory_only=False, slide_num=None):
"""读取 PPTX 并格式化输出"""
try:
prs = Presentation(filepath)
except Exception as e:
print(f"读取失败: {e}", file=sys.stderr)
sys.exit(1)
width_in = prs.slide_width / 914400
height_in = prs.slide_height / 914400
total_slides = len(prs.slides)
print(f"文件: {os.path.basename(filepath)}")
print(f"尺寸: {width_in:.1f}\" x {height_in:.1f}\" ({width_in/height_in:.2f}:1)")
print(f"页数: {total_slides}")
if inventory_only:
print(f"\n幻灯片清单:")
for i, slide in enumerate(prs.slides, 1):
layout_name = slide.slide_layout.name if slide.slide_layout else "无布局"
shapes_count = len(slide.shapes)
has_notes = bool(slide.notes_slide and slide.notes_slide.notes_text_frame.text.strip())
text_preview = ""
for shape in slide.shapes:
if shape.has_text_frame:
text_preview = shape.text_frame.text.strip()[:60]
if text_preview:
break
print(f" [{i:>3}] 布局: {layout_name:<20} 元素: {shapes_count:<4} 备注: {'有' if has_notes else '无'} {text_preview}")
return
# 提取内容
slides_data = []
for i, slide in enumerate(prs.slides, 1):
if slide_num and i != slide_num:
continue
slide_data = {
'number': i,
'layout': slide.slide_layout.name if slide.slide_layout else None,
'elements': []
}
for shape in slide.shapes:
element = {
'type': shape.shape_type,
'name': shape.name,
'position': {
'left': round(shape.left / 914400, 2) if shape.left else 0,
'top': round(shape.top / 914400, 2) if shape.top else 0,
'width': round(shape.width / 914400, 2) if shape.width else 0,
'height': round(shape.height / 914400, 2) if shape.height else 0
}
}
if shape.has_text_frame:
paragraphs = []
for para in shape.text_frame.paragraphs:
text = para.text.strip()
if text:
paragraphs.append(text)
element['text'] = paragraphs
if shape.has_table:
table = shape.table
rows = []
for row in table.rows:
cells = [cell.text.strip() for cell in row.cells]
rows.append(cells)
element['table'] = rows
if hasattr(shape, 'image'):
try:
element['image'] = {
'content_type': shape.image.content_type,
'size': len(shape.image.blob)
}
except Exception:
pass
slide_data['elements'].append(element)
# 提取备注
if slide.notes_slide and slide.notes_slide.notes_text_frame.text.strip():
slide_data['notes'] = slide.notes_slide.notes_text_frame.text.strip()
slides_data.append(slide_data)
# 输出
if output_format == 'json':
print(json.dumps(slides_data, ensure_ascii=False, indent=2))
elif output_format == 'markdown':
for slide_data in slides_data:
print(f"\n## 第 {slide_data['number']} 页")
if slide_data.get('layout'):
print(f"*布局: {slide_data['layout']}*\n")
for el in slide_data['elements']:
if 'text' in el and el['text']:
for para in el['text']:
print(f"- {para}")
if 'table' in el:
table = el['table']
if len(table) > 0:
# 表头
print(f"\n| {'|'.join(table[0])} |")
print(f"|{'|'.join(['---'] * len(table[0]))}|")
for row in table[1:]:
print(f"| {'|'.join(row)} |")
print()
if slide_data.get('notes'):
print(f"\n> 备注: {slide_data['notes']}")
else:
for slide_data in slides_data:
print(f"\n{'='*50}")
print(f"第 {slide_data['number']} 页 (布局: {slide_data.get('layout', '无')})")
print(f"{'='*50}")
for el in slide_data['elements']:
if 'text' in el and el['text']:
for para in el['text']:
print(f" {para}")
if 'table' in el:
for row in el['table']:
print(f" | {' | '.join(row)} |")
if slide_data.get('notes'):
print(f" [备注] {slide_data['notes']}")
def export_thumbnails(filepath, output_dir):
"""导出缩略图(需要 Pillow)"""
try:
from PIL import Image
import io
except ImportError:
print("导出缩略图需要 Pillow: pip install Pillow", file=sys.stderr)
sys.exit(1)
# 缩略图方案:用 Playwright 截图每页 HTML(如果有源文件)
# 否则提取 PPT 中的图片作为参考
print("提示:PPTX 原生缩略图导出建议使用 LibreOffice:")
print(f" libreoffice --headless --convert-to png {filepath}")
print(f"或使用 Playwright 截图 HTML 源文件(如果有的话)")
def main():
parser = argparse.ArgumentParser(description='读取 PPTX 文件')
parser.add_argument('filepath', help='PPTX 文件路径')
parser.add_argument('--format', choices=['text', 'markdown', 'json'], default='text', help='输出格式')
parser.add_argument('--inventory', action='store_true', help='仅输出结构信息')
parser.add_argument('--thumbnails', help='导出缩略图到指定目录')
parser.add_argument('--slide', type=int, help='仅输出第 N 页')
args = parser.parse_args()
if args.thumbnails:
export_thumbnails(args.filepath, args.thumbnails)
else:
read_pptx(args.filepath, args.format, args.inventory, args.slide)
if __name__ == '__main__':
main()
Related skills
How it compares
Choose huashu-data-pro for analyst-style Excel-to-report workflows; use notebook-centric ML skills for model training pipelines.
FAQ
What output format does huashu-data-pro prefer?
huashu-data-pro defaults to interactive HTML reports with ECharts charts and PDF export, and uses slide-style HTML when the user explicitly requests PPT presentations.
What data sources does huashu-data-pro handle?
huashu-data-pro handles Excel spreadsheets and ad campaign datasets, supporting ROI analysis, formula generation, and visualization without fabricating numbers.