Эх сурвалжийг харах

feat: implement financial number normalization across output formats, enhancing data consistency and accuracy in JSON, Markdown, and HTML exports

zhch158_admin 6 өдөр өмнө
parent
commit
b6a4b45c91

+ 14 - 2
zhch/universal_doc_parser/utils/html_generator.py

@@ -18,15 +18,21 @@ class HTMLGenerator:
     def save_table_htmls(
         results: Dict[str, Any],
         output_dir: Path,
-        doc_name: str
+        doc_name: str,
+        is_pdf: bool = True
     ) -> Path:
         """
         保存表格 HTML 文件
         
+        命名规则:
+        - PDF输入: 文件名_table_1_page_001.html
+        - 图片输入(单页): 文件名_table_1.html
+        
         Args:
             results: 处理结果
             output_dir: 输出目录
             doc_name: 文档名称
+            is_pdf: 是否为 PDF 输入
             
         Returns:
             表格目录路径
@@ -35,6 +41,7 @@ class HTMLGenerator:
         tables_dir.mkdir(exist_ok=True)
         
         table_count = 0
+        total_pages = len(results.get('pages', []))
         
         for page in results.get('pages', []):
             page_idx = page.get('page_idx', 0)
@@ -51,7 +58,12 @@ class HTMLGenerator:
                             html, cells, doc_name, page_idx, table_count
                         )
                         
-                        html_path = tables_dir / f"{doc_name}_table_{table_count}_page_{page_idx + 1}.html"
+                        # 根据输入类型决定命名
+                        if is_pdf or total_pages > 1:
+                            html_path = tables_dir / f"{doc_name}_table_{table_count}_page_{page_idx + 1:03d}.html"
+                        else:
+                            html_path = tables_dir / f"{doc_name}_table_{table_count}.html"
+                        
                         with open(html_path, 'w', encoding='utf-8') as f:
                             f.write(full_html)
         

+ 35 - 3
zhch/universal_doc_parser/utils/json_formatters.py

@@ -5,12 +5,16 @@ JSON 格式化工具模块
 - MinerU middle.json 格式转换
 - mineru_vllm_results_cell_bbox 格式转换
 - 表格单元格格式化
+- 金额数字标准化(全角→半角)
 """
 import json
+import sys
 from pathlib import Path
 from typing import Dict, Any, List, Optional
 from loguru import logger
 
+# 导入数字标准化工具
+from .normalize_financial_numbers import normalize_json_table
 
 class JSONFormatters:
     """JSON 格式化工具类"""
@@ -182,24 +186,38 @@ class JSONFormatters:
     def save_page_jsons(
         results: Dict[str, Any],
         output_dir: Path,
-        doc_name: str
+        doc_name: str,
+        is_pdf: bool = True,
+        normalize_numbers: bool = True
     ) -> List[str]:
         """
         保存每页独立的 JSON(mineru_vllm_results_cell_bbox 格式)
         
+        命名规则:
+        - PDF输入: 文件名_page_001.json
+        - 图片输入(单页): 文件名.json
+        
         Args:
             results: 处理结果
             output_dir: 输出目录
             doc_name: 文档名称
+            is_pdf: 是否为 PDF 输入
+            normalize_numbers: 是否标准化金额数字(全角→半角)
             
         Returns:
             保存的文件路径列表
         """
         saved_paths = []
+        total_pages = len(results.get('pages', []))
         
         for page in results.get('pages', []):
             page_idx = page.get('page_idx', 0)
-            page_name = f"{doc_name}_page_{page_idx + 1:03d}"
+            
+            # 根据输入类型决定命名
+            if is_pdf or total_pages > 1:
+                page_name = f"{doc_name}_page_{page_idx + 1:03d}"
+            else:
+                page_name = doc_name
             
             # 转换为 mineru_vllm_results_cell_bbox 格式
             page_elements = []
@@ -214,10 +232,24 @@ class JSONFormatters:
                 if converted:
                     page_elements.append(converted)
             
+            # 转换为 JSON 字符串
+            json_content = json.dumps(page_elements, ensure_ascii=False, indent=2)
+            
+            # 金额数字标准化
+            if normalize_numbers:
+                original_content = json_content
+                json_content = normalize_json_table(json_content)
+                
+                if json_content != original_content:
+                    original_path = output_dir / f"{page_name}_original.json"
+                    with open(original_path, 'w', encoding='utf-8') as f:
+                        f.write(original_content)
+                    logger.debug(f"📄 Original page JSON saved: {original_path}")
+            
             # 保存 JSON
             json_path = output_dir / f"{page_name}.json"
             with open(json_path, 'w', encoding='utf-8') as f:
-                json.dump(page_elements, f, ensure_ascii=False, indent=2)
+                f.write(json_content)
             
             saved_paths.append(str(json_path))
             logger.debug(f"📄 Page JSON saved: {json_path}")

