Browse Source

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

zhch158_admin 6 days ago
parent
commit
b6a4b45c91

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

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

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

@@ -5,12 +5,16 @@ JSON 格式化工具模块
 - MinerU middle.json 格式转换
 - MinerU middle.json 格式转换
 - mineru_vllm_results_cell_bbox 格式转换
 - mineru_vllm_results_cell_bbox 格式转换
 - 表格单元格格式化
 - 表格单元格格式化
+- 金额数字标准化(全角→半角)
 """
 """
 import json
 import json
+import sys
 from pathlib import Path
 from pathlib import Path
 from typing import Dict, Any, List, Optional
 from typing import Dict, Any, List, Optional
 from loguru import logger
 from loguru import logger
 
 
+# 导入数字标准化工具
+from .normalize_financial_numbers import normalize_json_table
 
 
 class JSONFormatters:
 class JSONFormatters:
     """JSON 格式化工具类"""
     """JSON 格式化工具类"""
@@ -182,24 +186,38 @@ class JSONFormatters:
     def save_page_jsons(
     def save_page_jsons(
         results: Dict[str, Any],
         results: Dict[str, Any],
         output_dir: Path,
         output_dir: Path,
-        doc_name: str
+        doc_name: str,
+        is_pdf: bool = True,
+        normalize_numbers: bool = True
     ) -> List[str]:
     ) -> List[str]:
         """
         """
         保存每页独立的 JSON(mineru_vllm_results_cell_bbox 格式)
         保存每页独立的 JSON(mineru_vllm_results_cell_bbox 格式)
         
         
+        命名规则:
+        - PDF输入: 文件名_page_001.json
+        - 图片输入(单页): 文件名.json
+        
         Args:
         Args:
             results: 处理结果
             results: 处理结果
             output_dir: 输出目录
             output_dir: 输出目录
             doc_name: 文档名称
             doc_name: 文档名称
+            is_pdf: 是否为 PDF 输入
+            normalize_numbers: 是否标准化金额数字(全角→半角)
             
             
         Returns:
         Returns:
             保存的文件路径列表
             保存的文件路径列表
         """
         """
         saved_paths = []
         saved_paths = []
+        total_pages = len(results.get('pages', []))
         
         
         for page in results.get('pages', []):
         for page in results.get('pages', []):
             page_idx = page.get('page_idx', 0)
             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 格式
             # 转换为 mineru_vllm_results_cell_bbox 格式
             page_elements = []
             page_elements = []
@@ -214,10 +232,24 @@ class JSONFormatters:
                 if converted:
                 if converted:
                     page_elements.append(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
             json_path = output_dir / f"{page_name}.json"
             json_path = output_dir / f"{page_name}.json"
             with open(json_path, 'w', encoding='utf-8') as f:
             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))
             saved_paths.append(str(json_path))
             logger.debug(f"📄 Page JSON saved: {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 生成
 - 按页 Markdown 生成
 - 按页 Markdown 生成
 - MinerU union_make 集成
 - MinerU union_make 集成
+- 金额数字标准化(全角→半角)
 """
 """
 import sys
 import sys
 from pathlib import Path
 from pathlib import Path
-from typing import Dict, Any, List
+from typing import Dict, Any, List, Tuple, Optional
 from loguru import logger
 from loguru import logger
 
 
 # 导入 MinerU 组件
 # 导入 MinerU 组件
@@ -28,6 +29,9 @@ except ImportError:
         MM_MD = 'mm_md'
         MM_MD = 'mm_md'
         NLP_MD = 'nlp_md'
         NLP_MD = 'nlp_md'
 
 
+# 导入数字标准化工具
+from .normalize_financial_numbers import normalize_markdown_table
+
 
 
 class MarkdownGenerator:
 class MarkdownGenerator:
     """Markdown 生成器类"""
     """Markdown 生成器类"""
@@ -38,8 +42,9 @@ class MarkdownGenerator:
         middle_json: Dict[str, Any],
         middle_json: Dict[str, Any],
         output_dir: Path,
         output_dir: Path,
         doc_name: str,
         doc_name: str,
-        use_mineru_union: bool = False
-    ) -> Path:
+        use_mineru_union: bool = False,
+        normalize_numbers: bool = True
+    ) -> Tuple[Path, Optional[Path]]:
         """
         """
         保存 Markdown 文件
         保存 Markdown 文件
         
         
