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