+ 76 - 17
zhch/universal_doc_parser/utils/markdown_generator.py

@@ -5,10 +5,11 @@ Markdown 生成器模块
 - 完整文档 Markdown 生成
 - 按页 Markdown 生成
 - MinerU union_make 集成
+- 金额数字标准化(全角→半角)
 """
 import sys
 from pathlib import Path
-from typing import Dict, Any, List
+from typing import Dict, Any, List, Tuple, Optional
 from loguru import logger
 
 # 导入 MinerU 组件
@@ -28,6 +29,9 @@ except ImportError:
         MM_MD = 'mm_md'
         NLP_MD = 'nlp_md'
 
+# 导入数字标准化工具
+from .normalize_financial_numbers import normalize_markdown_table
+
 
 class MarkdownGenerator:
     """Markdown 生成器类"""
@@ -38,8 +42,9 @@ class MarkdownGenerator:
         middle_json: Dict[str, Any],
         output_dir: Path,
         doc_name: str,
-        use_mineru_union: bool = False
-    ) -> Path:
+        use_mineru_union: bool = False,
+        normalize_numbers: bool = True
+    ) -> Tuple[Path, Optional[Path]]:
         """
         保存 Markdown 文件
         
@@ -52,11 +57,13 @@ class MarkdownGenerator:
             output_dir: 输出目录
             doc_name: 文档名称
             use_mineru_union: 是否使用 MinerU union_make(默认 False)
+            normalize_numbers: 是否标准化金额数字(全角→半角)
             
         Returns:
-            Markdown 文件路径
+            (Markdown 文件路径, 原始文件路径 或 None)
         """
         md_path = output_dir / f"{doc_name}.md"
+        original_path = None
         
         if use_mineru_union and MINERU_AVAILABLE and vlm_union_make is not None:
             try:
@@ -74,49 +81,102 @@ class MarkdownGenerator:
                     header = MarkdownGenerator._generate_header(results)
                     markdown_content = header + str(markdown_content)
                     
+                    # 金额数字标准化
+                    if normalize_numbers:
+                        original_content = markdown_content
+                        markdown_content = normalize_markdown_table(markdown_content)
+                        
+                        if markdown_content != original_content:
+                            original_path = output_dir / f"{doc_name}_original.md"
+                            with open(original_path, 'w', encoding='utf-8') as f:
+                                f.write(original_content)
+                            logger.info(f"📝 Original Markdown saved: {original_path}")
+                    
                     with open(md_path, 'w', encoding='utf-8') as f:
                         f.write(markdown_content)
                     
                     logger.info(f"📝 Markdown saved (MinerU format): {md_path}")
-                    return md_path
+                    return md_path, original_path
                     
             except Exception as e:
                 logger.warning(f"MinerU union_make failed: {e}, falling back to custom implementation")
         
         # 使用自定义实现,确保所有元素类型都被处理
         markdown_content = MarkdownGenerator._generate_full_markdown(results)
+        
+        # 金额数字标准化
+        if normalize_numbers:
+            original_content = markdown_content
+            markdown_content = normalize_markdown_table(markdown_content)
+            
+            if markdown_content != original_content:
+                original_path = output_dir / f"{doc_name}_original.md"
+                with open(original_path, 'w', encoding='utf-8') as f:
+                    f.write(original_content)
+                logger.info(f"📝 Original Markdown saved: {original_path}")
+        
         with open(md_path, 'w', encoding='utf-8') as f:
             f.write(markdown_content)
         
         logger.info(f"📝 Markdown saved (custom format): {md_path}")
-        return md_path
+        return md_path, original_path
     
     @staticmethod
     def save_page_markdowns(
         results: Dict[str, Any],
         output_dir: Path,
-        doc_name: str
+        doc_name: str,
+        is_pdf: bool = True,
+        normalize_numbers: bool = True
     ) -> List[str]:
         """
         按页保存 Markdown 文件
         
+        命名规则:
+        - PDF输入: 文件名_page_001.md
+        - 图片输入(单页): 文件名.md(跳过,因为已有完整版)
+        
         Args:
             results: 处理结果
             output_dir: 输出目录
             doc_name: 文档名称
