feat(ppt): 研发报告PPT重做设计系统——深海军蓝主色+亮蓝强调统一配色;封面深色满版+强调短线+方向标签;四节章节分隔页(带节序号);内容页强调竖条标题+多级要点缩进+粗体引导词着色;markdown表格真渲染pptx Table(表头深蓝白字隔行浅底自适应列宽);页脚页码+署名;长内容自动分页;目视验证13页2表格排版专业
This commit is contained in:
parent
8c5bf498c8
commit
47f191fc81
@ -1,12 +1,15 @@
|
||||
"""研发报告 PPT 生成:markdown 报告正文 → .pptx 文件(可下载查证)。
|
||||
"""研发报告 PPT 生成:markdown 报告正文 → .pptx(专业排版,可下载查证)。
|
||||
|
||||
触发时机:opp_submit_report 提交人工确认时生成/更新,存到
|
||||
项目工作空间 deliverables/ 目录(与报告同源,人工确认时可直接下载审阅)。
|
||||
触发时机:opp_submit_report 提交人工确认时生成/更新,存项目工作空间 deliverables/。
|
||||
|
||||
设计取舍:
|
||||
- python-pptx 纯后端生成,无外部模板依赖(模板缺失时降级纯文本版式)。
|
||||
- markdown 按 # / ## 切页;列表/表格转要点;来源链接保留为文本(PPT 超链接
|
||||
兼容性差,查证走界面按钮)。
|
||||
设计系统(2026-09-11 重做——旧版纯白底+平铺要点,用户反馈"没有排版和风格,很难看"):
|
||||
- 统一配色:深海军蓝主色 + 亮蓝强调 + 中性灰阶,全文一致不混色;
|
||||
- 封面页:深色满版背景 + 大标题 + 方向标签 + 日期 + 数据来源署名;
|
||||
- 章节页:四节模板(研发场景/输入/输出/成果)各一张深色分隔页,带节序号;
|
||||
- 内容页:顶部强调条 + 标题 + 正文区,多级要点缩进,粗体引导词单独着色;
|
||||
- **表格真渲染**:markdown 表格转 pptx Table(表头深蓝白字、隔行浅底),不再拍平成文本行;
|
||||
- 页脚:页码 + 署名;长内容自动分页(≤7 要点/页);
|
||||
- python-pptx 纯后端,无外部模板依赖;import 失败如实降级报错(不静默产出丑文件)。
|
||||
"""
|
||||
|
||||
import logging
|
||||
@ -15,141 +18,476 @@ import re
|
||||
|
||||
logger = logging.getLogger("pipeline.opportunity.ppt")
|
||||
|
||||
# ── 设计令牌(配色/字号/版式,集中一处便于统一调风格)──
|
||||
NAVY = (0x0E, 0x2A, 0x5C) # 主色:深海军蓝(封面/章节页背景、表头)
|
||||
NAVY_DARK = (0x08, 0x1C, 0x3E) # 封面渐变下段
|
||||
ACCENT = (0x2F, 0x6F, 0xED) # 强调亮蓝(顶部条、要点符、引导词)
|
||||
ACCENT_SOFT = (0xE8, 0xF0, 0xFE) # 强调浅底(要点卡片底)
|
||||
INK = (0x1F, 0x29, 0x37) # 正文墨色
|
||||
SLATE = (0x47, 0x55, 0x69) # 次级正文
|
||||
MUTED = (0x94, 0xA3, 0xB8) # 弱化(署名/页码)
|
||||
LINE = (0xE2, 0xE8, 0xF0) # 分隔线
|
||||
ROW_ALT = (0xF6, 0xF8, 0xFC) # 表格隔行
|
||||
WHITE = (0xFF, 0xFF, 0xFF)
|
||||
|
||||
def _md_to_sections(content):
|
||||
"""markdown 正文切分为 [(标题, [要点行], 原文块)] 段。
|
||||
FONT = "Microsoft YaHei" # 中文优先(缺失时 PowerPoint 自动回退)
|
||||
|
||||
# 一级标题视为章节页;无标题的连续文本归入"正文"页。
|
||||
"""
|
||||
sections = []
|
||||
cur_title = ""
|
||||
cur_lines = []
|
||||
for raw in (content or "").split("\n"):
|
||||
line = raw.rstrip()
|
||||
m = re.match(r"^(#{1,3})\s+(.+)$", line)
|
||||
if m and len(m.group(1)) <= 2:
|
||||
if cur_title or cur_lines:
|
||||
sections.append((cur_title, cur_lines))
|
||||
cur_title = m.group(2).strip()
|
||||
cur_lines = []
|
||||
SW, SH = 13.333, 7.5 # 16:9 英寸
|
||||
MARGIN_X = 0.72
|
||||
CONTENT_TOP = 1.42
|
||||
CONTENT_H = 5.18
|
||||
MAX_BULLETS_PER_PAGE = 7
|
||||
|
||||
# 四节模板顺序(与 opp_report_capability.REPORT_SECTIONS 一致)
|
||||
SECTION_ORDER = ("研发场景", "输入", "输出", "成果")
|
||||
|
||||
|
||||
def _rgb(t):
|
||||
from pptx.dml.color import RGBColor
|
||||
return RGBColor(t[0], t[1], t[2])
|
||||
|
||||
|
||||
# ══════════════════════ markdown 解析 ══════════════════════
|
||||
|
||||
def _split_blocks(md):
|
||||
"""把 markdown 段切成结构化块:[('h', lvl, text) | ('p', text) | ('ul', lvl, text)
|
||||
| ('ol', num, text) | ('table', [rows]) | ('quote', text)]。"""
|
||||
blocks = []
|
||||
lines = (md or "").split("\n")
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
raw = lines[i].rstrip()
|
||||
s = raw.strip()
|
||||
if not s:
|
||||
i += 1
|
||||
continue
|
||||
if line.strip():
|
||||
cur_lines.append(line)
|
||||
if cur_title or cur_lines:
|
||||
sections.append((cur_title, cur_lines))
|
||||
return sections
|
||||
# 表格:当前行是 | 开头且下一行是分隔线
|
||||
if s.startswith("|") and i + 1 < len(lines) and re.match(r"^\s*\|?[\s:|-]+\|?\s*$", lines[i + 1]) \
|
||||
and "-" in lines[i + 1]:
|
||||
rows = [_split_row(s)]
|
||||
i += 2
|
||||
while i < len(lines) and lines[i].strip().startswith("|"):
|
||||
rows.append(_split_row(lines[i].strip()))
|
||||
i += 1
|
||||
blocks.append(("table", rows))
|
||||
continue
|
||||
m = re.match(r"^(#{1,4})\s+(.+)$", s)
|
||||
if m:
|
||||
blocks.append(("h", len(m.group(1)), m.group(2).strip()))
|
||||
i += 1
|
||||
continue
|
||||
m = re.match(r"^>\s*(.+)$", s)
|
||||
if m:
|
||||
blocks.append(("quote", m.group(1).strip()))
|
||||
i += 1
|
||||
continue
|
||||
m = re.match(r"^[-*+]\s+(.+)$", s)
|
||||
if m:
|
||||
indent = len(raw) - len(raw.lstrip())
|
||||
lvl = 1 if indent < 2 else (2 if indent < 4 else 3)
|
||||
blocks.append(("ul", lvl, m.group(1).strip()))
|
||||
i += 1
|
||||
continue
|
||||
m = re.match(r"^(\d+)[.、)]\s*(.+)$", s)
|
||||
if m:
|
||||
blocks.append(("ol", m.group(1), m.group(2).strip()))
|
||||
i += 1
|
||||
continue
|
||||
if re.match(r"^[-=*_]{3,}$", s): # 水平线 → 忽略
|
||||
i += 1
|
||||
continue
|
||||
# 普通段落:合并连续行
|
||||
para = [s]
|
||||
i += 1
|
||||
while i < len(lines):
|
||||
ns = lines[i].strip()
|
||||
if not ns or ns.startswith(("#", "|", ">", "- ", "* ", "+ ")) \
|
||||
or re.match(r"^\d+[.、)]", ns) or re.match(r"^[-=*_]{3,}$", ns):
|
||||
break
|
||||
para.append(ns)
|
||||
i += 1
|
||||
blocks.append(("p", " ".join(para)))
|
||||
return blocks
|
||||
|
||||
|
||||
def _line_to_bullet(line):
|
||||
"""markdown 行 → PPT 要点文本(去标记、保留链接为 文字(链接))。"""
|
||||
def _split_row(line):
|
||||
"""markdown 表格行 → 单元格列表。"""
|
||||
s = line.strip()
|
||||
s = re.sub(r"^\s*[-*+]\s+", "", s) # 列表符
|
||||
s = re.sub(r"^\s*\d+[.、)]\s*", "", s) # 有序列表
|
||||
s = re.sub(r"\*\*(.+?)\*\*", r"\1", s) # 粗体
|
||||
s = re.sub(r"\*(.+?)\*", r"\1", s) # 斜体
|
||||
s = re.sub(r"^\|.*\|$", lambda m: " | ".join(
|
||||
c.strip() for c in m.group(0).strip("|").split("|") if c.strip()), s) # 表格行
|
||||
if re.match(r"^[-: =]+$", s):
|
||||
return "" # 表格分隔线
|
||||
s = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1(\2)", s) # 链接 → 文字(链接)
|
||||
if s.startswith("|"):
|
||||
s = s[1:]
|
||||
if s.endswith("|"):
|
||||
s = s[:-1]
|
||||
return [_clean_inline(c.strip()) for c in s.split("|")]
|
||||
|
||||
|
||||
def _clean_inline(s):
|
||||
"""去除行内 markdown 标记(粗体/斜体/行内码/链接)。"""
|
||||
s = s or ""
|
||||
s = re.sub(r"\*\*(.+?)\*\*", r"\1", s)
|
||||
s = re.sub(r"__(.+?)__", r"\1", s)
|
||||
s = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"\1", s)
|
||||
s = re.sub(r"`([^`]+)`", r"\1", s)
|
||||
s = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1", s) # 链接只留文字(PPT 查证走界面)
|
||||
s = re.sub(r"~~(.+?)~~", r"\1", s)
|
||||
return s.strip()
|
||||
|
||||
|
||||
def _split_lead(text):
|
||||
"""拆出粗体引导词:「**结论**:正文」→ ('结论', '正文');无引导词返回 ('', text)。"""
|
||||
m = re.match(r"^\s*\*\*(.+?)\*\*\s*[::]?\s*(.*)$", text or "")
|
||||
if m and m.group(1).strip():
|
||||
return m.group(1).strip(), (m.group(2) or "").strip()
|
||||
return "", _clean_inline(text)
|
||||
|
||||
|
||||
def _parse_report(content):
|
||||
"""按四节模板解析(与详情页同口径):返回 [(节名, markdown)]。
|
||||
|
||||
无四节标题的旧报告 → 整篇归「输出」节(如实呈现,不编造分节)。
|
||||
"""
|
||||
content = content or ""
|
||||
pat = re.compile(
|
||||
r"^##\s*(?:[一二三四五六七八九十\d]+[、..]?\s*)?[*]*\s*"
|
||||
r"(研发场景|输入|输出|成果)\s*[*]*\s*[::]?\s*$", re.MULTILINE)
|
||||
marks = list(pat.finditer(content))
|
||||
if not marks:
|
||||
return [("输出", content.strip())]
|
||||
out = []
|
||||
preamble = content[:marks[0].start()].strip()
|
||||
if preamble:
|
||||
out.append(("概览", preamble))
|
||||
for i, m in enumerate(marks):
|
||||
end = marks[i + 1].start() if i + 1 < len(marks) else len(content)
|
||||
body = content[m.end():end].strip()
|
||||
if body:
|
||||
out.append((m.group(1), body))
|
||||
return out or [("输出", content.strip())]
|
||||
|
||||
|
||||
# ══════════════════════ 版式基元 ══════════════════════
|
||||
|
||||
def _rect(slide, x, y, w, h, color, line_color=None):
|
||||
from pptx.enum.shapes import MSO_SHAPE
|
||||
from pptx.util import Inches
|
||||
sh = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(x), Inches(y), Inches(w), Inches(h))
|
||||
sh.fill.solid()
|
||||
sh.fill.fore_color.rgb = _rgb(color)
|
||||
if line_color is None:
|
||||
sh.line.fill.background()
|
||||
else:
|
||||
sh.line.color.rgb = _rgb(line_color)
|
||||
sh.line.width = Inches(0.008)
|
||||
sh.shadow.inherit = False
|
||||
return sh
|
||||
|
||||
|
||||
def _text(slide, x, y, w, h, runs, size=15, color=INK, bold=False,
|
||||
align=None, space_after=0, line_spacing=1.15):
|
||||
"""放一个文本框;runs 可以是 str 或 [(text, {size,color,bold}), ...] 列表。"""
|
||||
from pptx.util import Inches, Pt
|
||||
box = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
|
||||
tf = box.text_frame
|
||||
tf.word_wrap = True
|
||||
tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0
|
||||
p = tf.paragraphs[0]
|
||||
if align is not None:
|
||||
p.alignment = align
|
||||
p.space_after = Pt(space_after)
|
||||
p.line_spacing = line_spacing
|
||||
if isinstance(runs, str):
|
||||
runs = [(runs, {})]
|
||||
for txt, style in runs:
|
||||
if not txt:
|
||||
continue
|
||||
r = p.add_run()
|
||||
r.text = txt
|
||||
r.font.size = Pt(style.get("size", size))
|
||||
r.font.bold = style.get("bold", bold)
|
||||
r.font.color.rgb = _rgb(style.get("color", color))
|
||||
r.font.name = FONT
|
||||
return box
|
||||
|
||||
|
||||
def _footer(slide, page_no, total=None):
|
||||
"""页脚:细分隔线 + 页码 + 署名。"""
|
||||
from pptx.util import Inches
|
||||
from pptx.enum.text import PP_ALIGN
|
||||
_rect(slide, MARGIN_X, SH - 0.52, SW - 2 * MARGIN_X, 0.012, LINE)
|
||||
_text(slide, MARGIN_X, SH - 0.44, SW - 2 * MARGIN_X - 1.2, 0.3,
|
||||
"商机产线 · 数据来源可查证", size=9, color=MUTED)
|
||||
label = "%d" % page_no if not total else "%d / %d" % (page_no, total)
|
||||
_text(slide, SW - MARGIN_X - 1.2, SH - 0.44, 1.2, 0.3, label,
|
||||
size=9, color=MUTED, align=PP_ALIGN.RIGHT)
|
||||
|
||||
|
||||
def _content_header(slide, title, kicker=""):
|
||||
"""内容页页眉:强调竖条 + 标题(+ 可选小标签)。"""
|
||||
from pptx.util import Inches
|
||||
_rect(slide, MARGIN_X, 0.52, 0.085, 0.52, ACCENT)
|
||||
_text(slide, MARGIN_X + 0.24, 0.48, SW - 2 * MARGIN_X - 0.3, 0.62,
|
||||
_clean_inline(title), size=23, color=NAVY, bold=True)
|
||||
if kicker:
|
||||
_text(slide, MARGIN_X + 0.24, 1.08, SW - 2 * MARGIN_X - 0.3, 0.28,
|
||||
_clean_inline(kicker), size=11, color=MUTED)
|
||||
|
||||
|
||||
# ══════════════════════ 表格渲染(真 Table,非文本) ══════════════════════
|
||||
|
||||
def _add_table(slide, rows, top, width=None):
|
||||
"""渲染 markdown 表格为 pptx Table;返回占用高度(英寸)。超宽自动缩字号。"""
|
||||
from pptx.util import Inches, Pt
|
||||
if not rows or not rows[0]:
|
||||
return 0.0
|
||||
width = width or (SW - 2 * MARGIN_X)
|
||||
ncol = max(len(r) for r in rows)
|
||||
rows = [r + [""] * (ncol - len(r)) for r in rows]
|
||||
nrow = len(rows)
|
||||
row_h = 0.36
|
||||
avail = SH - 0.95 - top
|
||||
max_rows = max(2, int(avail / row_h))
|
||||
if nrow > max_rows:
|
||||
rows = rows[:max_rows - 1] + [["… 其余 %d 行见平台详情页" % (nrow - max_rows + 1)] + [""] * (ncol - 1)]
|
||||
nrow = len(rows)
|
||||
total_h = min(row_h * nrow, avail)
|
||||
gf = slide.shapes.add_table(nrow, ncol, Inches(MARGIN_X), Inches(top),
|
||||
Inches(width), Inches(total_h))
|
||||
tbl = gf.table
|
||||
# 列宽:首列略宽(多为名称),其余均分
|
||||
first = min(width * 0.30, 4.6)
|
||||
rest = (width - first) / max(1, ncol - 1)
|
||||
for ci in range(ncol):
|
||||
tbl.columns[ci].width = Inches(first if ci == 0 else rest)
|
||||
fs = 11 if ncol <= 5 else (10 if ncol <= 7 else 9)
|
||||
for ri, row in enumerate(rows):
|
||||
tbl.rows[ri].height = Inches(total_h / nrow)
|
||||
for ci, val in enumerate(row):
|
||||
cell = tbl.cell(ri, ci)
|
||||
cell.margin_left = cell.margin_right = Inches(0.07)
|
||||
cell.margin_top = cell.margin_bottom = Inches(0.03)
|
||||
cell.vertical_anchor = 3 # MIDDLE
|
||||
cell.fill.solid()
|
||||
if ri == 0:
|
||||
cell.fill.fore_color.rgb = _rgb(NAVY)
|
||||
else:
|
||||
cell.fill.fore_color.rgb = _rgb(WHITE if ri % 2 else ROW_ALT)
|
||||
tf = cell.text_frame
|
||||
tf.word_wrap = True
|
||||
p = tf.paragraphs[0]
|
||||
r = p.add_run()
|
||||
r.text = (val or "")[:180]
|
||||
r.font.size = Pt(fs if ri else fs)
|
||||
r.font.bold = (ri == 0)
|
||||
r.font.name = FONT
|
||||
r.font.color.rgb = _rgb(WHITE if ri == 0 else (INK if ci == 0 else SLATE))
|
||||
return total_h + 0.18
|
||||
|
||||
|
||||
# ══════════════════════ 页面构建 ══════════════════════
|
||||
|
||||
def _cover(prs, title, software, date_str):
|
||||
from pptx.util import Inches, Pt
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
_rect(slide, 0, 0, SW, SH, NAVY)
|
||||
_rect(slide, 0, SH * 0.62, SW, SH * 0.38, NAVY_DARK)
|
||||
_rect(slide, MARGIN_X, 2.28, 1.5, 0.075, ACCENT) # 强调短线
|
||||
_text(slide, MARGIN_X, 1.62, SW - 2 * MARGIN_X, 0.4,
|
||||
"RESEARCH REPORT · 研发商机报告", size=13, color=(0x9F, 0xC0, 0xFF))
|
||||
_text(slide, MARGIN_X, 2.62, SW - 2 * MARGIN_X - 0.8, 1.9,
|
||||
_clean_inline(title) or "研发报告", size=38, color=WHITE, bold=True, line_spacing=1.12)
|
||||
if software:
|
||||
_rect(slide, MARGIN_X, 4.62, 0.055, 0.42, ACCENT)
|
||||
_text(slide, MARGIN_X + 0.22, 4.66, 8.0, 0.4,
|
||||
"方向:%s" % _clean_inline(software), size=16, color=(0xC7, 0xD7, 0xF5))
|
||||
_text(slide, MARGIN_X, SH - 1.16, SW - 2 * MARGIN_X, 0.34,
|
||||
date_str, size=12, color=(0x8F, 0xA6, 0xCC))
|
||||
_text(slide, MARGIN_X, SH - 0.82, SW - 2 * MARGIN_X, 0.34,
|
||||
"商机产线 · 全部数据来自数据爬取平台(内网采集、已去重、逐条可查证)",
|
||||
size=11, color=(0x7B, 0x92, 0xBB))
|
||||
return slide
|
||||
|
||||
|
||||
def _section_divider(prs, idx, name, total_sections):
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
_rect(slide, 0, 0, SW, SH, NAVY)
|
||||
_rect(slide, 0, 0, 4.35, SH, NAVY_DARK)
|
||||
from pptx.util import Inches
|
||||
_text(slide, 0.85, 2.55, 2.9, 1.6, "%02d" % idx, size=76, color=ACCENT, bold=True)
|
||||
_rect(slide, 5.05, 3.02, 0.9, 0.06, ACCENT)
|
||||
_text(slide, 5.05, 3.28, SW - 5.05 - 0.8, 1.1, _clean_inline(name),
|
||||
size=32, color=WHITE, bold=True)
|
||||
_text(slide, 5.05, 4.42, SW - 5.05 - 0.8, 0.4,
|
||||
"SECTION %d OF %d" % (idx, total_sections), size=11, color=(0x8F, 0xA6, 0xCC))
|
||||
return slide
|
||||
|
||||
|
||||
def _bullet_slide(prs, title, kicker, items, page_no):
|
||||
"""要点页:items = [(level, lead, text)]。"""
|
||||
from pptx.util import Inches, Pt
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
_rect(slide, 0, 0, SW, SH, WHITE)
|
||||
_rect(slide, 0, 0, SW, 0.14, ACCENT)
|
||||
_content_header(slide, title, kicker)
|
||||
top = CONTENT_TOP + (0.26 if kicker else 0.0)
|
||||
y = top
|
||||
for lvl, lead, text in items:
|
||||
indent = 0.0 if lvl <= 1 else (0.34 if lvl == 2 else 0.68)
|
||||
marker = "▪" if lvl <= 1 else "–"
|
||||
mcolor = ACCENT if lvl <= 1 else MUTED
|
||||
_text(slide, MARGIN_X + indent, y, 0.26, 0.3, marker,
|
||||
size=12 if lvl <= 1 else 11, color=mcolor, bold=True)
|
||||
runs = []
|
||||
if lead:
|
||||
runs.append((lead, {"size": 14, "bold": True, "color": NAVY}))
|
||||
runs.append((" ", {}))
|
||||
if text:
|
||||
runs.append((text, {"size": 14 if lvl <= 1 else 13,
|
||||
"color": INK if lvl <= 1 else SLATE}))
|
||||
if not runs:
|
||||
runs = [("(空)", {"size": 13, "color": MUTED})]
|
||||
_text(slide, MARGIN_X + indent + 0.26, y - 0.02,
|
||||
SW - 2 * MARGIN_X - indent - 0.26, 0.72, runs, line_spacing=1.18)
|
||||
est = max(1, int(len((lead or "") + (text or "")) / 46) + 1)
|
||||
y += 0.30 * est + 0.16
|
||||
_footer(slide, page_no)
|
||||
return slide
|
||||
|
||||
|
||||
def _table_slide(prs, title, kicker, table_rows, extra_items, page_no):
|
||||
from pptx.util import Inches
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
_rect(slide, 0, 0, SW, SH, WHITE)
|
||||
_rect(slide, 0, 0, SW, 0.14, ACCENT)
|
||||
_content_header(slide, title, kicker)
|
||||
top = CONTENT_TOP + (0.26 if kicker else 0.0)
|
||||
used = _add_table(slide, table_rows, top)
|
||||
if extra_items:
|
||||
y = top + used + 0.05
|
||||
for lvl, lead, text in extra_items:
|
||||
_text(slide, MARGIN_X, y, 0.26, 0.3, "▪", size=12, color=ACCENT, bold=True)
|
||||
runs = []
|
||||
if lead:
|
||||
runs.append((lead, {"size": 13, "bold": True, "color": NAVY}))
|
||||
runs.append((" ", {}))
|
||||
if text:
|
||||
runs.append((text, {"size": 13, "color": SLATE}))
|
||||
_text(slide, MARGIN_X + 0.26, y - 0.02, SW - 2 * MARGIN_X - 0.26, 0.5, runs)
|
||||
y += 0.30 * max(1, int(len((lead or "") + (text or "")) / 52) + 1) + 0.10
|
||||
_footer(slide, page_no)
|
||||
return slide
|
||||
|
||||
|
||||
def _closing(prs, page_no):
|
||||
from pptx.util import Inches
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
_rect(slide, 0, 0, SW, SH, NAVY)
|
||||
_rect(slide, MARGIN_X, 2.0, 1.2, 0.06, ACCENT)
|
||||
_text(slide, MARGIN_X, 2.28, SW - 2 * MARGIN_X, 0.8, "说明与查证", size=30,
|
||||
color=WHITE, bold=True)
|
||||
notes = [
|
||||
"本报告全部数据来自数据爬取平台(内网采集、已去重),未做人工编造。",
|
||||
"每条引用均带公告原文 / 采集来源,可在产线平台「📋 数据参考」逐条查证。",
|
||||
"报告状态以平台门禁为准:人工确认 → 研发审批;未经审批不得视为立项依据。",
|
||||
"功能点与成本估算为模型基于公开数据的推算,落地前需结合团队实际复核。",
|
||||
]
|
||||
y = 3.28
|
||||
for n in notes:
|
||||
_text(slide, MARGIN_X, y, 0.3, 0.3, "▪", size=12, color=ACCENT, bold=True)
|
||||
_text(slide, MARGIN_X + 0.3, y - 0.02, SW - 2 * MARGIN_X - 0.3, 0.6,
|
||||
n, size=14, color=(0xC7, 0xD7, 0xF5), line_spacing=1.2)
|
||||
y += 0.62
|
||||
return slide
|
||||
|
||||
|
||||
# ══════════════════════ 主入口 ══════════════════════
|
||||
|
||||
def build_report_ppt(title, content, software="", out_path=""):
|
||||
"""生成 PPT 并保存。返回 (True, 文件路径) 或 (False, 错误)。"""
|
||||
"""生成 PPT 并保存。返回 (True, 文件路径) 或 (False, 错误)。
|
||||
|
||||
签名保持不变(opp_report_capability._build_report_ppt 调用方零改动)。
|
||||
"""
|
||||
try:
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches, Pt
|
||||
from pptx.dml.color import RGBColor
|
||||
from pptx.util import Inches
|
||||
except ImportError:
|
||||
return False, "缺少 python-pptx(pip install python-pptx)"
|
||||
|
||||
if not out_path:
|
||||
return False, "缺少输出路径"
|
||||
|
||||
import datetime
|
||||
date_str = datetime.date.today().strftime("%Y 年 %m 月 %d 日")
|
||||
prs = Presentation()
|
||||
prs.slide_width = Inches(13.333)
|
||||
prs.slide_height = Inches(7.5)
|
||||
blank_layout = prs.slide_layouts[6]
|
||||
prs.slide_width = Inches(SW)
|
||||
prs.slide_height = Inches(SH)
|
||||
|
||||
def _add_title_bar(slide, text, color=(0x1E, 0x40, 0xAF)):
|
||||
box = slide.shapes.add_textbox(Inches(0.5), Inches(0.3), Inches(12.3), Inches(0.8))
|
||||
tf = box.text_frame
|
||||
p = tf.paragraphs[0]
|
||||
run = p.add_run()
|
||||
run.text = text
|
||||
run.font.size = Pt(26)
|
||||
run.font.bold = True
|
||||
run.font.color.rgb = RGBColor(*color)
|
||||
_cover(prs, title, software, date_str)
|
||||
|
||||
# ── 封面页 ──
|
||||
slide = prs.slides.add_slide(blank_layout)
|
||||
box = slide.shapes.add_textbox(Inches(0.8), Inches(2.6), Inches(11.7), Inches(2))
|
||||
tf = box.text_frame
|
||||
tf.word_wrap = True
|
||||
p = tf.paragraphs[0]
|
||||
run = p.add_run()
|
||||
run.text = title or "研发报告"
|
||||
run.font.size = Pt(36)
|
||||
run.font.bold = True
|
||||
run.font.color.rgb = RGBColor(0x0F, 0x17, 0x2A)
|
||||
sub = software or ""
|
||||
if sub:
|
||||
p2 = tf.add_paragraph()
|
||||
r2 = p2.add_run()
|
||||
r2.text = "方向:%s" % sub
|
||||
r2.font.size = Pt(18)
|
||||
r2.font.color.rgb = RGBColor(0x64, 0x74, 0x8B)
|
||||
p3 = tf.add_paragraph()
|
||||
r3 = p3.add_run()
|
||||
r3.text = "商机产线 · 数据来自数据爬取平台(可查证)"
|
||||
r3.font.size = Pt(14)
|
||||
r3.font.color.rgb = RGBColor(0x94, 0xA3, 0xB8)
|
||||
# 预扫描:算出要渲染的页面,便于一次性算总页数(页脚 x/y)
|
||||
pages = [] # [('divider', idx, name) | ('bullets', title, kicker, items)
|
||||
# | ('table', title, kicker, rows, extra)]
|
||||
sections = _parse_report(content)
|
||||
sec_idx = 0
|
||||
for sec_name, sec_md in sections:
|
||||
if sec_name in SECTION_ORDER:
|
||||
sec_idx += 1
|
||||
pages.append(("divider", sec_idx, sec_name))
|
||||
blocks = _split_blocks(sec_md)
|
||||
cur_title = sec_name if sec_name not in SECTION_ORDER else sec_name
|
||||
buf_items = [] # [(lvl, lead, text)]
|
||||
pending_table = None
|
||||
|
||||
# ── 内容页 ──
|
||||
sections = _md_to_sections(content)
|
||||
for sec_title, lines in sections:
|
||||
bullets = [b for b in (_line_to_bullet(l) for l in lines) if b]
|
||||
if not sec_title and not bullets:
|
||||
continue
|
||||
# 要点过多 → 分页(每页 ≤ 8 条,防溢出)
|
||||
chunks = [bullets[i:i + 8] for i in range(0, max(len(bullets), 1), 8)] or [[]]
|
||||
for ci, chunk in enumerate(chunks):
|
||||
slide = prs.slides.add_slide(blank_layout)
|
||||
t = sec_title or "正文"
|
||||
if len(chunks) > 1:
|
||||
t += "(%d/%d)" % (ci + 1, len(chunks))
|
||||
_add_title_bar(slide, t)
|
||||
box = slide.shapes.add_textbox(Inches(0.7), Inches(1.3), Inches(12), Inches(5.8))
|
||||
tf = box.text_frame
|
||||
tf.word_wrap = True
|
||||
first = True
|
||||
for b in chunk:
|
||||
p = tf.paragraphs[0] if first else tf.add_paragraph()
|
||||
first = False
|
||||
run = p.add_run()
|
||||
run.text = "• " + b
|
||||
run.font.size = Pt(15)
|
||||
run.font.color.rgb = RGBColor(0x33, 0x41, 0x55)
|
||||
p.space_after = Pt(8)
|
||||
def flush(kicker=""):
|
||||
if not buf_items and pending_table is None:
|
||||
return
|
||||
if pending_table is not None:
|
||||
pages.append(("table", cur_title, kicker, pending_table, list(buf_items)))
|
||||
else:
|
||||
# 按页容量切分要点
|
||||
chunk = []
|
||||
for it in buf_items:
|
||||
chunk.append(it)
|
||||
if len(chunk) >= MAX_BULLETS_PER_PAGE:
|
||||
pages.append(("bullets", cur_title, kicker, chunk))
|
||||
chunk = []
|
||||
if chunk:
|
||||
pages.append(("bullets", cur_title, kicker, chunk))
|
||||
del buf_items[:]
|
||||
|
||||
# ── 尾页 ──
|
||||
slide = prs.slides.add_slide(blank_layout)
|
||||
_add_title_bar(slide, "说明", color=(0x64, 0x74, 0x8B))
|
||||
box = slide.shapes.add_textbox(Inches(0.7), Inches(1.4), Inches(12), Inches(5))
|
||||
tf = box.text_frame
|
||||
tf.word_wrap = True
|
||||
for i, note in enumerate([
|
||||
"本报告全部数据来自数据爬取平台(内网采集、已去重)。",
|
||||
"每条引用均带公告原文/采集来源,可在产线平台「数据参考」逐条查证。",
|
||||
"报告状态以平台门禁为准(人工确认 → 研发审批),未经审批不得视为立项依据。",
|
||||
]):
|
||||
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
||||
run = p.add_run()
|
||||
run.text = note
|
||||
run.font.size = Pt(15)
|
||||
run.font.color.rgb = RGBColor(0x47, 0x55, 0x69)
|
||||
p.space_after = Pt(10)
|
||||
for b in blocks:
|
||||
kind = b[0]
|
||||
if kind == "h":
|
||||
flush()
|
||||
cur_title = b[2]
|
||||
elif kind == "table":
|
||||
# 表格独立成页(带上此前累积的要点作为补充说明)
|
||||
flush()
|
||||
pending_table = b[1]
|
||||
pages.append(("table", cur_title, "", pending_table, []))
|
||||
pending_table = None
|
||||
elif kind == "ul":
|
||||
lead, text = _split_lead(b[2])
|
||||
buf_items.append((b[1], lead, text))
|
||||
elif kind == "ol":
|
||||
lead, text = _split_lead(b[2])
|
||||
buf_items.append((1, "%s. %s" % (b[1], lead) if lead else "%s." % b[1], text))
|
||||
elif kind == "quote":
|
||||
buf_items.append((1, "", _clean_inline(b[1])))
|
||||
else: # p
|
||||
lead, text = _split_lead(b[1])
|
||||
buf_items.append((1, lead, text))
|
||||
flush()
|
||||
|
||||
# 渲染(带总页数)
|
||||
total_pages = len(pages) + 2 # 封面 + 内容 + 尾页
|
||||
page_no = 1
|
||||
for pg in pages:
|
||||
page_no += 1
|
||||
if pg[0] == "divider":
|
||||
_section_divider(prs, pg[1], pg[2], sec_idx or 1)
|
||||
elif pg[0] == "bullets":
|
||||
_bullet_slide(prs, pg[1], pg[2], pg[3], page_no)
|
||||
else:
|
||||
_table_slide(prs, pg[1], pg[2], pg[3], pg[4], page_no)
|
||||
_closing(prs, total_pages)
|
||||
|
||||
os.makedirs(os.path.dirname(out_path), exist_ok=True)
|
||||
prs.save(out_path)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user