532 lines
22 KiB
Python
532 lines
22 KiB
Python
"""研发报告 PPT 生成:markdown 报告正文 → .pptx(专业排版,可下载查证)。
|
||
|
||
触发时机:opp_submit_report 提交人工确认时生成/更新,存项目工作空间 deliverables/。
|
||
|
||
设计系统(2026-09-11 重做——旧版纯白底+平铺要点,用户反馈"没有排版和风格,很难看"):
|
||
- 统一配色:深海军蓝主色 + 亮蓝强调 + 中性灰阶,全文一致不混色;
|
||
- 封面页:深色满版背景 + 大标题 + 方向标签 + 日期 + 数据来源署名;
|
||
- 章节页:四节模板(研发场景/输入/输出/成果)各一张深色分隔页,带节序号;
|
||
- 内容页:顶部强调条 + 标题 + 正文区,多级要点缩进,粗体引导词单独着色;
|
||
- **表格真渲染**:markdown 表格转 pptx Table(表头深蓝白字、隔行浅底),不再拍平成文本行;
|
||
- 页脚:页码 + 署名;长内容自动分页(≤7 要点/页);
|
||
- python-pptx 纯后端,无外部模板依赖;import 失败如实降级报错(不静默产出丑文件)。
|
||
"""
|
||
|
||
import logging
|
||
import os
|
||
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)
|
||
|
||
FONT = "Microsoft YaHei" # 中文优先(缺失时 PowerPoint 自动回退)
|
||
|
||
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 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 _split_row(line):
|
||
"""markdown 表格行 → 单元格列表。"""
|
||
s = line.strip()
|
||
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 _disp_w(s):
|
||
"""显示宽度(CJK=1.0、ASCII≈0.55 字符单位),用于估算换行数。"""
|
||
return sum(1.0 if ord(ch) > 0x2E7F else 0.55 for ch in (s or ""))
|
||
|
||
|
||
def _add_table(slide, rows, top, width=None):
|
||
"""渲染 markdown 表格为 pptx Table;返回占用高度(英寸)。超宽自动缩字号。
|
||
|
||
行高按内容估算逐行设置(2026-09-11 实测教训:统一行高时 LibreOffice/WPS
|
||
不按换行撑行 → 长文本在行底被截断)。总高超页则截行加提示(不跨页拆表,
|
||
保持单表完整可读)。
|
||
"""
|
||
from pptx.util import Inches, Pt
|
||
import math
|
||
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)
|
||
avail = SH - 0.95 - top
|
||
# 列宽:首列略宽(多为名称),其余均分
|
||
first = min(width * 0.30, 4.6)
|
||
rest = (width - first) / max(1, ncol - 1)
|
||
col_w = [first if ci == 0 else rest for ci in range(ncol)]
|
||
fs = 11 if ncol <= 5 else (10 if ncol <= 7 else 9)
|
||
line_h = fs * 1.45 / 72.0 # 每行文本高度(英寸,含行距余量)
|
||
pad = 0.12 # 单元格上下边距+余量
|
||
|
||
def row_height(row_vals):
|
||
lines = 1
|
||
for ci, val in enumerate(row_vals):
|
||
cap = max(2.0, (col_w[ci] - 0.16) * 72.0 / fs) # 每行可容纳字符单位
|
||
n = 0
|
||
for seg in (val or "")[:180].split("\n"): # 显式换行强制断行
|
||
n += max(1, math.ceil(_disp_w(seg) / cap))
|
||
lines = max(lines, n)
|
||
return lines * line_h + pad
|
||
|
||
heights = [row_height(r) for r in rows]
|
||
total_h = sum(heights)
|
||
if total_h > avail:
|
||
# 从尾行往前砍到放得下(至少留表头+1 数据行+提示行)
|
||
keep = nrow
|
||
while keep > 3 and sum(heights[:keep]) + row_height(
|
||
["…"] + [""] * (ncol - 1)) > avail:
|
||
keep -= 1
|
||
cut = nrow - keep
|
||
rows = rows[:keep] + [["… 其余 %d 行见平台详情页" % cut] + [""] * (ncol - 1)]
|
||
heights = heights[:keep] + [row_height(rows[-1])]
|
||
nrow = len(rows)
|
||
total_h = sum(heights)
|
||
gf = slide.shapes.add_table(nrow, ncol, Inches(MARGIN_X), Inches(top),
|
||
Inches(width), Inches(total_h))
|
||
tbl = gf.table
|
||
for ci in range(ncol):
|
||
tbl.columns[ci].width = Inches(col_w[ci])
|
||
for ri, row in enumerate(rows):
|
||
tbl.rows[ri].height = Inches(heights[ri])
|
||
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, 错误)。
|
||
|
||
签名保持不变(opp_report_capability._build_report_ppt 调用方零改动)。
|
||
"""
|
||
try:
|
||
from pptx import Presentation
|
||
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(SW)
|
||
prs.slide_height = Inches(SH)
|
||
|
||
_cover(prs, title, software, date_str)
|
||
|
||
# 预扫描:算出要渲染的页面,便于一次性算总页数(页脚 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
|
||
|
||
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[:]
|
||
|
||
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)
|
||
try:
|
||
npages = len(prs.slides._sldIdLst)
|
||
except Exception:
|
||
npages = -1
|
||
logger.info("报告 PPT 已生成: %s (%d 页)", out_path, npages)
|
||
return True, out_path
|