+            is_pdf: 是否为 PDF 输入
+            normalize_numbers: 是否标准化金额数字(全角→半角)
             
         Returns:
             保存的 Markdown 文件路径列表
         """
         saved_paths = []
+        total_pages = len(results.get('pages', []))
+        
+        # 单个图片输入时,跳过按页保存(因为已有完整版 doc_name.md)
+        if not is_pdf and total_pages == 1:
+            logger.debug("📝 Single image input, skipping page markdown (full version exists)")
+            return saved_paths
         
         for page in results.get('pages', []):
             page_idx = page.get('page_idx', 0)
-            page_name = f"{doc_name}_page_{page_idx + 1:03d}"
+            
+            # 根据输入类型决定命名
+            if is_pdf or total_pages > 1:
+                page_name = f"{doc_name}_page_{page_idx + 1:03d}"
+            else:
+                page_name = doc_name
             
             # 生成单页 Markdown
             md_content = MarkdownGenerator._generate_page_markdown(page, doc_name, page_idx)
             
+            # 金额数字标准化
+            if normalize_numbers:
+                original_content = md_content
+                md_content = normalize_markdown_table(md_content)
+                
+                if md_content != original_content:
+                    original_path = output_dir / f"{page_name}_original.md"
+                    with open(original_path, 'w', encoding='utf-8') as f:
+                        f.write(original_content)
+                    logger.debug(f"📝 Original page Markdown saved: {original_path}")
+            
             # 保存
             md_path = output_dir / f"{page_name}.md"
             with open(md_path, 'w', encoding='utf-8') as f:
@@ -133,12 +193,11 @@ class MarkdownGenerator:
     @staticmethod
     def _generate_header(results: Dict[str, Any]) -> str:
         """生成 Markdown 文件头"""
-        return f"""---
+        return f"""<!--
 scene: {results.get('scene', 'unknown')}
 document: {results.get('document_path', '')}
 pages: {len(results.get('pages', []))}
----
-
+-->
 """
     
     @staticmethod
@@ -155,11 +214,11 @@ pages: {len(results.get('pages', []))}
             Markdown 内容字符串
         """
         md_lines = [
-            f"---",
+            f"<!-- ",
             f"scene: {results.get('scene', 'unknown')}",
             f"document: {results.get('document_path', '')}",
             f"pages: {len(results.get('pages', []))}",
-            f"---",
+            f"-->",
             "",
         ]
         
@@ -224,11 +283,11 @@ pages: {len(results.get('pages', []))}
     def _generate_fallback(results: Dict[str, Any]) -> str:
         """降级方案:自定义 Markdown 生成"""
         md_lines = [
-            f"---",
+            f"<!--",
             f"scene: {results.get('scene', 'unknown')}",
             f"document: {results.get('document_path', '')}",
             f"pages: {len(results.get('pages', []))}",
-            f"---",
+            f"-->",
             "",
         ]
         
@@ -317,11 +376,11 @@ pages: {len(results.get('pages', []))}
             Markdown 内容字符串
         """
         md_lines = [
-            f"---",
+            f"<!--",
             f"document: {doc_name}",
             f"page: {page_idx + 1}",
             f"angle: {page.get('angle', 0)}",
-            f"---",
+            f"-->",
             "",
         ]
         

+ 250 - 0
zhch/universal_doc_parser/utils/normalize_financial_numbers.py