@@ -52,11 +57,13 @@ class MarkdownGenerator:
             output_dir: 输出目录
             output_dir: 输出目录
             doc_name: 文档名称
             doc_name: 文档名称
             use_mineru_union: 是否使用 MinerU union_make(默认 False)
             use_mineru_union: 是否使用 MinerU union_make(默认 False)
+            normalize_numbers: 是否标准化金额数字(全角→半角)
             
             
         Returns:
         Returns:
-            Markdown 文件路径
+            (Markdown 文件路径, 原始文件路径 或 None)
         """
         """
         md_path = output_dir / f"{doc_name}.md"
         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:
         if use_mineru_union and MINERU_AVAILABLE and vlm_union_make is not None:
             try:
             try:
@@ -74,49 +81,102 @@ class MarkdownGenerator:
                     header = MarkdownGenerator._generate_header(results)
                     header = MarkdownGenerator._generate_header(results)
                     markdown_content = header + str(markdown_content)
                     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:
                     with open(md_path, 'w', encoding='utf-8') as f:
                         f.write(markdown_content)
                         f.write(markdown_content)
                     
                     
                     logger.info(f"📝 Markdown saved (MinerU format): {md_path}")
                     logger.info(f"📝 Markdown saved (MinerU format): {md_path}")
-                    return md_path
+                    return md_path, original_path
                     
                     
             except Exception as e:
             except Exception as e:
                 logger.warning(f"MinerU union_make failed: {e}, falling back to custom implementation")
                 logger.warning(f"MinerU union_make failed: {e}, falling back to custom implementation")
         
         
         # 使用自定义实现,确保所有元素类型都被处理
         # 使用自定义实现,确保所有元素类型都被处理
         markdown_content = MarkdownGenerator._generate_full_markdown(results)
         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:
         with open(md_path, 'w', encoding='utf-8') as f:
             f.write(markdown_content)
             f.write(markdown_content)
         
         
         logger.info(f"📝 Markdown saved (custom format): {md_path}")
         logger.info(f"📝 Markdown saved (custom format): {md_path}")
-        return md_path
+        return md_path, original_path
     
     
     @staticmethod
     @staticmethod
     def save_page_markdowns(
     def save_page_markdowns(
         results: Dict[str, Any],
         results: Dict[str, Any],
         output_dir: Path,
         output_dir: Path,
-        doc_name: str
+        doc_name: str,
+        is_pdf: bool = True,
+        normalize_numbers: bool = True
     ) -> List[str]:
     ) -> List[str]:
         """
         """
         按页保存 Markdown 文件
         按页保存 Markdown 文件
         
         
+        命名规则:
+        - PDF输入: 文件名_page_001.md
+        - 图片输入(单页): 文件名.md(跳过,因为已有完整版)
+        
         Args:
         Args:
             results: 处理结果
             results: 处理结果
             output_dir: 输出目录
             output_dir: 输出目录
             doc_name: 文档名称
             doc_name: 文档名称
+            is_pdf: 是否为 PDF 输入
+            normalize_numbers: 是否标准化金额数字(全角→半角)
             
             
         Returns:
         Returns:
             保存的 Markdown 文件路径列表
             保存的 Markdown 文件路径列表
         """
         """
         saved_paths = []
         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', []):
         for page in results.get('pages', []):
             page_idx = page.get('page_idx', 0)
             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
             # 生成单页 Markdown
             md_content = MarkdownGenerator._generate_page_markdown(page, doc_name, page_idx)
             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"
             md_path = output_dir / f"{page_name}.md"
             with open(md_path, 'w', encoding='utf-8') as f:
             with open(md_path, 'w', encoding='utf-8') as f:
@@ -133,12 +193,11 @@ class MarkdownGenerator:
     @staticmethod
     @staticmethod
     def _generate_header(results: Dict[str, Any]) -> str:
     def _generate_header(results: Dict[str, Any]) -> str:
         """生成 Markdown 文件头"""
         """生成 Markdown 文件头"""
-        return f"""---
+        return f"""<!--
 scene: {results.get('scene', 'unknown')}
 scene: {results.get('scene', 'unknown')}
 document: {results.get('document_path', '')}
 document: {results.get('document_path', '')}
 pages: {len(results.get('pages', []))}
 pages: {len(results.get('pages', []))}
----
-
+-->
 """
 """
     
     
     @staticmethod
     @staticmethod
@@ -155,11 +214,11 @@ pages: {len(results.get('pages', []))}
             Markdown 内容字符串
             Markdown 内容字符串
         """
         """
         md_lines = [
         md_lines = [
-            f"---",
+            f"<!-- ",
             f"scene: {results.get('scene', 'unknown')}",
             f"scene: {results.get('scene', 'unknown')}",
             f"document: {results.get('document_path', '')}",
             f"document: {results.get('document_path', '')}",
             f"pages: {len(results.get('pages', []))}",
             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:
     def _generate_fallback(results: Dict[str, Any]) -> str:
         """降级方案:自定义 Markdown 生成"""
         """降级方案:自定义 Markdown 生成"""
         md_lines = [
         md_lines = [
-            f"---",
+            f"<!--",
             f"scene: {results.get('scene', 'unknown')}",
             f"scene: {results.get('scene', 'unknown')}",
             f"document: {results.get('document_path', '')}",
             f"document: {results.get('document_path', '')}",
             f"pages: {len(results.get('pages', []))}",
             f"pages: {len(results.get('pages', []))}",
-            f"---",
+            f"-->",
             "",
             "",
         ]
         ]
         
         
@@ -317,11 +376,11 @@ pages: {len(results.get('pages', []))}
             Markdown 内容字符串
             Markdown 内容字符串
         """
         """
         md_lines = [
         md_lines = [
-            f"---",
+            f"<!--",
             f"document: {doc_name}",
             f"document: {doc_name}",
             f"page: {page_idx + 1}",
             f"page: {page_idx + 1}",
             f"angle: {page.get('angle', 0)}",
             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)
 3. Markdown 输出(复用 MinerU union_make)
 4. Debug 模式:layout 图片、OCR 图片
 4. Debug 模式:layout 图片、OCR 图片
 5. 表格 HTML 输出(带坐标信息)
 5. 表格 HTML 输出(带坐标信息)
+6. 金额数字标准化(全角→半角转换)
 
 
 模块结构:
 模块结构:
 - json_formatters.py: JSON 格式化工具
 - json_formatters.py: JSON 格式化工具
@@ -17,6 +18,7 @@
 - visualization_utils.py: 可视化工具
 - visualization_utils.py: 可视化工具
 """
 """
 import json
 import json
+import sys
 from pathlib import Path
 from pathlib import Path
 from typing import Dict, Any, List, Optional
 from typing import Dict, Any, List, Optional
 from loguru import logger
 from loguru import logger
@@ -27,6 +29,9 @@ from .markdown_generator import MarkdownGenerator
 from .html_generator import HTMLGenerator
 from .html_generator import HTMLGenerator
 from .visualization_utils import VisualizationUtils
 from .visualization_utils import VisualizationUtils
 
 
+# 导入数字标准化工具
+from .normalize_financial_numbers import normalize_markdown_table, normalize_json_table
+
 
 
 class OutputFormatterV2:
 class OutputFormatterV2:
     """
     """
@@ -37,6 +42,10 @@ class OutputFormatterV2:
     - page_xxx.json: 每页独立的 JSON,包含 table_cells
     - page_xxx.json: 每页独立的 JSON,包含 table_cells
     - Markdown: 带 bbox 注释
     - Markdown: 带 bbox 注释
     - 表格: HTML 格式,带 data-bbox 属性
     - 表格: HTML 格式,带 data-bbox 属性
+    
+    命名规则:
+    - PDF输入: 文件名_page_001.*(按页编号)
+    - 图片输入: 文件名.*(不加页码后缀)
     """
     """
     
     
     # 颜色映射(导出供其他模块使用)
     # 颜色映射(导出供其他模块使用)
@@ -54,6 +63,46 @@ class OutputFormatterV2:
         self.output_dir = Path(output_dir)
         self.output_dir = Path(output_dir)
         self.output_dir.mkdir(parents=True, exist_ok=True)
         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(
     def save_results(
         self,
         self,
         results: Dict[str, Any],
         results: Dict[str, Any],
@@ -62,9 +111,15 @@ class OutputFormatterV2:
         """
         """
         保存处理结果
         保存处理结果
         
         
+        命名规则:
+        - PDF输入: 文件名_page_001.*(按页编号)
+        - 图片输入: 文件名.*(不加页码后缀)
+        
         Args:
         Args:
             results: 处理结果
             results: 处理结果
-            output_config: 输出配置
+            output_config: 输出配置,支持以下选项:
+                - create_subdir: 是否在输出目录下创建文档名子目录(默认 False)
+                - ... 其他选项见 save_mineru_format 函数
             
             
         Returns:
         Returns:
             输出文件路径字典
             输出文件路径字典
@@ -76,15 +131,27 @@ class OutputFormatterV2:
         
         
         # 创建文档输出目录
         # 创建文档输出目录
         doc_name = Path(results['document_path']).stem
         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)
         doc_output_dir.mkdir(parents=True, exist_ok=True)
         
         
+        # 判断输入类型
+        is_pdf = self.is_pdf_input(results)
+        total_pages = len(results.get('pages', []))
+        
         # 创建 images 子目录
         # 创建 images 子目录
         images_dir = doc_output_dir / 'images'
         images_dir = doc_output_dir / 'images'
         images_dir.mkdir(exist_ok=True)
         images_dir.mkdir(exist_ok=True)
         
         
         # 1. 首先保存图片元素(设置 image_path)
         # 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:
         if image_paths:
             output_paths['images'] = image_paths
             output_paths['images'] = image_paths
         
         
@@ -94,29 +161,62 @@ class OutputFormatterV2:
         # 3. 保存 middle.json
         # 3. 保存 middle.json
         if output_config.get('save_json', True):
         if output_config.get('save_json', True):
             json_path = doc_output_dir / f"{doc_name}_middle.json"
             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:
             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)
             output_paths['middle_json'] = str(json_path)
             logger.info(f"📄 Middle JSON saved: {json_path}")
             logger.info(f"📄 Middle JSON saved: {json_path}")
         
         
         # 4. 保存每页独立的 mineru_vllm_results_cell_bbox 格式 JSON
         # 4. 保存每页独立的 mineru_vllm_results_cell_bbox 格式 JSON
         if output_config.get('save_page_json', True):
         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
             output_paths['json_pages'] = page_json_paths
         
         
         # 5. 保存 Markdown(完整版)
         # 5. 保存 Markdown(完整版)
         if output_config.get('save_markdown', True):
         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)
             output_paths['markdown'] = str(md_path)
+            if original_md_path:
+                output_paths['markdown_original'] = str(original_md_path)
         
         
         # 5.5 保存每页独立的 Markdown
         # 5.5 保存每页独立的 Markdown
         if output_config.get('save_page_markdown', True):
         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
             output_paths['markdown_pages'] = page_md_paths
         
         
         # 6. 保存表格 HTML
         # 6. 保存表格 HTML
         if output_config.get('save_html', True):
         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)
             output_paths['table_htmls'] = str(html_dir)
         
         
         # 7. Debug 模式:保存可视化图片
         # 7. Debug 模式:保存可视化图片
@@ -124,12 +224,15 @@ class OutputFormatterV2:
             layout_paths = VisualizationUtils.save_layout_images(
             layout_paths = VisualizationUtils.save_layout_images(
                 results, doc_output_dir, doc_name,
                 results, doc_output_dir, doc_name,
                 draw_type_label=output_config.get('draw_type_label', True),
                 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
             output_paths['layout_images'] = layout_paths
         
         
         if output_config.get('save_ocr_image', False):
         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
             output_paths['ocr_images'] = ocr_paths
         
         
         logger.info(f"✅ All results saved to: {doc_output_dir}")
         logger.info(f"✅ All results saved to: {doc_output_dir}")
@@ -149,13 +252,23 @@ def save_mineru_format(
     Args:
     Args:
         results: pipeline 处理结果
         results: pipeline 处理结果
         output_dir: 输出目录
         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:
     Returns:
         输出文件路径字典
         输出文件路径字典
     """
     """
     if output_config is None:
     if output_config is None:
         output_config = {
         output_config = {
+            'create_subdir': False,  # 默认不创建子目录,直接使用指定目录
             'save_json': True,
             'save_json': True,
             'save_page_json': True,
             'save_page_json': True,
             'save_markdown': True,
             'save_markdown': True,
@@ -163,6 +276,7 @@ def save_mineru_format(
             'save_html': True,
             'save_html': True,
             'save_layout_image': False,
             'save_layout_image': False,
             'save_ocr_image': False,
             'save_ocr_image': False,
+            'normalize_numbers': True,  # 默认启用数字标准化
         }
         }
     
     
     formatter = OutputFormatterV2(output_dir)
     formatter = OutputFormatterV2(output_dir)

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

@@ -17,26 +17,55 @@ from loguru import logger
 class VisualizationUtils:
 class VisualizationUtils:
     """可视化工具类"""
     """可视化工具类"""
     
     
-    # 颜色映射(与 MinerU 保持一致)
+    # 颜色映射(与 MinerU BlockType / EnhancedDocPipeline 类别保持一致)
     COLOR_MAP = {
     COLOR_MAP = {
+        # 文本类元素 (TEXT_CATEGORIES)
         'title': (102, 102, 255),           # 蓝色
         'title': (102, 102, 255),           # 蓝色
         'text': (153, 0, 76),               # 深红
         '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': (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),  # 亮绿
         '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),              # 紫色
         '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),               # 红色
         'error': (255, 0, 0),               # 红色
     }
     }
     
     
@@ -49,21 +78,28 @@ class VisualizationUtils:
     def save_image_elements(
     def save_image_elements(
         results: Dict[str, Any],
         results: Dict[str, Any],
         images_dir: Path,
         images_dir: Path,
-        doc_name: str
+        doc_name: str,
+        is_pdf: bool = True
     ) -> List[str]:
     ) -> List[str]:
         """
         """
         保存图片元素
         保存图片元素
         
         
+        命名规则:
+        - PDF输入: 文件名_page_001_image_1.png
+        - 图片输入(单页): 文件名_image_1.png
+        
         Args:
         Args:
             results: 处理结果
             results: 处理结果
             images_dir: 图片输出目录
             images_dir: 图片输出目录
             doc_name: 文档名称
             doc_name: 文档名称
+            is_pdf: 是否为 PDF 输入
             
             
         Returns:
         Returns:
             保存的图片路径列表
             保存的图片路径列表
         """
         """
         saved_paths = []
         saved_paths = []
         image_count = 0
         image_count = 0
+        total_pages = len(results.get('pages', []))
         
         
         for page in results.get('pages', []):
         for page in results.get('pages', []):
             page_idx = page.get('page_idx', 0)
             page_idx = page.get('page_idx', 0)
@@ -75,7 +111,13 @@ class VisualizationUtils:
                     
                     
                     if image_data is not None:
                     if image_data is not None:
                         image_count += 1
                         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
                         image_path = images_dir / image_filename
                         
                         
                         try:
                         try:
@@ -104,22 +146,29 @@ class VisualizationUtils:
         output_dir: Path,
         output_dir: Path,
         doc_name: str,
         doc_name: str,
         draw_type_label: bool = True,
         draw_type_label: bool = True,
-        draw_bbox_number: bool = True
+        draw_bbox_number: bool = True,
+        is_pdf: bool = True
     ) -> List[str]:
     ) -> List[str]:
         """
         """
         保存 Layout 可视化图片
         保存 Layout 可视化图片
         
         
+        命名规则:
+        - PDF输入: 文件名_page_001_layout.png
+        - 图片输入(单页): 文件名_layout.png
+        
         Args:
         Args:
             results: 处理结果
             results: 处理结果
             output_dir: 输出目录
             output_dir: 输出目录
             doc_name: 文档名称
             doc_name: 文档名称
             draw_type_label: 是否绘制类型标签
             draw_type_label: 是否绘制类型标签
             draw_bbox_number: 是否绘制序号
             draw_bbox_number: 是否绘制序号
+            is_pdf: 是否为 PDF 输入
             
             
         Returns:
         Returns:
             保存的图片路径列表
             保存的图片路径列表
         """
         """
         layout_paths = []
         layout_paths = []
+        total_pages = len(results.get('pages', []))
         
         
         for page in results.get('pages', []):
         for page in results.get('pages', []):
             page_idx = page.get('page_idx', 0)
             page_idx = page.get('page_idx', 0)
@@ -203,7 +252,12 @@ class VisualizationUtils:
                     draw.rectangle(bbox_label, fill=VisualizationUtils.DISCARD_COLOR)
                     draw.rectangle(bbox_label, fill=VisualizationUtils.DISCARD_COLOR)
                     draw.text((x0 + 2, y0 + 2), label, fill='white', font=font)
                     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)
             image.save(layout_path)
             layout_paths.append(str(layout_path))
             layout_paths.append(str(layout_path))
             logger.info(f"🖼️ Layout image saved: {layout_path}")
             logger.info(f"🖼️ Layout image saved: {layout_path}")
@@ -214,20 +268,27 @@ class VisualizationUtils:
     def save_ocr_images(
     def save_ocr_images(
         results: Dict[str, Any],
         results: Dict[str, Any],
         output_dir: Path,
         output_dir: Path,
-        doc_name: str
+        doc_name: str,
+        is_pdf: bool = True
     ) -> List[str]:
     ) -> List[str]:
         """
         """
         保存 OCR 可视化图片
         保存 OCR 可视化图片
         
         
+        命名规则:
+        - PDF输入: 文件名_page_001_ocr.png
+        - 图片输入(单页): 文件名_ocr.png
+        
         Args:
         Args:
             results: 处理结果
             results: 处理结果
             output_dir: 输出目录
             output_dir: 输出目录
             doc_name: 文档名称
             doc_name: 文档名称
+            is_pdf: 是否为 PDF 输入
             
             
         Returns:
         Returns:
             保存的图片路径列表
             保存的图片路径列表
         """
         """
         ocr_paths = []
         ocr_paths = []
+        total_pages = len(results.get('pages', []))
         
         
         for page in results.get('pages', []):
         for page in results.get('pages', []):
             page_idx = page.get('page_idx', 0)
             page_idx = page.get('page_idx', 0)
@@ -312,7 +373,12 @@ class VisualizationUtils:
                                 draw, ocr_bbox, VisualizationUtils.DISCARD_COLOR, width=1
                                 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)
             image.save(ocr_path)
             ocr_paths.append(str(ocr_path))
             ocr_paths.append(str(ocr_path))
             logger.info(f"🖼️ OCR image saved: {ocr_path}")
             logger.info(f"🖼️ OCR image saved: {ocr_path}")