File size: 32,397 Bytes
d82bbe4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 | """
paper2technical workflow
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
生成时间: 2025-12-07 23:36:51
1. 在 **TOOLS** 区域定义需要暴露给 Prompt 的前置工具
2. 在 **NODES** 区域实现异步节点函数 (await-able)
3. 在 **EDGES** 区域声明有向边
4. 最后返回 builder.compile() 或 GenericGraphBuilder
"""
from __future__ import annotations
import json
import time
from pathlib import Path
from dataflow_agent.state import Paper2FigureState
from dataflow_agent.graphbuilder.graph_builder import GenericGraphBuilder
from dataflow_agent.workflow.registry import register
from dataflow_agent.agentroles import create_graph_agent, create_react_agent, create_simple_agent
from dataflow_agent.toolkits.tool_manager import get_tool_manager
from dataflow_agent.toolkits.imtool.bg_tool import (
local_tool_for_svg_render,
local_tool_for_raster_to_svg,
)
from dataflow_agent.toolkits.imtool.sam_tool import run_sam_auto, postprocess_sam_items
from dataflow_agent.toolkits.imtool.mineru_tool import (
run_aio_two_step_extract,
crop_mineru_blocks_by_type,
crop_mineru_blocks_with_meta,
svg_to_emf,
recursive_mineru_layout,
)
from dataflow_agent.logger import get_logger
from dataflow_agent.utils import get_project_root
from pptx.util import Pt
log = get_logger(__name__)
def _strip_svg_text_nodes(svg_code: str) -> str:
"""
兜底版 SVG 去文字函数,用于在 svg_bg_cleaner agent 失败时硬编码移除文本。
处理策略(尽量“只删字不动图形”):
- 删除所有 <text>...</text> 块(支持跨行、多属性写法);
- 删除所有 <tspan>...</tspan> 块;
- 删除 <title>...</title> 块;
- 删除自闭合 text / tspan 节点(如 <text .../>,<tspan .../>)。
其它图形元素(rect/circle/path/...)保留不动。
"""
import re
if not svg_code:
return svg_code
cleaned = svg_code
# 1) 删除 <title>...</title>(SVG 文档标题/图标题)
cleaned = re.sub(
r"<title[^>]*?>.*?</title>",
"",
cleaned,
flags=re.IGNORECASE | re.DOTALL,
)
# 2) 删除 <tspan>...</tspan>,避免保留行内文本
cleaned = re.sub(
r"<tspan[^>]*?>.*?</tspan>",
"",
cleaned,
flags=re.IGNORECASE | re.DOTALL,
)
# 3) 删除 <text>...</text>,主要文字节点
cleaned = re.sub(
r"<text[^>]*?>.*?</text>",
"",
cleaned,
flags=re.IGNORECASE | re.DOTALL,
)
# 4) 删除自闭合的 text / tspan 节点(无内容的标签)
cleaned = re.sub(
r"<text[^>]*/>",
"",
cleaned,
flags=re.IGNORECASE,
)
cleaned = re.sub(
r"<tspan[^>]*/>",
"",
cleaned,
flags=re.IGNORECASE,
)
# 5) 简单压缩多余空行,避免产生大片空白
lines = cleaned.splitlines()
cleaned_lines = []
for line in lines:
if line.strip() == "":
if cleaned_lines and cleaned_lines[-1].strip() == "":
continue
cleaned_lines.append(line)
return "\n".join(cleaned_lines)
def _ensure_result_path(state: Paper2FigureState) -> str:
"""
统一本次 workflow 的根输出目录:
- 如果 state.result_path 已存在(通常由调用方传入,形如 时间戳+编码),直接使用;
- 否则:使用 get_project_root() / "outputs" / "paper2tec" / <timestamp>,
并回写到 state.result_path,确保后续节点共享同一目录,避免数据串台。
"""
raw = getattr(state, "result_path", None)
if raw:
return raw
root = get_project_root()
ts = int(time.time())
base_dir = (root / "outputs" / "paper2tec" / str(ts)).resolve()
base_dir.mkdir(parents=True, exist_ok=True)
state.result_path = str(base_dir)
return state.result_path
@register("paper2technical_bg_remove")
def create_paper2technical_graph() -> GenericGraphBuilder: # noqa: N802
"""
Workflow factory: dfa run --wf paper2technical
"""
# 使用 Paper2FigureState,复用其中的 paper_file / paper_idea / fig_desc 等字段,
# 这里不做图像生成和抠图,只负责“技术路线图”的 SVG + PPT 逻辑。
builder = GenericGraphBuilder(
state_model=Paper2FigureState,
entry_point="_start_", # 入口统一为 _start_,再由路由函数分发
)
# ----------------------------------------------------------------------
# TOOLS (pre_tool definitions)
# ----------------------------------------------------------------------
# 1) 提供给 paper_idea_extractor 的 PDF 内容(标题 + 前几页正文)
@builder.pre_tool("paper_content", "paper_idea_extractor")
def _get_paper_content(state: Paper2FigureState):
"""
前置工具: 读取论文 PDF 的标题和前若干页内容,供 paper_idea_extractor 节点使用。
- 作用: 为大模型提供足够的上下文,让其抽取论文中的技术路线/实验流程关键信息。
- 输出: 一个字符串,包含论文标题 + 前若干页文本。
"""
import fitz # PyMuPDF
import PyPDF2
pdf_path = state.paper_file
if not pdf_path:
log.warning("paper_file 为空,无法读取 PDF 内容")
return ""
try:
with open(pdf_path, "rb") as f:
reader = PyPDF2.PdfReader(f)
paper_title = reader.metadata.get("/Title", "Unknown Title")
except Exception:
paper_title = "Unknown Title"
try:
doc = fitz.open(pdf_path)
except Exception as e:
log.error(f"打开 PDF 失败: {e}")
return f"The title of the paper is {paper_title}"
text_parts: list[str] = []
# 读取前 10 页内容,通常技术路线、整体框架会在前几页出现
for page_idx in range(min(10, len(doc))):
page = doc.load_page(page_idx)
text_parts.append(page.get_text("text") or "")
content = "\n".join(text_parts).strip()
final_text = (
f"The title of the paper is {paper_title}\n\n"
f"Here are the first 10 pages of the paper:\n{content}"
)
log.info("paper_content 提取完成")
return final_text
# 2) 提供给技术路线图描述生成器的“论文核心想法/摘要”
@builder.pre_tool("paper_idea", "technical_route_desc_generator")
def _get_paper_idea(state: Paper2FigureState):
"""
前置工具: 为 technical_route_desc_generator 节点暴露论文的核心想法摘要。
- 在 PDF 模式下,该摘要由 paper_idea_extractor 节点写入 state.paper_idea。
- 在 TEXT 模式下,可以直接由调用方事先把概要写入 state.paper_idea。
"""
return state.paper_idea or ""
# ----------------------------------------------------------------------
# ==============================================================
# NODES
# ==============================================================
async def paper_idea_extractor_node(state: Paper2FigureState) -> Paper2FigureState:
"""
节点 1: 从 PDF 中抽取论文的核心思想 / 技术路线相关信息
- 只在 input_type == "PDF" 时作为入口节点被调用。
- 基于 pre_tool("paper_content") 提供的标题 + 前若干页内容,
调用专门的 agent(例如 paper_idea_extractor)生成摘要。
- 该摘要用于后续技术路线图描述生成。
输入:
state.paper_file : 论文 PDF 路径
输出:
state.paper_idea : 论文核心思想 / 技术路线要点摘要
state.agent_results["paper_idea_extractor"] : agent 原始输出
"""
agent = create_simple_agent("paper_idea_extractor")
state = await agent.execute(state=state)
return state
async def technical_route_desc_generator_node(state: Paper2FigureState) -> Paper2FigureState:
"""
节点 2: 技术路线图描述生成器
- 根据论文摘要(PDF 模式)或用户直接提供的文本描述(TEXT 模式),
生成“技术路线/实验流程”的结构化自然语言描述或 JSON。
- 典型内容包括: 各阶段实验步骤、模块之间的依赖关系、输入输出数据流等。
输入:
- PDF 模式: state.paper_idea 由 paper_idea_extractor 填充
- TEXT 模式: 可以事先把文本写入 state.paper_idea 或其他字段
输出:
- 建议: 在 agent 内把结果存到 state.fig_desc 或 state.agent_results["technical_route_desc_generator"]
"""
agent = create_react_agent(
name="technical_route_desc_generator",
max_retries=4,
model_name="claude-haiku-4-5-20251001",
)
state = await agent.execute(state=state)
# --------------------------------------------------------------
# 将 LLM 生成的 SVG 源码渲染为实际图像文件,并写入统一的 result_path 目录
# --------------------------------------------------------------
svg_code = getattr(state, "figure_tec_svg_content", None)
if svg_code:
# 确保本次 workflow 的根输出目录已确定
base_dir = Path(_ensure_result_path(state))
base_dir.mkdir(parents=True, exist_ok=True)
timestamp = int(time.time())
# 同时输出 SVG 源码文件和 PNG 位图
svg_output_path = str((base_dir / f"technical_route_{timestamp}.svg").resolve())
svg_bg_output_path = str((base_dir / f"technical_route_{timestamp}_bg.svg").resolve())
png_output_path_bg = str((base_dir / f"technical_route_{timestamp}_bg.png").resolve())
png_output_path_full = str((base_dir / f"technical_route_{timestamp}_full.png").resolve())
try:
# 1) 保存原始 SVG 源码(含文字)
Path(svg_output_path).write_text(svg_code, encoding="utf-8")
state.svg_file_path = svg_output_path
# 2) 调用 svg_bg_cleaner agent 生成“去文字版” SVG,失败时回退到本地函数
svg_bg_code = None
try:
cleaner_agent = create_react_agent(
name="svg_bg_cleaner",
max_retries=4,
model_name="claude-haiku-4-5-20251001",
)
# 将原始 SVG 挂到 state,方便 agent 读取
cleaner_state = await cleaner_agent.execute(state=state)
svg_bg_code = cleaner_state.svg_bg_code
except Exception as e:
log.warning(f"svg_bg_cleaner agent 执行失败,回退到本地去文字函数: {e}")
if not svg_bg_code:
svg_bg_code = _strip_svg_text_nodes(svg_code)
Path(svg_bg_output_path).write_text(svg_bg_code, encoding="utf-8")
state.svg_bg_file_path = svg_bg_output_path
# 3) 用“纯背景 SVG”渲染 PNG 供 MinerU 使用(背景通路)
png_bg_path = local_tool_for_svg_render(
{
"svg_code": svg_bg_code,
"output_path": png_output_path_bg,
"scale": 3.0,
}
)
state.svg_img_path = png_bg_path
# 4) 额外渲染一份“带文字版” PNG 供 MinerU 抽取文本
png_full_path = local_tool_for_svg_render(
{
"svg_path": svg_output_path,
"output_path": png_output_path_full,
"scale": 3.0,
}
)
state.svg_full_img_path = png_full_path
log.critical(f"[state.svg_img_path]: {state.svg_img_path}")
log.critical(f"[state.svg_full_img_path]: {state.svg_full_img_path}")
log.critical(f"[state.svg_file_path]: {state.svg_file_path}")
log.critical(f"[state.svg_bg_file_path]: {state.svg_bg_file_path}")
except Exception as e:
# 渲染或写文件失败时仅记录日志,避免打断整体 workflow
log.error(f"technical_route_desc_generator_node: SVG 落盘/渲染失败: {e}")
return state
async def svg_fragment_miner_node(state: Paper2FigureState) -> Paper2FigureState:
"""
节点 4: SVG 结构切分 / 小图块生成 (MinerU 接入)
新策略:
- 仅对“带文字版” PNG (full_image_path) 调用 MinerU,获取全局 layout (mineru_full);
- 使用 mineru_full 的 bbox 在“去文字版” PNG (bg_image_path) 上裁剪出纯背景小图;
- 这些背景小图再进行 PNG->SVG->EMF 转换,用于 PPT 背景层;
- 文本仍由 mineru_full 中的 text/title/image_caption block 提取,用于 PPT 文本框 overlay。
"""
bg_image_path = getattr(state, "svg_img_path", None)
full_image_path = getattr(state, "svg_full_img_path", None)
if not bg_image_path or not full_image_path:
log.error(
"svg_fragment_miner_node: svg_img_path 或 svg_full_img_path 为空,"
"无法调用 MinerU 进行双通路布局与裁剪"
)
return state
mineru_port = 8010 # MinerU 服务端口
if getattr(state, "agent_results", None) is None:
state.agent_results = {}
try:
# -----------------------------
# 1) layout 通路: 带文字 PNG -> MinerU -> mineru_full
# -----------------------------
log.info(
f"svg_fragment_miner_node[full]: 调用 MinerU recursive_mineru_layout, "
f"image_path={full_image_path}, port={mineru_port}"
)
mineru_full = await recursive_mineru_layout(
image_path=full_image_path,
port=mineru_port,
max_depth = 3,
current_depth= 0,
# block_types_for_subimage=
)
state.agent_results["mineru_svg_fragment_full"] = mineru_full
try:
log.warning(
"svg_fragment_miner_node[full]: MinerU 返回结果 (JSON): "
+ json.dumps(mineru_full, ensure_ascii=False, indent=2)
)
except Exception:
log.warning(
"svg_fragment_miner_node[full]: MinerU 返回结果 (repr): "
+ repr(mineru_full)
)
# -----------------------------
# 2) SAM 背景裁剪:对 full_image_path 做自动分割,用 bbox 在“去文字版” PNG 上裁剪纯背景小图
# -----------------------------
run_root = Path(_ensure_result_path(state))
crop_output_dir = run_root / "crops" / "bg_sam"
sam_blocks_for_ppt: list[dict] = []
try:
sam_items = run_sam_auto(
image_path=full_image_path,
checkpoint="sam_b.pt", # 与 sam_tool 中保持一致
device="cuda", # 无 GPU 可改为 "cpu"
)
sam_items = postprocess_sam_items(
sam_items,
min_area=200, # 去掉过小噪声块,可按需要调整
min_score=0.0, # 目前 SAM 一般无 score,可留作接口
iou_threshold=0.3, # IoU 过高视为重叠,做 NMS 去重
top_k=30, # 最多保留 30 个块,视业务需要调整
# nms_by="bbox", # 先用 bbox NMS,速度快
)
log.info(
"svg_fragment_miner_node[SAM]: "
f"Got {len(sam_items)} masks after post-process"
)
# 将 SAM 的 bbox 包装成 MinerU 风格的 blocks,便于复用现有裁剪函数
sam_blocks = []
for i, it in enumerate(sam_items):
bbox = it.get("bbox")
if not bbox or len(bbox) != 4:
continue
sam_blocks.append(
{
"type": "sam_bg",
"bbox": bbox,
"text": None,
"depth": 0,
}
)
crops_with_meta = crop_mineru_blocks_with_meta(
image_path=bg_image_path, # 去文字 PNG
blocks=sam_blocks, # 使用 SAM 产生的 bbox
output_dir=str(crop_output_dir / "meta"),
prefix="paper2technical_sam_bg_",
)
svg_output_dir = crop_output_dir / "svgs"
svg_output_dir.mkdir(parents=True, exist_ok=True)
for item in crops_with_meta:
png_path = item.get("png_path")
if not png_path:
continue
try:
png_p = Path(png_path)
svg_path = str((svg_output_dir / f"{png_p.stem}.svg").resolve())
out_svg = local_tool_for_raster_to_svg(
{
"image_path": str(png_p),
"output_svg": svg_path,
"colormode": "color",
}
)
sam_blocks_for_ppt.append(
{
"block_index": item.get("block_index"),
"type": "sam_bg",
"bbox": item.get("bbox"), # 归一化 bbox,用于 PPT 布局
"png_path": png_path,
"svg_path": out_svg,
}
)
except Exception as e:
log.error(f"svg_fragment_miner_node[SAM]: PNG->SVG 转换失败 {png_path}: {e}")
except Exception as e:
log.error(f"svg_fragment_miner_node[SAM]: SAM 分割或裁剪失败: {e}", exc_info=True)
# 使用 SAM 产生的块作为 PPT 背景块,后续 PPT 生成逻辑无需改动
state.agent_results["mineru_blocks_for_ppt"] = sam_blocks_for_ppt
# -----------------------------
# 3) 文本通路:从 mineru_full 提取文本块 (text/title/image_caption)
# -----------------------------
# text_types = {"title", "text", "image_caption"}
text_blocks = [
{
"type": b.get("type"),
"bbox": b.get("bbox"),
"text": b.get("text"),
"depth": b.get("depth"),
}
for b in mineru_full
# if b.get("bbox") is not None
# and b.get("text") not in (None, "")
# and b.get("type") in text_types
]
state.agent_results["mineru_text_blocks"] = text_blocks
# 若 Paper2FigureState 定义了 mineru_text_blocks 字段,可同步一份
try:
state.mineru_text_blocks = text_blocks # type: ignore[attr-defined]
except Exception:
pass
except Exception as e:
log.error(f"svg_fragment_miner_node: MinerU 调用失败: {e}", exc_info=True)
return state
async def technical_ppt_generator_node(state: Paper2FigureState) -> Paper2FigureState:
"""
节点 5: 基于技术路线 SVG / 片段生成 PPT
- 根据前面步骤生成的 SVG 代码或 svg_fragments,
生成一份或多份 PPT 幻灯片,用于展示技术路线图。
- 与 paper2figure 的 PPT 生成不同:
- 这里不依赖位图图片和抠图,不需要图像背景去除模型;
- 完全围绕“技术路线图”的结构信息进行排版。
现在的策略:
- 优先使用 state.agent_results["mineru_blocks_for_ppt"] 中的
(svg_path, bbox) 信息,在一页 PPT 中根据 bbox 还原整体布局;
- 若该字段不存在,则回退到旧逻辑:逐个 SVG/EMF 单独占一页。
"""
from pptx import Presentation
# 输出目录:统一使用本次 workflow 的根输出目录
run_root = Path(_ensure_result_path(state))
output_dir = run_root
output_dir.mkdir(parents=True, exist_ok=True)
timestamp = int(time.time())
ppt_path = output_dir / f"technical_route_{timestamp}.pptx"
prs = Presentation()
blank_slide_layout = prs.slide_layouts[6]
slide_width = prs.slide_width
slide_height = prs.slide_height
# 优先尝试按 bbox 还原布局
blocks_for_ppt: list[dict] = []
if getattr(state, "agent_results", None):
blocks_for_ppt = state.agent_results.get("mineru_blocks_for_ppt", []) or []
# 预先将完整 SVG / 去文字 SVG 转为 EMF,用于后面单页展示
full_svg_path = getattr(state, "svg_file_path", None)
bg_svg_path = getattr(state, "svg_bg_file_path", None)
full_emf = None
bg_emf = None
# 统一 EMF 输出目录
emf_output_dir = output_dir / "ppt_emf"
emf_output_dir.mkdir(parents=True, exist_ok=True)
if full_svg_path:
try:
full_emf = svg_to_emf(
full_svg_path,
str((emf_output_dir / "technical_route_full.emf").resolve()),
)
except Exception as e:
log.error(f"technical_ppt_generator_node: full SVG -> EMF 失败 {full_svg_path}: {e}")
if bg_svg_path:
try:
bg_emf = svg_to_emf(
bg_svg_path,
str((emf_output_dir / "technical_route_bg.emf").resolve()),
)
except Exception as e:
log.error(f"technical_ppt_generator_node: bg SVG -> EMF 失败 {bg_svg_path}: {e}")
if blocks_for_ppt:
# 在同一页 slide 上按 MinerU bbox 摆放所有 EMF 图块,并叠加文本框
slide = prs.slides.add_slide(blank_slide_layout)
# 1) 背景原子图块(EMF)
for blk in blocks_for_ppt:
svg_path = blk.get("svg_path")
bbox = blk.get("bbox") or [0, 0, 1, 1]
x1, y1, x2, y2 = bbox
if not svg_path:
continue
try:
svg_p = Path(svg_path)
emf_path = str((emf_output_dir / f"{svg_p.stem}.emf").resolve())
emf_abs = svg_to_emf(str(svg_p), emf_path)
except Exception as e:
log.error(f"technical_ppt_generator_node: SVG -> EMF 失败 {svg_path}: {e}")
continue
left = int(slide_width * x1)
top = int(slide_height * y1)
width = int(slide_width * (x2 - x1))
height = int(slide_height * (y2 - y1))
try:
slide.shapes.add_picture(
emf_abs,
left,
top,
width=width,
height=height,
)
except Exception as e:
log.error(
f"technical_ppt_generator_node: 将 EMF 按 bbox 插入 PPT 失败 {emf_abs}: {e}"
)
continue
# 2) 文本块 overlay:将 MinerU 抽取的文本按 bbox 作为文本框叠加到同一页
text_blocks: list[dict] = []
if getattr(state, "agent_results", None):
text_blocks = state.agent_results.get("mineru_text_blocks", []) or []
for tb in text_blocks:
bbox = tb.get("bbox") or [0, 0, 1, 1]
if len(bbox) != 4:
continue
x1, y1, x2, y2 = bbox
raw_text = tb.get("text") or ""
text = raw_text.strip()
if not text:
continue
left = int(slide_width * x1)
top = int(slide_height * y1)
width = int(slide_width * (x2 - x1))
height = int(slide_height * (y2 - y1))
try:
tx_box = slide.shapes.add_textbox(left, top, width, height)
tf = tx_box.text_frame
tf.text = text
for p in tf.paragraphs:
p.font.size = Pt(12)
if tb.get("type") == "title":
p.font.bold = True
p.font.size = Pt(14)
except Exception as e:
log.error(
f"technical_ppt_generator_node: 文本块插入失败 "
f"{text[:30]}...: {e}"
)
# 额外页 1:完整带文字版 SVG 的 EMF
if full_emf:
slide_full = prs.slides.add_slide(blank_slide_layout)
try:
slide_full.shapes.add_picture(
full_emf,
0,
0,
width=slide_width,
height=slide_height,
)
except Exception as e:
log.error(
f"technical_ppt_generator_node: 将 full EMF 插入 PPT 失败 {full_emf}: {e}"
)
# 额外页 2:完整去文字版 SVG 的 EMF
if bg_emf:
slide_bg = prs.slides.add_slide(blank_slide_layout)
try:
slide_bg.shapes.add_picture(
bg_emf,
0,
0,
width=slide_width,
height=slide_height,
)
except Exception as e:
log.error(
f"technical_ppt_generator_node: 将 bg EMF 插入 PPT 失败 {bg_emf}: {e}"
)
prs.save(str(ppt_path))
state.ppt_path = str(ppt_path)
log.info(
"technical_ppt_generator_node: PPT 已按 MinerU bbox 还原整体布局并叠加文本生成,"
"并追加 full/bg 整图页面: "
f"{ppt_path}"
)
return state
# 若没有 blocks_for_ppt,退回旧逻辑:每个 SVG 单独一页居中缩放
svg_paths: list[str] = []
if getattr(state, "agent_results", None):
svg_paths = state.agent_results.get("mineru_crops_svg", []) or []
if not svg_paths:
# 没有碎片 SVG,保留一页空白,用于兼容/调试
prs.slides.add_slide(blank_slide_layout)
log.warning(
"technical_ppt_generator_node: 未找到 mineru_crops_svg,生成占位空白 PPT"
)
else:
# 临时 EMF 输出目录
emf_output_dir = output_dir / "ppt_emf_fallback"
emf_output_dir.mkdir(parents=True, exist_ok=True)
for svg_path in svg_paths:
slide = prs.slides.add_slide(blank_slide_layout)
try:
svg_p = Path(svg_path)
emf_path = str((emf_output_dir / f"{svg_p.stem}.emf").resolve())
# SVG -> EMF(保持矢量特性)
emf_abs = svg_to_emf(str(svg_p), emf_path)
except Exception as e:
log.error(f"technical_ppt_generator_node: SVG -> EMF 失败 {svg_path}: {e}")
continue
# 将 EMF 插入 PPT,先插入再按 80% 宽度缩放并居中
try:
pic = slide.shapes.add_picture(emf_abs, 0, 0)
# 缩放到宽度 80%,保持纵横比
if pic.width and pic.width > 0:
scale = (slide_width * 0.8) / pic.width
else:
scale = 1.0
pic.width = int(pic.width * scale)
pic.height = int(pic.height * scale)
# 居中
pic.left = int((slide_width - pic.width) / 2)
pic.top = int((slide_height - pic.height) / 2)
except Exception as e:
log.error(
f"technical_ppt_generator_node: 将 EMF 插入 PPT 失败 {emf_abs}: {e}"
)
continue
prs.save(str(ppt_path))
state.ppt_path = str(ppt_path)
log.info(f"technical_ppt_generator_node: PPT 已生成: {ppt_path}")
return state
# ==============================================================
# 注册 nodes / edges
# ==============================================================
def set_entry_node(state: Paper2FigureState) -> str:
"""
路由函数: 根据输入类型选择技术路线工作流的入口节点。
- input_type == "PDF" : 从 PDF 中抽取论文想法,先走 paper_idea_extractor
- input_type == "TEXT" : 直接使用调用方提供的文本描述,跳过 PDF 抽取,
从 technical_route_desc_generator 开始
其他值:
- 认为是不合法输入,直接结束工作流。
"""
input_type = getattr(state.request, "input_type", "PDF")
if input_type == "PDF":
log.critical("paper2technical: 进入 PDF 流程 (paper_idea_extractor)")
return "paper_idea_extractor"
elif input_type == "TEXT":
log.critical("paper2technical: 进入 TEXT 流程 (technical_route_desc_generator)")
return "technical_route_desc_generator"
else:
log.error(f"paper2technical: Invalid input type: {input_type}")
return "_end_"
def _init_result_path(state: Paper2FigureState) -> Paper2FigureState:
"""
_start_ 节点:确保本次 workflow 有一个统一的 result_path 根目录。
- 若用户已在 state.result_path 传入自定义目录,则直接使用该目录;
- 若未传入,则初始化为 get_project_root()/outputs/paper2tec/<timestamp>。
"""
_ensure_result_path(state)
return state
nodes = {
"_start_": _init_result_path,
"paper_idea_extractor": paper_idea_extractor_node,
"technical_route_desc_generator": technical_route_desc_generator_node,
"svg_fragment_miner": svg_fragment_miner_node,
"technical_ppt_generator": technical_ppt_generator_node,
"_end_": lambda state: state, # 终止节点
}
# ------------------------------------------------------------------
# EDGES (从节点 A 指向节点 B)
# ------------------------------------------------------------------
edges = [
# PDF 流程: 先抽想法,再生成技术路线描述
("paper_idea_extractor", "technical_route_desc_generator"),
# PDF/TEXT 后续流程共用: 描述 -> 结构切分 -> PPT
("technical_route_desc_generator", "svg_fragment_miner"),
("svg_fragment_miner", "technical_ppt_generator"),
("technical_ppt_generator", "_end_"),
]
builder.add_nodes(nodes).add_edges(edges).add_conditional_edge("_start_", set_entry_node)
return builder
|