@@ -0,0 +1,250 @@
+import re
+import os
+from pathlib import Path
+
+def normalize_financial_numbers(text: str) -> str:
+    """
+    标准化财务数字:将全角字符转换为半角字符
+    
+    Args:
+        text: 原始文本
+    
+    Returns:
+        标准化后的文本
+    """
+    if not text:
+        return text
+    
+    # 定义全角到半角的映射
+    fullwidth_to_halfwidth = {
+        '0': '0', '1': '1', '2': '2', '3': '3', '4': '4',
+        '5': '5', '6': '6', '7': '7', '8': '8', '9': '9',
+        ',': ',',  # 全角逗号转半角逗号
+        '。': '.',  # 全角句号转半角句号  
+        '.': '.',  # 全角句点转半角句点
+        ':': ':',  # 全角冒号转半角冒号
+        ';': ';',  # 全角分号转半角分号
+        '(': '(',  # 全角左括号转半角左括号
+        ')': ')',  # 全角右括号转半角右括号
+        '-': '-',  # 全角减号转半角减号
+        '+': '+',  # 全角加号转半角加号
+        '%': '%',  # 全角百分号转半角百分号
+    }
+    
+    # 第一步:执行基础字符替换
+    normalized_text = text
+    for fullwidth, halfwidth in fullwidth_to_halfwidth.items():
+        normalized_text = normalized_text.replace(fullwidth, halfwidth)
+    
+    # 第二步:处理数字序列中的空格和分隔符
+    # 修改正则表达式以匹配完整的数字序列,包括空格
+    # 匹配模式:数字 + (空格? + 逗号 + 空格? + 数字)* + (空格? + 小数点 + 数字+)?
+    number_sequence_pattern = r'(\d+(?:\s*[,,]\s*\d+)*(?:\s*[。..]\s*\d+)?)'
+    
+    def normalize_number_sequence(match):
+        sequence = match.group(1)
+        
+        # 处理千分位分隔符周围的空格
+        # 将 "数字 + 空格 + 逗号 + 空格 + 数字" 标准化为 "数字,数字"
+        sequence = re.sub(r'(\d)\s*[,,]\s*(\d)', r'\1,\2', sequence)
+        
+        # 处理小数点周围的空格
+        # 将 "数字 + 空格 + 小数点 + 空格 + 数字" 标准化为 "数字.数字"
+        sequence = re.sub(r'(\d)\s*[。..]\s*(\d)', r'\1.\2', sequence)
+        
+        return sequence
+    
+    normalized_text = re.sub(number_sequence_pattern, normalize_number_sequence, normalized_text)
+    return normalized_text
+    
+def normalize_markdown_table(markdown_content: str) -> str:
+    """
+    专门处理Markdown表格中的数字标准化
+    
+    Args:
+        markdown_content: Markdown内容
+    
+    Returns:
+        标准化后的Markdown内容
+    """
+    # 使用BeautifulSoup处理HTML表格
+    from bs4 import BeautifulSoup, Tag
+    
+    soup = BeautifulSoup(markdown_content, 'html.parser')
+    tables = soup.find_all('table')
+    
+    for table in tables:
+        if isinstance(table, Tag):
+            cells = table.find_all(['td', 'th'])
+            for cell in cells:
+                if isinstance(cell, Tag):
+                    original_text = cell.get_text()
+                    normalized_text = normalize_financial_numbers(original_text)
+                    
+                    # 如果内容发生了变化,更新单元格内容
+                    if original_text != normalized_text:
+                        cell.string = normalized_text
+    
+    # 返回更新后的HTML
+    return str(soup)
+
+def normalize_json_table(json_content: str) -> str:
+    """
+    专门处理JSON格式OCR结果中表格的数字标准化
+    
+    Args:
+        json_content: JSON格式的OCR结果内容
+    
+    Returns:
+        标准化后的JSON内容
+    """
+    """
+    json_content 示例:
+    [
+        {
+            "category": "Table",
+            "text": "<table>...</table>"
+        },
+        {
+            "category": "Text",
+            "text": "Some other text"
+        }
+    ]
+    """
+    import json
+    
+    try:
+        # 解析JSON内容
+        data = json.loads(json_content) if isinstance(json_content, str) else json_content
+        
+        # 确保data是列表格式
+        if not isinstance(data, list):
+            return json_content
+        
+        # 遍历所有OCR结果项
+        for item in data:
+            if not isinstance(item, dict):
+                continue
+                
+            # 检查是否是表格类型
+            if item.get('category') == 'Table' and 'text' in item:
+                table_html = item['text']
+                
+                # 使用BeautifulSoup处理HTML表格
+                from bs4 import BeautifulSoup, Tag
+                
+                soup = BeautifulSoup(table_html, 'html.parser')
+                tables = soup.find_all('table')
+                
+                for table in tables:
+                    if isinstance(table, Tag):
+                        cells = table.find_all(['td', 'th'])
+                        for cell in cells:
+                            if isinstance(cell, Tag):
+                                original_text = cell.get_text()
+                                
+                                # 应用数字标准化
+                                normalized_text = normalize_financial_numbers(original_text)
+                                
+                                # 如果内容发生了变化,更新单元格内容
+                                if original_text != normalized_text:
+                                    cell.string = normalized_text
+                
+                # 更新item中的表格内容
+                item['text'] = str(soup)
+            
+            # 同时标准化普通文本中的数字(如果需要)
+            # elif 'text' in item:
+            #     original_text = item['text']
+            #     normalized_text = normalize_financial_numbers(original_text)
+            #     if original_text != normalized_text:
+            #         item['text'] = normalized_text
+        
+        # 返回标准化后的JSON字符串
+        return json.dumps(data, ensure_ascii=False, indent=2)
+        
+    except json.JSONDecodeError as e:
+        print(f"⚠️ JSON解析失败: {e}")
+        return json_content
+    except Exception as e:
+        print(f"⚠️ JSON表格标准化失败: {e}")
+        return json_content
+
+def normalize_json_file(file_path: str, output_path: str | None = None) -> str:
+    """
+    标准化JSON文件中的表格数字
+    
+    Args:
+        file_path: 输入JSON文件路径
+        output_path: 输出文件路径,如果为None则覆盖原文件
+    
+    Returns:
+        标准化后的JSON内容
+    """
+    input_file = Path(file_path)
+    output_file = Path(output_path) if output_path else input_file
+    
+    if not input_file.exists():
+        raise FileNotFoundError(f"找不到文件: {file_path}")
+    
+    # 读取原始JSON文件
+    with open(input_file, 'r', encoding='utf-8') as f:
+        original_content = f.read()
+    
+    print(f"🔧 正在标准化JSON文件: {input_file.name}")
+    
+    # 标准化内容
+    normalized_content = normalize_json_table(original_content)
+    
+    # 保存标准化后的文件
+    with open(output_file, 'w', encoding='utf-8') as f:
+        f.write(normalized_content)
+    
+    # 统计变化
+    changes = sum(1 for o, n in zip(original_content, normalized_content) if o != n)
+    if changes > 0:
+        print(f"✅ 标准化了 {changes} 个字符")
+        
+        # 如果输出路径不同,也保存原始版本
+        if output_path and output_path != file_path:
+            original_backup = Path(output_path).parent / f"{Path(output_path).stem}_original.json"
+            with open(original_backup, 'w', encoding='utf-8') as f:
+                f.write(original_content)
+            print(f"📄 原始版本已保存到: {original_backup}")
+    else:
+        print("ℹ️ 无需标准化(已是标准格式)")
+    
+    print(f"📄 标准化结果已保存到: {output_file}")
+    return normalized_content
+
+if __name__ == "__main__":
+    # 简单测试
+    test_strings = [
+        "28, 239, 305.48",
+        "2023年净利润为28,239,305.48元",
+        "总资产为1,234,567.89元",
+        "负债总额为500,000.00元",
+        "收入增长了10.5%,达到1,200,000元",
+        "费用为300,000元",
+        "利润率为15.2%",
+        "现金流量为-50,000元",
+        "股东权益为2,500,000.00元",
+        "每股收益为3.25元",
+        "市盈率为20.5倍",
+        "营业收入为750,000元",
+        "净资产收益率为12.3%",
+        "总负债为1,200,000元",
+        "流动比率为1.5倍",
+        "速动比率为1.2倍",
+        "资产负债率为40%",
+        "存货周转率为6次/年",
+        "应收账款周转率为8次/年",
+        "固定资产周转率为2次/年",
+        "总资产周转率为1.2次/年",
+        "经营活动产生的现金流量净额为200,000元"
+    ]
+    
+    for s in test_strings:
+        print("原始: ", s)
+        print("标准化: ", normalize_financial_numbers(s))
+        print("-" * 50)

