feat(ppt): 研发报告PPT重做设计系统——深海军蓝主色+亮蓝强调统一配色;封面深色满版+强调短线+方向标签;四节章节分隔页(带节序号);内容页强调竖条标题+多级要点缩进+粗体引导词着色;markdown表格真渲染pptx Table(表头深蓝白字隔行浅底自适应列宽);页脚页码+署名;长内容自动分页;目视验证13页2表格排版专业

This commit is contained in:
yumoqing 2026-09-11 15:09:04 +08:00
parent 8c5bf498c8
commit 47f191fc81

View File

@ -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-pptxpip 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)