| import ast |
| from json import JSONDecodeError, JSONDecoder |
| import json |
| import re |
| from typing import Any, Dict, Union, List |
| from pathlib import Path |
| from dataflow_agent.logger import get_logger |
| log = get_logger(__name__) |
|
|
| import ast |
| from json import JSONDecodeError, JSONDecoder |
| import json |
| import re |
| from typing import Any, Dict, Union, List |
| from pathlib import Path |
| import asyncio |
| import logging |
| from typing import List, Dict, Any, Optional |
|
|
|
|
| from math import ceil |
| import time |
| import os |
| import math |
| from pptx.enum.text import PP_ALIGN |
| from pptx.dml.color import RGBColor |
| from pptx.enum.shapes import MSO_SHAPE |
| from pptx.util import Inches, Pt |
|
|
| import fitz |
|
|
| from PIL import Image |
| import pdfplumber |
| import uuid |
|
|
| from dataflow_agent.toolkits.imtool.mineru_tool import run_aio_batch_two_step_extract, run_aio_two_step_extract |
|
|
| def get_project_root() -> Path: |
| return Path(__file__).resolve().parent.parent |
|
|
| def robust_parse_json( |
| text: str, |
| *, |
| merge_dicts: bool = False, |
| strip_double_braces: bool = False |
| ) -> Union[Dict[str, Any], List[Any]]: |
| """ |
| 尽量从 LLM / 日志 / jsonl / Markdown 片段中提取合法 JSON。 |
| |
| 参数 |
| ---- |
| text : str |
| 输入原始文本 |
| merge_dicts : bool, default False |
| 提取到多个对象且全部是 dict 时,是否用 dict.update 合并返回 |
| strip_double_braces : bool, default False |
| 把 '{{' / '}}' 替换成 '{' / '}'(某些模板语言会加双层花括号) |
| |
| 返回 |
| ---- |
| Dict / List / List[Dict | List] |
| """ |
| s = text.strip() |
|
|
| |
| s = _remove_markdown_fence(s) |
| s = _remove_outer_triple_quotes(s) |
| s = _remove_leading_json_word(s) |
|
|
| if strip_double_braces: |
| s = s.replace("{{", "{").replace("}}", "}") |
|
|
| |
| s = _strip_json_comments(s) |
|
|
| |
| |
| |
| s = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', s) |
|
|
| |
| |
| |
| s = s.replace('\\\\', '\x00DOUBLE_BACKSLASH\x00') |
| s = s.replace('\\n', '\x00NEWLINE\x00') |
| s = s.replace('\\r', '\x00RETURN\x00') |
| s = s.replace('\\t', '\x00TAB\x00') |
| s = s.replace('\\"', '\x00QUOTE\x00') |
| s = s.replace('\\/', '\x00SLASH\x00') |
| s = s.replace('\\b', '\x00BACKSPACE\x00') |
| s = s.replace('\\f', '\x00FORMFEED\x00') |
| |
| |
| s = s.replace('\\', '\\\\') |
| |
| |
| s = s.replace('\x00DOUBLE_BACKSLASH\x00', '\\\\') |
| s = s.replace('\x00NEWLINE\x00', '\\n') |
| s = s.replace('\x00RETURN\x00', '\\r') |
| s = s.replace('\x00TAB\x00', '\\t') |
| s = s.replace('\x00QUOTE\x00', '\\"') |
| s = s.replace('\x00SLASH\x00', '\\/') |
| s = s.replace('\x00BACKSPACE\x00', '\\b') |
| s = s.replace('\x00FORMFEED\x00', '\\f') |
|
|
| log.debug(f'清洗完之后内容是: {s}') |
|
|
| |
| |
| try: |
| result = json.loads(s) |
| log.info(f"整体解析成功,类型: {type(result)}") |
| return result |
| except JSONDecodeError as e: |
| log.warning(f"整体解析失败: {e}") |
|
|
| |
| objs = _parse_json_lines(s) |
| if objs is not None: |
| return _maybe_merge(objs, merge_dicts) |
|
|
| |
| objs = _extract_json_objects(s) |
| log.warning(f"提取到 {len(objs)} 个对象") |
| if not objs: |
| raise ValueError("Unable to locate any valid JSON fragment.") |
|
|
| return _maybe_merge(objs, merge_dicts) |
|
|
|
|
| |
| |
| |
|
|
| _fence_pat = re.compile(r'```[\w-]*\s*([\s\S]*?)```', re.I) |
| |
| _outer_fence_pat = re.compile(r'^\s*```[\w-]*\s*([\s\S]*?)```\s*$', re.I) |
|
|
|
|
| def _remove_markdown_fence(src: str) -> str: |
| """只提取外层包裹的 ``` … ``` 内文本;若内容不是被代码块包裹则原样返回""" |
| |
| match = _outer_fence_pat.match(src) |
| if match: |
| return match.group(1).strip() |
| return src |
|
|
|
|
| def _remove_outer_triple_quotes(src: str) -> str: |
| if (src.startswith("'''") and src.endswith("'''")) or ( |
| src.startswith('"""') and src.endswith('"""') |
| ): |
| return src[3:-3].strip() |
| return src |
|
|
|
|
| def _remove_leading_json_word(src: str) -> str: |
| return src[4:].lstrip() if src.lower().startswith("json") else src |
|
|
|
|
| def _strip_json_comments(src: str) -> str: |
| |
| src = re.sub(r'/\*[\s\S]*?\*/', '', src) |
| |
| src = re.sub(r'(?<![:\"\'])//.*', '', src) |
| |
| src = re.sub(r',\s*([}\]])', r'\1', src) |
| return src.strip() |
|
|
|
|
| |
| def _parse_json_lines(src: str) -> Union[List[Any], None]: |
| lines = [ln.strip() for ln in src.splitlines() if ln.strip()] |
| if len(lines) <= 1: |
| return None |
|
|
| objs: List[Any] = [] |
| for ln in lines: |
| try: |
| objs.append(json.loads(ln)) |
| except JSONDecodeError: |
| return None |
| return objs |
|
|
|
|
| |
| def _extract_json_objects(src: str) -> List[Any]: |
| dec = JSONDecoder() |
| idx, n = 0, len(src) |
| objs: List[Any] = [] |
|
|
| while idx < n: |
| m = re.search(r'[{\[]', src[idx:]) |
| if not m: |
| break |
| idx += m.start() |
| try: |
| obj, end = dec.raw_decode(src, idx) |
| |
| tail = src[end:].lstrip() |
| |
| if tail and tail[0] not in ',]}>\n\r': |
| idx += 1 |
| continue |
| objs.append(obj) |
| idx = end |
| except JSONDecodeError: |
| idx += 1 |
| return objs |
|
|
|
|
| def _maybe_merge(objs: List[Any], merge_dicts: bool) -> Union[Any, List[Any]]: |
| if len(objs) == 1: |
| return objs[0] |
| if merge_dicts and all(isinstance(o, dict) for o in objs): |
| merged: Dict[str, Any] = {} |
| for o in objs: |
| merged.update(o) |
| return merged |
| return objs |
|
|
|
|
| |
|
|
| |
|
|
| |
|
|
| async def run_mineru(image_path: Path, output_dir: Path) -> bool: |
| """调用 mineru 并返回是否成功""" |
| cmd = [ |
| "mineru", |
| "-p", str(image_path), |
| "--backend", "vlm-transformers", |
| "--source", "local", |
| "-o", str(output_dir) |
| ] |
| proc = await asyncio.create_subprocess_exec( |
| *cmd, |
| stdout=asyncio.subprocess.PIPE, |
| stderr=asyncio.subprocess.PIPE, |
| ) |
| stdout, stderr = await proc.communicate() |
|
|
| if proc.returncode != 0: |
| log.warning(f"[mineru] 运行失败: {stderr.decode(errors='ignore')}") |
| return False |
| |
| log.info("[mineru] 执行成功") |
| return True |
|
|
| async def replace_item_with_sub_items( |
| items: List[Dict[str, Any]], |
| sub_items: List[Dict[str, Any]], |
| sub_img_path: str |
| ) -> List[Dict[str, Any]]: |
| """ |
| 处理每个 sub_item,执行坐标变换并替换原有的 item。 |
| """ |
| |
| with Image.open(sub_img_path) as sub_img: |
| sub_img_width, sub_img_height = sub_img.size |
|
|
| expanded_items = [] |
| items_to_remove = [] |
|
|
| log.info(f"[replace_item_with_sub_items] 开始替换图像: {sub_img_path}") |
|
|
| for i, item in enumerate(items): |
| log.info(f"item[type]: {item['type']}") |
| if item["type"] in ["image", "table"]: |
| log.info(f"item['img_path']: {item['img_path']}") |
| log.info(f"sub_img_path: {sub_img_path}") |
| if item["img_path"] == sub_img_path: |
| |
| target_bbox = item["bbox"] |
| xmin, ymin, xmax, ymax = target_bbox |
| |
| width_ratio = (xmax - xmin) / sub_img_width |
| height_ratio = (ymax - ymin) / sub_img_height |
|
|
| log.info(f"[replace_item_with_sub_items] 找到匹配的 item,替换 bbox: {target_bbox} -> 展开为 {len(sub_items)} 个 sub_items") |
|
|
| |
| for sub_item in sub_items: |
| |
| sub_item_bbox = sub_item["bbox"] |
| sub_xmin, sub_ymin, sub_xmax, sub_ymax = sub_item_bbox |
|
|
| |
| transformed_bbox = [ |
| int(sub_xmin * width_ratio + xmin), |
| int(sub_ymin * height_ratio + ymin), |
| int(sub_xmax * width_ratio + xmin), |
| int(sub_ymax * height_ratio + ymin) |
| ] |
|
|
| |
| sub_item["bbox"] = transformed_bbox |
|
|
| |
| expanded_items.append(sub_item) |
|
|
| |
| items_to_remove.append(i) |
|
|
| break |
|
|
| |
| log.info(f"[replace_item_with_sub_items] 将原 item 删除, 替换为 {len(expanded_items)} 个 sub_item") |
|
|
| for index in sorted(items_to_remove, reverse=True): |
| del items[index] |
|
|
| |
| items.extend(expanded_items) |
|
|
| log.info(f"[replace_item_with_sub_items] 替换完成后,当前 items 长度: {len(items)}") |
|
|
| return items |
|
|
| async def recursive_run_mineru( |
| img_path: Path, |
| out_dir: Path, |
| max_depth: int = 2, |
| current_depth: int = 0 |
| ) -> List[Dict[str, Any]]: |
| """递归运行 mineru,处理子图并更新fig_mask""" |
| |
| if current_depth > max_depth: |
| return [] |
| |
| log.info(f"[recursive_run_mineru] 当前深度 {current_depth},处理图像: {img_path}") |
| |
| |
| ok = await run_mineru(img_path, out_dir) |
| if not ok: |
| return [] |
|
|
| |
| content_json = locate_content_json(out_dir / img_path.stem) |
| if content_json is None: |
| return [] |
|
|
| items = load_and_fix_items(content_json, out_dir) |
|
|
| log.info(f"[recursive_run_mineru] 当前 items 长度: {len(items)}") |
|
|
| |
| vlm_images_dir = out_dir / img_path.stem / 'vlm' / 'images' |
| sub_images = list(vlm_images_dir.glob("*.jpg")) |
|
|
| if(current_depth != max_depth): |
| for sub_img_path in sub_images: |
| log.info(f"[recursive_run_mineru] 处理子图: {sub_img_path}") |
|
|
| |
| sub_items = await recursive_run_mineru(sub_img_path, out_dir, max_depth, current_depth + 1) |
|
|
| |
| items = await replace_item_with_sub_items(items, sub_items, str(sub_img_path)) |
|
|
| log.info(f"[recursive_run_mineru] 替换完成后,当前 items 长度: {len(items)}") |
|
|
| return items |
|
|
| def locate_content_json(output_dir: Path) -> Path | None: |
| """寻找 *_middle.json 文件""" |
| files = list(output_dir.rglob("*_middle.json")) |
| if not files: |
| log.warning(f"[mineru] 未找到 *_middle.json in {output_dir}") |
| return None |
| return files[0] |
|
|
|
|
| def load_and_fix_items(content_json: Path, output_dir: Path) -> List[Dict[str, Any]]: |
| """ |
| 读取 JSON 并提取所有文本和图片元素,修复图片路径为绝对路径 |
| """ |
| try: |
| data = json.loads(content_json.read_text(encoding="utf-8")) |
| except Exception as e: |
| log.warning(f"[mineru] JSON 读取失败: {e}") |
| return [] |
|
|
| |
| stem = content_json.stem |
| base_name = stem.replace("_middle", "") or stem |
| |
| results = [] |
| |
| |
| if "pdf_info" in data and isinstance(data["pdf_info"], list): |
| for pdf_info in data["pdf_info"]: |
| if "para_blocks" in pdf_info and isinstance(pdf_info["para_blocks"], list): |
| for block in pdf_info["para_blocks"]: |
| block_type = block.get("type", "") |
| |
| |
| if block_type in ["title", "text", "paragraph"]: |
| |
| text_content = extract_text_from_block(block) |
| if text_content: |
| results.append({ |
| "type": "text", |
| "text": text_content, |
| "bbox": block.get("bbox", []), |
| "text_level": 1 if block_type == "title" else None, |
| "page_idx": 0 |
| }) |
| |
| |
| elif block_type in ["list", "image", "table"]: |
| |
| image_elements = extract_image_elements(block, base_name, output_dir) |
| results.extend(image_elements) |
| |
| return results |
|
|
|
|
| def extract_text_from_block(block: Dict) -> str: |
| """从块中提取文本内容""" |
| text_parts = [] |
| |
| |
| if "lines" in block and isinstance(block["lines"], list): |
| for line in block["lines"]: |
| if "spans" in line and isinstance(line["spans"], list): |
| for span in line["spans"]: |
| if span.get("type") == "text" and "content" in span: |
| text_parts.append(span["content"]) |
| |
| |
| elif "content" in block: |
| text_parts.append(block["content"]) |
| |
| return " ".join(text_parts) if text_parts else "" |
|
|
|
|
| def extract_image_elements(block: Dict, base_name: str, output_dir: Path) -> List[Dict]: |
| """从图片块中提取图片和相关的文本元素""" |
| elements = [] |
| |
| if "blocks" in block and isinstance(block["blocks"], list): |
| for sub_block in block["blocks"]: |
| sub_type = sub_block.get("type", "") |
| |
| |
| if sub_type in ["title", "text", "paragraph","image_caption", "table_caption"]: |
| caption_text = extract_text_from_block(sub_block) |
| if caption_text: |
| elements.append({ |
| "type": "text", |
| "text": caption_text, |
| "bbox": sub_block.get("bbox", []), |
| "text_level": None, |
| "page_idx": 0 |
| }) |
| |
| |
| elif sub_type in ["image_body", "table_body"]: |
| image_path = extract_image_path(sub_block, base_name, output_dir) |
| if image_path: |
| elements.append({ |
| "type": "image", |
| "img_path": str(image_path), |
| "bbox": sub_block.get("bbox", []), |
| "image_caption": [], |
| "image_footnote": [], |
| "page_idx": 0 |
| }) |
| |
| return elements |
|
|
|
|
| def extract_image_path(block: Dict, base_name: str, output_dir: Path) -> Optional[Path]: |
| """从图片块中提取图片路径并转换为绝对路径""" |
| if "lines" in block and isinstance(block["lines"], list): |
| for line in block["lines"]: |
| if "spans" in line and isinstance(line["spans"], list): |
| for span in line["spans"]: |
| if span.get("type") in ["image", "table"] and "image_path" in span: |
| rel_path = span["image_path"] |
| if rel_path: |
| |
| abs_path = output_dir / base_name / "vlm/images" / rel_path |
| return abs_path |
| return None |
|
|
| def build_output_directory(image_path: Path) -> Path: |
| """构造 <image_no_ext>_mineru 输出目录""" |
| base = image_path.with_suffix("") |
| out_dir = Path(f"{base}_mineru") |
| out_dir.mkdir(parents=True, exist_ok=True) |
| return out_dir |
|
|
|
|
| |
|
|
| import asyncio |
| from pathlib import Path |
| from PIL import Image |
| from mineru_vl_utils import MinerUClient |
|
|
|
|
| |
| |
| |
| def rel_bbox_to_pixel(bbox, width, height): |
| x1, y1, x2, y2 = bbox |
| return [ |
| int(x1 * width), |
| int(y1 * height), |
| int(x2 * width), |
| int(y2 * height), |
| ] |
|
|
|
|
| |
| |
| |
| def crop_and_save(img: Image.Image, bbox_pixel, save_path: Path, margin=3): |
| W, H = img.size |
| x1, y1, x2, y2 = bbox_pixel |
|
|
| log.info(f"[crop_and_save] Image size={img.size}, bbox={bbox_pixel}") |
|
|
| |
| if x1 < 0 or y1 < 0 or x2 > W or y2 > H or x2 <= x1 or y2 <= y1: |
| log.info("[crop_and_save] BBOX out of range → return original path") |
| return None |
|
|
| |
| touch_left = x1 <= margin |
| touch_top = y1 <= margin |
| touch_right = x2 >= W - margin |
| touch_bottom = y2 >= H - margin |
|
|
| if touch_left and touch_top and touch_right and touch_bottom: |
| log.info("[crop_and_save] BBOX covers almost entire image → skip crop, return original") |
| return None |
|
|
| |
| try: |
| crop_img = img.crop(bbox_pixel) |
| crop_img.save(save_path) |
| log.info(f"[crop_and_save] Cropped size={crop_img.size}, saved={save_path}") |
| return str(save_path) |
|
|
| except Exception as e: |
| log.info(f"[crop_and_save] ERROR during crop: {e}") |
| return None |
|
|
| def transform_sub_bbox(sub_bbox, parent_bbox): |
| """把子图内 bbox 映射回原图坐标系""" |
| px1, py1, px2, py2 = parent_bbox |
| sx1, sy1, sx2, sy2 = sub_bbox |
|
|
| return [ |
| px1 + sx1, |
| py1 + sy1, |
| px1 + sx2, |
| py1 + sy2 |
| ] |
|
|
| |
| |
| |
| async def recursive_run_mineru_http( |
| image_path: Path, |
| out_dir: Path, |
| port:int = 8010, |
| max_depth: int = 2, |
| current_depth: int = 0, |
| ): |
| """ |
| 使用 aio_two_step_extract 执行异步 mineru 提取。 |
| 不依赖中间 JSON 文件。 |
| 自动截图 image/table/list 等元素作为下一轮输入。 |
| """ |
|
|
| |
| if current_depth > max_depth: |
| return [] |
|
|
| out_dir.mkdir(parents=True, exist_ok=True) |
|
|
| log.info(f"[recursive_http] ─ Depth={current_depth}, Image={image_path}") |
|
|
| |
| img = Image.open(image_path) |
| W, H = img.size |
| log.info(f"[recursive_http] Image size: W={W}, H={H}") |
|
|
| |
| try: |
| blocks = await run_aio_two_step_extract(image_path, port) |
| log.info(f"[recursive_http] MinerU returned {len(blocks)} blocks") |
| except Exception as e: |
| log.info(f"[recursive_http] MinerU error: {e}") |
| return [] |
|
|
| results = [] |
| sub_images_paths = [] |
|
|
| |
| |
| |
| for idx, blk in enumerate(blocks): |
|
|
| btype = blk.get("type") |
| bbox_rel = blk.get("bbox", [0, 0, 1, 1]) |
| content = blk.get("content") |
|
|
| log.info(f" Block[{idx}] type={btype}, bbox_rel={bbox_rel}, " |
| f"content={str(content)[:30] if content else None}") |
|
|
| bbox_pixel = rel_bbox_to_pixel(bbox_rel, W, H) |
| log.info(f" → bbox_pixel={bbox_pixel}") |
|
|
| |
| if btype in ["title", "text", "paragraph", "caption", "image_caption", "table_caption"]: |
| results.append({ |
| "type": "text", |
| "text": content or "", |
| "bbox": bbox_pixel, |
| }) |
| log.info(f" Added TEXT block, content preview: {str(content)[:30]}") |
|
|
| |
| elif btype in ["image", "table", "list"]: |
|
|
| sub_img_name = f"sub_{current_depth}_{uuid.uuid4()}.png" |
| sub_img_path = out_dir / sub_img_name |
| |
| log.info(f"Try to crop img: {image_path}") |
| cropped_path = crop_and_save(img, bbox_pixel, sub_img_path) |
| sub_img_path = cropped_path if cropped_path else image_path |
| log.info(f" Cropped IMAGE block → {sub_img_path}") |
|
|
| results.append({ |
| "type": "image", |
| "img_path": str(sub_img_path), |
| "bbox": bbox_pixel, |
| }) |
|
|
| sub_images_paths.append(sub_img_path) |
|
|
| log.info(f"[recursive_http] Depth={current_depth} → Parsed {len(results)} items, {len(sub_images_paths)} sub-images") |
|
|
| |
| |
| |
| if current_depth < max_depth and len(sub_images_paths) > 0: |
|
|
| tasks = [ |
| recursive_run_mineru_http( |
| sub_img_path, |
| out_dir, |
| port=port, |
| max_depth=max_depth, |
| current_depth=current_depth + 1 |
| ) |
| for sub_img_path in sub_images_paths |
| ] |
|
|
| sub_results_list = await asyncio.gather(*tasks) |
|
|
| new_results = [] |
|
|
| sub_map = { |
| str(path): sub_results_list[i] |
| for i, path in enumerate(sub_images_paths) |
| } |
|
|
| for item in results: |
| img_path = item.get("img_path") |
|
|
| |
| if item["type"] in ["image", 'table', 'list'] and img_path in sub_map: |
| parent_bbox = item["bbox"] |
| sub_items = sub_map[img_path] |
|
|
| log.info(f" Replacing sub-image {img_path} with {len(sub_items)} items") |
|
|
| for si in sub_items: |
| new_item = si.copy() |
| new_item["bbox"] = transform_sub_bbox(si["bbox"], parent_bbox) |
| new_results.append(new_item) |
| else: |
| new_results.append(item) |
|
|
| results = new_results |
|
|
| return results |
|
|
| |
|
|
| def get_font_size_for_text(bbox, text, max_font_size=48, min_font_size=10): |
| """ |
| 根据文本框的 bbox 和文本长度推算字体大小 |
| bbox: [xmin, ymin, xmax, ymax] |
| text: 要插入的文本 |
| """ |
| box_height = bbox[3] - bbox[1] |
| max_chars_per_line = 30 |
| lines = ceil(len(text) / max_chars_per_line) |
|
|
| font_size = min(box_height // lines, max_font_size) |
| return max(font_size, min_font_size) |
|
|
| def generate_ppt_filename(output_path): |
| """ |
| 生成一个唯一的 PPT 文件名,基于当前时间戳 |
| """ |
| timestamp = time.strftime("%Y%m%d_%H%M%S") |
| return f"{output_path}/presentation_{timestamp}.pptx" |
|
|
| def pixels_to_inches(pixels: int, dpi: int = 96) -> float: |
| """将像素转换为英寸""" |
| return pixels / dpi |
|
|
|
|
| def calculate_font_size(text: str, bbox: List[int], text_level: int = None) -> int: |
| """ |
| 根据文本框大小、文字内容和文本级别计算合适的字体大小 |
| """ |
| |
| width = bbox[2] - bbox[0] |
| height = bbox[3] - bbox[1] |
| |
| |
| if text_level == 1: |
| base_size = min(height * 0.8, 44) |
| elif text_level == 2: |
| base_size = min(height * 0.7, 32) |
| else: |
| base_size = min(height * 0.6, 24) |
| |
| |
| char_count = len(text) |
| if char_count > 0: |
| chars_per_line = max(1, width / (base_size * 0.6)) |
| lines_needed = math.ceil(char_count / chars_per_line) |
| |
| max_lines = max(1, height / (base_size * 1.1)) |
| if lines_needed > max_lines: |
| base_size = base_size * (max_lines / lines_needed) |
| |
| |
| font_size = max(8, min(base_size, 72)) |
| |
| return int(font_size) |
|
|
|
|
| def setup_presentation_size(prs, slide_width_px: int = 1024, slide_height_px: int = 1024): |
| """设置PPT尺寸""" |
| prs.slide_width = Inches(pixels_to_inches(slide_width_px)) |
| prs.slide_height = Inches(pixels_to_inches(slide_height_px)) |
| |
| return slide_width_px, slide_height_px |
|
|
|
|
| def add_text_element(slide, element: Dict): |
| """添加文本元素到幻灯片""" |
| bbox = element.get('bbox', [0, 0, 100, 50]) |
| text = element.get('text', '') |
| text_level = element.get('text_level') |
| |
| |
| left = pixels_to_inches(bbox[0]) |
| top = pixels_to_inches(bbox[1]) |
| width = pixels_to_inches(bbox[2] - bbox[0]) |
| height = pixels_to_inches(bbox[3] - bbox[1]) |
| |
| |
| font_size = calculate_font_size(text, bbox, text_level) |
| |
| log.info(f"添加文本框:") |
| log.info(f" 位置: [{bbox[0]}, {bbox[1]}, {bbox[2]}, {bbox[3]}] 像素") |
| log.info(f" 英寸坐标: left={left:.2f}, top={top:.2f}, width={width:.2f}, height={height:.2f}") |
| log.info(f" 文本内容: {text[:30]}{'...' if len(text) > 30 else ''}") |
| log.info(f" 文本级别: {text_level}, 字体大小: {font_size}pt") |
| |
| |
| textbox = slide.shapes.add_textbox( |
| Inches(left), Inches(top), Inches(width) * 1.2, Inches(height) |
| ) |
| text_frame = textbox.text_frame |
| text_frame.word_wrap = True |
| |
| |
| paragraph = text_frame.paragraphs[0] |
| paragraph.text = text |
| |
| |
| paragraph.font.size = Pt(font_size) |
| paragraph.font.name = "Comic Sans MS" |
| |
| |
| if text_level == 1: |
| paragraph.font.bold = True |
| paragraph.alignment = PP_ALIGN.CENTER |
| log.info(" 样式: 标题(加粗、居中)") |
| elif text_level == 2: |
| paragraph.font.bold = True |
| log.info(" 样式: 子标题(加粗)") |
| else: |
| log.info(" 样式: 正文") |
| |
| return textbox |
|
|
|
|
| def add_image_element(slide, element: Dict): |
| """添加图片元素到幻灯片""" |
| bbox = element.get('bbox', [0, 0, 100, 100]) |
| img_path = element.get('img_path', '') |
| |
| |
| left = pixels_to_inches(bbox[0]) |
| top = pixels_to_inches(bbox[1]) |
| width = pixels_to_inches(bbox[2] - bbox[0]) |
| height = pixels_to_inches(bbox[3] - bbox[1]) |
| |
| log.info(f"添加图片:") |
| log.info(f" 位置: [{bbox[0]}, {bbox[1]}, {bbox[2]}, {bbox[3]}] 像素") |
| log.info(f" 英寸坐标: left={left:.2f}, top={top:.2f}, width={width:.2f}, height={height:.2f}") |
| log.info(f" 图片路径: {img_path}") |
| log.info(f" 图片尺寸: {bbox[2]-bbox[0]}x{bbox[3]-bbox[1]} 像素") |
| |
| |
| if os.path.exists(img_path): |
| try: |
| log.info(" 图片文件存在,正在添加...") |
| result = slide.shapes.add_picture( |
| img_path, |
| Inches(left), Inches(top), Inches(width), Inches(height) |
| ) |
| log.info(" 图片添加成功") |
| return result |
| except Exception as e: |
| log.error(f" 添加图片时出错: {e}") |
| return add_image_placeholder(slide, bbox, f"Error: {str(e)}") |
| else: |
| log.warning(" 图片文件不存在,使用占位符") |
| return add_image_placeholder(slide, bbox, "Image not found") |
|
|
|
|
| def add_image_placeholder(slide, bbox: List[int], message: str): |
| """添加图片占位符""" |
| left = pixels_to_inches(bbox[0]) |
| top = pixels_to_inches(bbox[1]) |
| width = pixels_to_inches(bbox[2] - bbox[0]) |
| height = pixels_to_inches(bbox[3] - bbox[1]) |
| |
| |
| shape = slide.shapes.add_shape( |
| MSO_SHAPE.RECTANGLE, |
| Inches(left), Inches(top), Inches(width), Inches(height) |
| ) |
| shape.fill.solid() |
| shape.fill.fore_color.rgb = RGBColor(240, 240, 240) |
| shape.line.color.rgb = RGBColor(200, 200, 200) |
| |
| |
| textbox = slide.shapes.add_textbox( |
| Inches(left), Inches(top), Inches(width), Inches(height) |
| ) |
| text_frame = textbox.text_frame |
| text_frame.text = message |
| text_frame.paragraphs[0].alignment = PP_ALIGN.CENTER |
| text_frame.paragraphs[0].font.size = Pt(10) |
| text_frame.paragraphs[0].font.name = "Comic Sans MS" |
| text_frame.paragraphs[0].font.color.rgb = RGBColor(128, 128, 128) |
| |
| return shape |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| |
|
|
| |
|
|
| |
|
|
| def pdf_to_pil_images( |
| pdf_path: Union[str, Path], |
| dpi: int = 300 |
| ) -> List[Image.Image]: |
| """ |
| 将 PDF 文件的每一页转换为 PIL Image 对象。 |
| 修复了 CMYK/灰度/透明背景导致的白图或花屏问题。 |
| """ |
| pdf_path = Path(pdf_path) |
| if not pdf_path.exists(): |
| raise FileNotFoundError(f"PDF 文件不存在: {pdf_path}") |
|
|
| |
| zoom = dpi / 72.0 |
| matrix = fitz.Matrix(zoom, zoom) |
|
|
| images: List[Image.Image] = [] |
| |
| |
| |
| |
| doc = fitz.open(pdf_path) |
| try: |
| for page_num in range(len(doc)): |
| page = doc[page_num] |
| |
| |
| |
| pix = page.get_pixmap(matrix=matrix, alpha=False) |
|
|
| |
| |
| if pix.n != 3: |
| |
| temp_pix = fitz.Pixmap(fitz.csRGB, pix) |
| pix = temp_pix |
| |
|
|
| |
| |
| img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples) |
| images.append(img) |
|
|
| log.info(f"[pdf_to_pil_images] 已转换第 {page_num + 1} 页 (模式:{pix.n}通道),尺寸: {pix.width}x{pix.height}") |
|
|
| except Exception as e: |
| log.error(f"[pdf_to_pil_images] 转换过程出错: {e}") |
| raise e |
| finally: |
| doc.close() |
|
|
| log.info(f"[pdf_to_pil_images] 完成,共生成 {len(images)} 张图片") |
| return images |
|
|
|
|
| def _parse_html_table(html_content: str) -> tuple[List[str], List[List[str]]]: |
| """ |
| 解析 HTML 表格内容,提取表头和数据行。 |
| |
| 参数 |
| ---- |
| html_content : str |
| HTML 表格字符串,如 <table>...</table> |
| |
| 返回 |
| ---- |
| tuple[List[str], List[List[str]]] |
| (headers, rows) - 表头列名和数据行 |
| """ |
| try: |
| from bs4 import BeautifulSoup |
| except ImportError: |
| log.warning("[_parse_html_table] BeautifulSoup 未安装,使用简单解析") |
| return _parse_html_table_simple(html_content) |
| |
| try: |
| soup = BeautifulSoup(html_content, 'html.parser') |
| table = soup.find('table') |
| |
| if not table: |
| log.warning("[_parse_html_table] 未找到 table 标签") |
| return [], [] |
| |
| |
| rows_data = [] |
| for tr in table.find_all('tr'): |
| row = [] |
| for cell in tr.find_all(['td', 'th']): |
| |
| colspan = int(cell.get('colspan', 1)) |
| cell_text = cell.get_text(strip=True) |
| row.append(cell_text) |
| |
| for _ in range(colspan - 1): |
| row.append('') |
| if row: |
| rows_data.append(row) |
| |
| if not rows_data: |
| return [], [] |
| |
| |
| headers = rows_data[0] |
| data_rows = rows_data[1:] if len(rows_data) > 1 else [] |
| |
| return headers, data_rows |
| |
| except Exception as e: |
| log.error(f"[_parse_html_table] 解析失败: {e}") |
| return _parse_html_table_simple(html_content) |
|
|
|
|
| def _parse_html_table_simple(html_content: str) -> tuple[List[str], List[List[str]]]: |
| """ |
| 简单的 HTML 表格解析(不依赖 BeautifulSoup)。 |
| 仅用于备用,可能不够健壮。 |
| """ |
| try: |
| |
| import re |
| tr_pattern = re.compile(r'<tr>(.*?)</tr>', re.DOTALL | re.IGNORECASE) |
| td_pattern = re.compile(r'<t[dh][^>]*>(.*?)</t[dh]>', re.DOTALL | re.IGNORECASE) |
| |
| rows_data = [] |
| for tr_match in tr_pattern.finditer(html_content): |
| tr_content = tr_match.group(1) |
| row = [] |
| for td_match in td_pattern.finditer(tr_content): |
| cell_text = td_match.group(1).strip() |
| |
| cell_text = re.sub(r'<[^>]+>', '', cell_text) |
| row.append(cell_text) |
| if row: |
| rows_data.append(row) |
| |
| if not rows_data: |
| return [], [] |
| |
| headers = rows_data[0] |
| data_rows = rows_data[1:] if len(rows_data) > 1 else [] |
| |
| return headers, data_rows |
| |
| except Exception as e: |
| log.error(f"[_parse_html_table_simple] 简单解析失败: {e}") |
| return [], [] |
|
|
|
|
| def extract_tables_from_mineru_results( |
| mineru_items: List[Dict[str, Any]], |
| min_rows: int = 2, |
| min_cols: int = 2, |
| ) -> List[Dict[str, Any]]: |
| """ |
| 从 MinerU 识别结果中提取表格数据。 |
| |
| 参数 |
| ---- |
| mineru_items : List[Dict[str, Any]] |
| MinerU 返回的 items 列表,每个 item 包含 type, bbox, content 等字段 |
| min_rows : int, default 2 |
| 最小行数,少于此值的表格会被过滤 |
| min_cols : int, default 2 |
| 最小列数,少于此值的表格会被过滤 |
| |
| 返回 |
| ---- |
| List[Dict[str, Any]] |
| 提取的表格列表,每个表格格式: |
| { |
| "table_id": str, # 表格唯一标识 |
| "headers": List[str], # 表头列名 |
| "rows": List[List[str]], # 数据行 |
| "caption": str, # 表格标题/说明 |
| "bbox": List[int], # 原始坐标 |
| "content": str, # 原始 HTML 内容(如果是 HTML 表格) |
| } |
| """ |
| tables = [] |
| table_idx = 0 |
|
|
| |
| pending_caption = "" |
|
|
| for item in mineru_items: |
| item_type = item.get("type", "") |
| content = item.get("content", "") |
| bbox = item.get("bbox", []) |
|
|
| |
| if item_type == "table_caption" and content: |
| pending_caption = content |
| continue |
|
|
| |
| if item_type == "table" and content: |
| |
| headers, rows = _parse_html_table(content) |
| |
| |
| if len(headers) < min_cols or len(rows) < min_rows: |
| log.debug(f"[extract_tables] 跳过小表格: {len(headers)} 列, {len(rows)} 行") |
| continue |
|
|
| table_id = f"table_{table_idx}" |
| table_idx += 1 |
|
|
| tables.append({ |
| "table_id": table_id, |
| "headers": headers, |
| "rows": rows, |
| "caption": pending_caption, |
| "bbox": bbox, |
| "content": content, |
| }) |
|
|
| pending_caption = "" |
| log.info(f"[extract_tables] 提取表格 {table_id}: {len(headers)} 列, {len(rows)} 行, caption: {pending_caption[:50] if pending_caption else 'N/A'}") |
|
|
| log.info(f"[extract_tables] 共提取 {len(tables)} 个表格") |
| return tables |
|
|
|
|
| def extract_text_from_mineru_results( |
| mineru_items: List[Dict[str, Any]], |
| max_chars: int = 10000, |
| ) -> str: |
| """ |
| 从 MinerU 识别结果中提取纯文本内容(用于 paper_idea_extractor)。 |
| |
| 参数 |
| ---- |
| mineru_items : List[Dict[str, Any]] |
| MinerU 返回的 items 列表 |
| max_chars : int, default 10000 |
| 最大提取字符数,避免内容过长 |
| |
| 返回 |
| ---- |
| str |
| 提取的文本内容 |
| """ |
| text_parts = [] |
| total_chars = 0 |
|
|
| for item in mineru_items: |
| if total_chars >= max_chars: |
| break |
|
|
| item_type = item.get("type", "") |
| content = item.get("content", "") |
|
|
| |
| if item_type in ["text", "title", "table_caption"] and content: |
| text_parts.append(content) |
| total_chars += len(content) |
|
|
| result = "\n\n".join(text_parts) |
| |
| if total_chars > max_chars: |
| result = result[:max_chars] + "..." |
|
|
| log.info(f"[extract_text] 提取了 {len(text_parts)} 段文本,共 {len(result)} 字符") |
| return result |
|
|
|
|
| def execute_matplotlib_code( |
| code: str, |
| output_path: Union[str, Path], |
| timeout: int = 30, |
| allowed_modules: Optional[List[str]] = None, |
| ) -> Dict[str, Any]: |
| """ |
| 安全执行 matplotlib 代码并保存图表。 |
| |
| 参数 |
| ---- |
| code : str |
| 要执行的 matplotlib Python 代码 |
| output_path : str | Path |
| 图表输出路径(包含文件名,如 /tmp/chart.png) |
| timeout : int, default 30 |
| 执行超时时间(秒) |
| allowed_modules : List[str], optional |
| 允许导入的模块列表,默认为 ["matplotlib", "numpy", "pandas"] |
| |
| 返回 |
| ---- |
| Dict[str, Any] |
| { |
| "success": bool, |
| "output_path": str, # 成功时返回图片路径 |
| "error": str, # 失败时返回错误信息 |
| } |
| """ |
| import subprocess |
| import tempfile |
|
|
| if allowed_modules is None: |
| allowed_modules = ["matplotlib", "numpy", "pandas", "math"] |
|
|
| output_path = Path(output_path) |
| output_path.parent.mkdir(parents=True, exist_ok=True) |
|
|
| |
| dangerous_patterns = [ |
| r'\bos\.system\b', |
| r'\bsubprocess\b', |
| r'\beval\b', |
| r'\bexec\b', |
| r'\bopen\s*\(', |
| r'\b__import__\b', |
| r'\bimport\s+os\b', |
| r'\bimport\s+sys\b', |
| r'\bimport\s+subprocess\b', |
| r'\bfrom\s+os\b', |
| r'\bfrom\s+sys\b', |
| ] |
|
|
| for pattern in dangerous_patterns: |
| if re.search(pattern, code): |
| return { |
| "success": False, |
| "output_path": "", |
| "error": f"检测到危险操作: {pattern}", |
| } |
|
|
| |
| |
| |
| |
| |
| |
|
|
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| full_code = f''' |
| {code} |
| ''' |
|
|
| |
| try: |
| with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False, encoding='utf-8') as f: |
| f.write(full_code) |
| temp_script = f.name |
|
|
| result = subprocess.run( |
| ['python', temp_script], |
| capture_output=True, |
| text=True, |
| timeout=timeout, |
| cwd=str(output_path.parent), |
| ) |
|
|
| |
| try: |
| os.unlink(temp_script) |
| except Exception: |
| pass |
|
|
| |
| if result.returncode == 0: |
| if output_path.exists(): |
| log.info(f"[execute_matplotlib] 图表生成成功: {output_path}") |
| return { |
| "success": True, |
| "output_path": str(output_path), |
| "error": "", |
| } |
| else: |
| log.warning(f"[execute_matplotlib] 代码执行成功但图片未生成") |
| return { |
| "success": False, |
| "output_path": "", |
| "error": "代码执行成功但图片未生成", |
| } |
| else: |
| error_msg = result.stderr or result.stdout or "未知错误" |
| log.warning(f"[execute_matplotlib] 执行失败 (返回码={result.returncode}): {error_msg}") |
| return { |
| "success": False, |
| "output_path": "", |
| "error": error_msg[:500], |
| } |
|
|
| except subprocess.TimeoutExpired: |
| log.warning(f"[execute_matplotlib] 执行超时 ({timeout}s)") |
| return { |
| "success": False, |
| "output_path": "", |
| "error": f"代码执行超时 ({timeout} 秒)", |
| } |
| except Exception as e: |
| log.error(f"[execute_matplotlib] 执行异常: {e}") |
| return { |
| "success": False, |
| "output_path": "", |
| "final_code": full_code, |
| "error": str(e), |
| } |
|
|