+ 125 - 11
zhch/universal_doc_parser/utils/output_formatter_v2.py

@@ -9,6 +9,7 @@
 3. Markdown 输出(复用 MinerU union_make)
 4. Debug 模式:layout 图片、OCR 图片
 5. 表格 HTML 输出(带坐标信息)
+6. 金额数字标准化(全角→半角转换)
 
 模块结构:
 - json_formatters.py: JSON 格式化工具
@@ -17,6 +18,7 @@
 - visualization_utils.py: 可视化工具
 """
 import json
+import sys
 from pathlib import Path
 from typing import Dict, Any, List, Optional
 from loguru import logger
@@ -27,6 +29,9 @@ from .markdown_generator import MarkdownGenerator
 from .html_generator import HTMLGenerator
 from .visualization_utils import VisualizationUtils
 
+# 导入数字标准化工具
+from .normalize_financial_numbers import normalize_markdown_table, normalize_json_table
+
 
 class OutputFormatterV2:
     """
@@ -37,6 +42,10 @@ class OutputFormatterV2:
     - page_xxx.json: 每页独立的 JSON,包含 table_cells
     - Markdown: 带 bbox 注释
     - 表格: HTML 格式,带 data-bbox 属性
+    
+    命名规则:
+    - PDF输入: 文件名_page_001.*(按页编号)
+    - 图片输入: 文件名.*(不加页码后缀)
     """
     
     # 颜色映射(导出供其他模块使用)
@@ -54,6 +63,46 @@ class OutputFormatterV2:
         self.output_dir = Path(output_dir)
         self.output_dir.mkdir(parents=True, exist_ok=True)
     
+    @staticmethod
+    def is_pdf_input(results: Dict[str, Any]) -> bool:
+        """
+        判断输入是否为 PDF
+        
+        Args:
+            results: 处理结果
+            
+        Returns:
+            True 如果输入是 PDF,否则 False
+        """
+        doc_path = results.get('document_path', '')
+        if doc_path:
+            return Path(doc_path).suffix.lower() == '.pdf'
+        
+        # 如果没有 document_path,检查 metadata
+        input_type = results.get('metadata', {}).get('input_type', '')
+        return input_type == 'pdf'
+    
+    @staticmethod
+    def get_page_name(doc_name: str, page_idx: int, is_pdf: bool, total_pages: int = 1) -> str:
+        """
+        获取页面名称
+        
+        Args:
+            doc_name: 文档名称
+            page_idx: 页码索引(从0开始)
+            is_pdf: 是否为 PDF 输入
+            total_pages: 总页数
+            
+        Returns:
+            页面名称(不含扩展名)
+        """
+        if is_pdf or total_pages > 1:
+            # PDF 或多页输入:添加页码后缀
+            return f"{doc_name}_page_{page_idx + 1:03d}"
+        else:
+            # 单个图片:不添加页码后缀
+            return doc_name
+    
     def save_results(
         self,
         results: Dict[str, Any],
@@ -62,9 +111,15 @@ class OutputFormatterV2:
         """
         保存处理结果
         
+        命名规则:
+        - PDF输入: 文件名_page_001.*(按页编号)
+        - 图片输入: 文件名.*(不加页码后缀)
+        
         Args:
             results: 处理结果
-            output_config: 输出配置
+            output_config: 输出配置,支持以下选项:
+                - create_subdir: 是否在输出目录下创建文档名子目录(默认 False)
+                - ... 其他选项见 save_mineru_format 函数
             
         Returns:
             输出文件路径字典
@@ -76,15 +131,27 @@ class OutputFormatterV2:
         
         # 创建文档输出目录
         doc_name = Path(results['document_path']).stem
-        doc_output_dir = self.output_dir / doc_name
+        
+        # 是否创建子目录(默认不创建,直接使用指定的输出目录)
+        create_subdir = output_config.get('create_subdir', False)
+        if create_subdir:
+            doc_output_dir = self.output_dir / doc_name
+        else:
+            doc_output_dir = self.output_dir
         doc_output_dir.mkdir(parents=True, exist_ok=True)
         
+        # 判断输入类型
+        is_pdf = self.is_pdf_input(results)
+        total_pages = len(results.get('pages', []))
+        
         # 创建 images 子目录
         images_dir = doc_output_dir / 'images'
         images_dir.mkdir(exist_ok=True)
         
         # 1. 首先保存图片元素(设置 image_path)
-        image_paths = VisualizationUtils.save_image_elements(results, images_dir, doc_name)
+        image_paths = VisualizationUtils.save_image_elements(
+            results, images_dir, doc_name, is_pdf=is_pdf
+        )
         if image_paths:
             output_paths['images'] = image_paths
         
@@ -94,29 +161,62 @@ class OutputFormatterV2:
         # 3. 保存 middle.json
         if output_config.get('save_json', True):
             json_path = doc_output_dir / f"{doc_name}_middle.json"
+            json_content = json.dumps(middle_json, ensure_ascii=False, indent=2)
+            
+            # 金额数字标准化
+            normalize_numbers = output_config.get('normalize_numbers', True)
+            if normalize_numbers:
+                original_content = json_content
+                json_content = normalize_json_table(json_content)
+                
+                # 检查是否有变化
+                if json_content != original_content:
+                    # 保存原始文件
+                    original_path = doc_output_dir / f"{doc_name}_middle_original.json"
+                    with open(original_path, 'w', encoding='utf-8') as f:
+                        f.write(original_content)
+                    logger.info(f"📄 Original middle JSON saved: {original_path}")
+                    output_paths['middle_json_original'] = str(original_path)
+            
             with open(json_path, 'w', encoding='utf-8') as f:
-                json.dump(middle_json, f, ensure_ascii=False, indent=2)
+                f.write(json_content)
             output_paths['middle_json'] = str(json_path)
             logger.info(f"📄 Middle JSON saved: {json_path}")
         
         # 4. 保存每页独立的 mineru_vllm_results_cell_bbox 格式 JSON
         if output_config.get('save_page_json', True):
-            page_json_paths = JSONFormatters.save_page_jsons(results, doc_output_dir, doc_name)
+            normalize_numbers = output_config.get('normalize_numbers', True)
+            page_json_paths = JSONFormatters.save_page_jsons(
+                results, doc_output_dir, doc_name, is_pdf=is_pdf,
+                normalize_numbers=normalize_numbers
+            )
             output_paths['json_pages'] = page_json_paths
         
         # 5. 保存 Markdown(完整版)
         if output_config.get('save_markdown', True):
-            md_path = MarkdownGenerator.save_markdown(results, middle_json, doc_output_dir, doc_name)
+            normalize_numbers = output_config.get('normalize_numbers', True)
+            md_path, original_md_path = MarkdownGenerator.save_markdown(
+                results, middle_json, doc_output_dir, doc_name,
+                normalize_numbers=normalize_numbers
+            )
             output_paths['markdown'] = str(md_path)
+            if original_md_path:
+                output_paths['markdown_original'] = str(original_md_path)
         
         # 5.5 保存每页独立的 Markdown
         if output_config.get('save_page_markdown', True):
-            page_md_paths = MarkdownGenerator.save_page_markdowns(results, doc_output_dir, doc_name)
+            normalize_numbers = output_config.get('normalize_numbers', True)
+            page_md_paths = MarkdownGenerator.save_page_markdowns(
+                results, doc_output_dir, doc_name, is_pdf=is_pdf,
+                normalize_numbers=normalize_numbers
+            )
             output_paths['markdown_pages'] = page_md_paths
         
         # 6. 保存表格 HTML
         if output_config.get('save_html', True):
-            html_dir = HTMLGenerator.save_table_htmls(results, doc_output_dir, doc_name)
+            html_dir = HTMLGenerator.save_table_htmls(
+                results, doc_output_dir, doc_name, is_pdf=is_pdf
+            )
             output_paths['table_htmls'] = str(html_dir)
         
         # 7. Debug 模式:保存可视化图片
@@ -124,12 +224,15 @@ class OutputFormatterV2:
             layout_paths = VisualizationUtils.save_layout_images(
                 results, doc_output_dir, doc_name,
                 draw_type_label=output_config.get('draw_type_label', True),
-                draw_bbox_number=output_config.get('draw_bbox_number', True)
+                draw_bbox_number=output_config.get('draw_bbox_number', True),
+                is_pdf=is_pdf
             )
             output_paths['layout_images'] = layout_paths
         
         if output_config.get('save_ocr_image', False):
-            ocr_paths = VisualizationUtils.save_ocr_images(results, doc_output_dir, doc_name)
+            ocr_paths = VisualizationUtils.save_ocr_images(
+                results, doc_output_dir, doc_name, is_pdf=is_pdf
+            )
             output_paths['ocr_images'] = ocr_paths
         
         logger.info(f"✅ All results saved to: {doc_output_dir}")
@@ -149,13 +252,23 @@ def save_mineru_format(
     Args:
         results: pipeline 处理结果
         output_dir: 输出目录
-        output_config: 输出配置
+        output_config: 输出配置,支持以下选项:
+            - create_subdir: 在输出目录下创建文档名子目录(默认 False)
+            - save_json: 保存 middle.json
+            - save_page_json: 保存每页 JSON
+            - save_markdown: 保存完整 Markdown
+            - save_page_markdown: 保存每页 Markdown
+            - save_html: 保存表格 HTML
+            - save_layout_image: 保存布局可视化图
+            - save_ocr_image: 保存 OCR 可视化图
+            - normalize_numbers: 标准化金额数字(全角→半角)
         
     Returns:
         输出文件路径字典
     """
     if output_config is None:
         output_config = {
+            'create_subdir': False,  # 默认不创建子目录,直接使用指定目录
             'save_json': True,
             'save_page_json': True,
             'save_markdown': True,
@@ -163,6 +276,7 @@ def save_mineru_format(
             'save_html': True,
             'save_layout_image': False,
             'save_ocr_image': False,
+            'normalize_numbers': True,  # 默认启用数字标准化
         }
     
     formatter = OutputFormatterV2(output_dir)

+ 86 - 20
zhch/universal_doc_parser/utils/visualization_utils.py

@@ -17,26 +17,55 @@ from loguru import logger
 class VisualizationUtils:
     """可视化工具类"""
     
-    # 颜色映射(与 MinerU 保持一致)
+    # 颜色映射(与 MinerU BlockType / EnhancedDocPipeline 类别保持一致)
     COLOR_MAP = {
+        # 文本类元素 (TEXT_CATEGORIES)
         'title': (102, 102, 255),           # 蓝色
         'text': (153, 0, 76),               # 深红
-        'image': (153, 255, 51),            # 绿色
-        'image_body': (153, 255, 51),
-        'image_caption': (102, 178, 255),
-        'image_footnote': (255, 178, 102),
+        'ocr_text': (153, 0, 76),           # 深红(同 text)
+        'low_score_text': (200, 100, 100),  # 浅红
+        'header': (128, 128, 128),          # 灰色
+        'footer': (128, 128, 128),          # 灰色
+        'page_number': (160, 160, 160),     # 浅灰
+        'ref_text': (180, 180, 180),        # 浅灰
+        'aside_text': (180, 180, 180),      # 浅灰
+        'page_footnote': (200, 200, 200),   # 浅灰
+        
+        # 表格相关元素
         'table': (204, 204, 0),             # 黄色
-        'table_body': (204, 204, 0),
-        'table_caption': (255, 255, 102),
-        'table_footnote': (229, 255, 204),
+        'table_body': (204, 204, 0),        # 黄色
+        'table_caption': (255, 255, 102),   # 浅黄
+        'table_footnote': (229, 255, 204),  # 浅黄绿
+        
+        # 图片相关元素
+        'image': (153, 255, 51),            # 绿色
+        'image_body': (153, 255, 51),       # 绿色
+        'figure': (153, 255, 51),           # 绿色
+        'image_caption': (102, 178, 255),   # 浅蓝
+        'image_footnote': (255, 178, 102),  # 橙色
+        
+        # 公式类元素
         'interline_equation': (0, 255, 0),  # 亮绿
-        'inline_equation': (0, 200, 0),
-        'list': (40, 169, 92),
+        'inline_equation': (0, 200, 0),     # 绿色
+        'equation': (0, 220, 0),            # 绿色
+        'interline_equation_yolo': (0, 180, 0),
+        'interline_equation_number': (0, 160, 0),
+        
+        # 代码类元素
         'code': (102, 0, 204),              # 紫色
-        'header': (128, 128, 128),          # 灰色
-        'footer': (128, 128, 128),
-        'ref_text': (180, 180, 180),
-        'ocr_text': (153, 0, 76),
+        'code_body': (102, 0, 204),         # 紫色
+        'code_caption': (153, 51, 255),     # 浅紫
+        'algorithm': (128, 0, 255),         # 紫色
+        
+        # 列表类元素
+        'list': (40, 169, 92),              # 青绿
+        'index': (60, 180, 100),            # 青绿
+        
+        # 丢弃类元素
+        'abandon': (100, 100, 100),         # 深灰
+        'discarded': (100, 100, 100),       # 深灰
+        
+        # 错误
         'error': (255, 0, 0),               # 红色
     }
     
@@ -49,21 +78,28 @@ class VisualizationUtils:
     def save_image_elements(
         results: Dict[str, Any],
         images_dir: Path,
-        doc_name: str
+        doc_name: str,
+        is_pdf: bool = True
     ) -> List[str]:
         """
         保存图片元素
         
+        命名规则:
+        - PDF输入: 文件名_page_001_image_1.png
+        - 图片输入(单页): 文件名_image_1.png
+        
         Args:
             results: 处理结果
             images_dir: 图片输出目录
             doc_name: 文档名称
+            is_pdf: 是否为 PDF 输入
             
         Returns:
             保存的图片路径列表
         """
         saved_paths = []
         image_count = 0
+        total_pages = len(results.get('pages', []))
         
         for page in results.get('pages', []):
             page_idx = page.get('page_idx', 0)
@@ -75,7 +111,13 @@ class VisualizationUtils:
                     
                     if image_data is not None:
                         image_count += 1
-                        image_filename = f"{doc_name}_page_{page_idx + 1}_image_{image_count}.png"
+                        
+                        # 根据输入类型决定命名
+                        if is_pdf or total_pages > 1:
+                            image_filename = f"{doc_name}_page_{page_idx + 1}_image_{image_count}.png"
+                        else:
+                            image_filename = f"{doc_name}_image_{image_count}.png"
+                        
                         image_path = images_dir / image_filename
                         
                         try:
@@ -104,22 +146,29 @@ class VisualizationUtils:
         output_dir: Path,
         doc_name: str,
         draw_type_label: bool = True,
-        draw_bbox_number: bool = True
+        draw_bbox_number: bool = True,
+        is_pdf: bool = True
     ) -> List[str]:
         """
         保存 Layout 可视化图片
         
+        命名规则:
+        - PDF输入: 文件名_page_001_layout.png
+        - 图片输入(单页): 文件名_layout.png
+        
         Args:
             results: 处理结果
             output_dir: 输出目录
             doc_name: 文档名称
             draw_type_label: 是否绘制类型标签
             draw_bbox_number: 是否绘制序号
+            is_pdf: 是否为 PDF 输入
             
         Returns:
             保存的图片路径列表
         """
         layout_paths = []
+        total_pages = len(results.get('pages', []))
         
         for page in results.get('pages', []):
             page_idx = page.get('page_idx', 0)
@@ -203,7 +252,12 @@ class VisualizationUtils:
                     draw.rectangle(bbox_label, fill=VisualizationUtils.DISCARD_COLOR)
                     draw.text((x0 + 2, y0 + 2), label, fill='white', font=font)
             
-            layout_path = output_dir / f"{doc_name}_page_{page_idx + 1}_layout.png"
+            # 根据输入类型决定命名
+            if is_pdf or total_pages > 1:
+                layout_path = output_dir / f"{doc_name}_page_{page_idx + 1:03d}_layout.png"
+            else:
+                layout_path = output_dir / f"{doc_name}_layout.png"
+            
             image.save(layout_path)
             layout_paths.append(str(layout_path))
             logger.info(f"🖼️ Layout image saved: {layout_path}")
@@ -214,20 +268,27 @@ class VisualizationUtils:
     def save_ocr_images(
         results: Dict[str, Any],
         output_dir: Path,
-        doc_name: str
+        doc_name: str,
+        is_pdf: bool = True
     ) -> List[str]:
         """
         保存 OCR 可视化图片
         
+        命名规则:
+        - PDF输入: 文件名_page_001_ocr.png
+        - 图片输入(单页): 文件名_ocr.png
+        
         Args:
             results: 处理结果
             output_dir: 输出目录
             doc_name: 文档名称
+            is_pdf: 是否为 PDF 输入
             
         Returns:
             保存的图片路径列表
         """
         ocr_paths = []
+        total_pages = len(results.get('pages', []))
         
         for page in results.get('pages', []):
             page_idx = page.get('page_idx', 0)
@@ -312,7 +373,12 @@ class VisualizationUtils:
                                 draw, ocr_bbox, VisualizationUtils.DISCARD_COLOR, width=1
                             )
             
-            ocr_path = output_dir / f"{doc_name}_page_{page_idx + 1}_ocr.png"
+            # 根据输入类型决定命名
+            if is_pdf or total_pages > 1:
+                ocr_path = output_dir / f"{doc_name}_page_{page_idx + 1:03d}_ocr.png"
+            else:
+                ocr_path = output_dir / f"{doc_name}_ocr.png"
+            
             image.save(ocr_path)
             ocr_paths.append(str(ocr_path))
             logger.info(f"🖼️ OCR image saved: {ocr_path}")