文档清洗系统初始化脚本

This commit is contained in:
cxs
2025-05-16 11:30:02 +08:00
parent a73040d739
commit 532eb2857c
29 changed files with 11568 additions and 225 deletions

3
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

12
.idea/doc-etl.iml generated Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="GOOGLE" />
<option name="myDocStringFormat" value="Google" />
</component>
</module>

View File

@@ -0,0 +1,21 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PyInterpreterInspection" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="PyPackageRequirementsInspection" enabled="false" level="WARNING" enabled_by_default="false">
<option name="ignoredPackages">
<value>
<list size="7">
<item index="0" class="java.lang.String" itemvalue="httpx" />
<item index="1" class="java.lang.String" itemvalue="sanic_cors" />
<item index="2" class="java.lang.String" itemvalue="aiofiles" />
<item index="3" class="java.lang.String" itemvalue="sanic" />
<item index="4" class="java.lang.String" itemvalue="sanic-ext" />
<item index="5" class="java.lang.String" itemvalue="Jinja2" />
<item index="6" class="java.lang.String" itemvalue="PyExecJS2" />
</list>
</value>
</option>
</inspection_tool>
</profile>
</component>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/doc-etl.iml" filepath="$PROJECT_DIR$/.idea/doc-etl.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

566
README.md
View File

@@ -1,235 +1,361 @@
# 文档清理工具
# 文档处理系统
这是一个用于理和标准化Word文档(doc/docx)的Python工具主要用于为构建RAG知识库做数据准备工作
## 主要功能
- 移除页眉页脚(包括页码)
- 删除特殊符号(版权信息、水印等)
- 统一标点符号(全角转半角)
- 分离正文与附录/参考文献
- 删除重复段落(基于文本相似度)
- 自动跳过图片内容
- 支持doc格式自动转换为docx
- 保持原始文档格式统一输出docx格式
- 完整保留表格内容及格式
- 支持表格转换为格式化文本,便于大模型识别
- 同时输出docx和txt格式文件txt文件包含完整的文本内容和表格的文本表示
本系统用于理和转换文档支持PDF和Word文档的处理
## 系统要求
- Python 3.6+
- LibreOffice用于转换doc格式文件
### 必需组件
### 安装LibreOffice
1. Python 3.8 或更高版本
2. LibreOffice用于文档格式转换
- 下载地址https://www.libreoffice.org/download/download/
- 安装后需要将安装目录(通常是 `C:\Program Files\LibreOffice\program``C:\Program Files (x86)\LibreOffice\program`)添加到系统 PATH 环境变量
- 如果安装后仍然报错,请尝试重启系统
- macOS:
```bash
brew install libreoffice
```
3. Tesseract OCR用于图片文字识别
- 下载地址https://github.com/UB-Mannheim/tesseract/wiki
- 安装时选择"添加到系统路径"选项
- Ubuntu/Debian:
```bash
sudo apt-get install libreoffice
```
### Python 依赖
- Windows:
从[LibreOffice官网](https://www.libreoffice.org/download/download/)下载安装并确保将安装目录添加到系统PATH中。
## 安装依赖
所有必需的 Python 包都列在 `requirements.txt` 文件中。使用以下命令安装:
```bash
pip install -r requirements.txt
```
## 使用方法
```bash
python doc_cleaner.py 输入目录
```
### 示例
```bash
python doc_cleaner.py ./input_docs
```
## 输出说明
程序会为每个处理的文档生成两个文件:
- `文档名_cleaned.docx`: 包含清理后的正文内容和附录(如果存在)
- 附录内容会自动添加分页符并在新页面开始
- 所有文件包括原始doc格式都会统一转换并保存为docx格式
- 保持文档格式为docx支持段落对齐等基本格式
- 表格以文本格式显示使用ASCII字符绘制边框和分隔符
- 使用等宽字体Courier New确保表格对齐
- 自动调整列宽以适应内容
- 清晰标识表格序号和位置(正文/附录)
- `文档名_cleaned.txt`: 包含所有文本内容的纯文本文件
- 包含完整的正文内容
- 包含所有表格的文本表示使用ASCII字符绘制
- 保持文档的原始结构(正文、表格、附录的顺序)
- 使用空行分隔不同部分
- 清晰标注表格的序号和位置
## 注意事项
1. 确保输入目录中包含要处理的doc或docx文件
2. 程序会自动创建输出目录(如果不存在)
3. 处理过程中的错误会被记录但不会中断整体处理
4. 相似度阈值默认设置为0.85,可以通过修改代码中的`similarity_threshold`参数调整
5. 输出文件将统一保存为docx格式便于后续编辑和使用
6. 处理doc格式文件需要安装LibreOffice
7. 首次处理doc文件时可能需要较长时间因为需要进行格式转换
## 正则表达式说明
### 页眉页脚匹配模式
- `\d+-\d+`: 匹配类似"1-1"的页码格式
- `第\s*\d+\s*页`: 匹配中文页码
- `Page\s*\d+\s*of\s*\d+`: 匹配英文页码
### 附录标题匹配模式
- `^附录\s*[A-Za-z]?[\s:]`
- `^Appendix\s*[A-Za-z]?[\s:]`
- `^参考文献$`
- `^References$`
- `^Bibliography$`
## 版本历史
### v1.1.0 (2024-01-09)
- 新增完整的表格支持
- 保留表格原始格式和样式
- 优化文档处理流程
### v1.0.0
- 初始版本发布
- 基础文档清理功能
## 更新日志
### 2024-03-21
- 修复了表格位置错误的问题
- 改进了表格占位符的处理机制
- 实现了基于索引的精确表格定位
- 确保表格按原文档位置正确插入
- 重构了文档处理核心逻辑
- 改进了文档元素的解析和存储方式
- 优化了正文和附录的分离逻辑
- 加强了表格位置的追踪机制
- 简化了文档结构处理流程
### 2024-03-xx
- 修复了表格在清理过程中位置错位的问题
- 改进了文本清理逻辑,确保表格占位符不被清理
- 优化了去重算法,保持表格在文档中的原始位置
- 分离表格和文本内容的处理流程,避免交叉影响
### 2024-03-22
- 优化了文件类型检测方法
- 移除了对magic库的依赖
- 改用文件后缀名直接判断文件类型
- 简化了文件类型检测逻辑
### 2024-03-23
- 优化表格处理方式
- 将表格直接转换为文本格式显示在文档中
- 使用ASCII字符绘制表格边框和分隔符
- 采用等宽字体确保表格对齐
- 自动调整列宽以适应内容
- 优化了表格内容的文本对齐方式
- 保留表头样式,便于识别表格结构
- 清晰标识表格序号和位置(正文/附录)
- 移除了原始表格格式,提高可读性和兼容性
### 2024-03-24
- 新增文本文件输出功能
- 同时生成docx和txt格式的输出文件
- txt文件包含完整的文本内容和表格的文本表示
- 保持文档结构和内容的完整性
- 优化了表格在文本文件中的显示效果
### 2024-03-25
- 优化文本输出格式
- 移除文本输出中的换行符
- 使用空格分隔文本内容
- 提高文本的连续性和可读性
### 2024-03-26
- 优化表格文本格式
- 移除ASCII边框字符+和-
- 使用方括号[]标识单元格边界
- 避免JSON解析错误
- 简化表格文本表示方式
### 2024-03-27
- 进一步优化文本输出格式
- 移除所有换行符和多余空格
- 所有内容在单行内显示
- 优化表格文本的连续性
- 确保输出文本的JSON兼容性
### 2024-03-28
- 改进表格输出格式
- 采用表单形式输出表格内容
- 使用"键:值"格式表示每个单元格
- 按行组织表格数据
- 使用表头作为数据项的键名
- 提高表格数据的可读性和结构性
### 2024-03-29
- 完善表格输出格式
- 添加表头行作为第一行输出
- 确保每行数据包含所有字段
- 保持字段顺序与表头一致
- 空值字段显示为空字符串
- 提升数据的完整性和一致性
### 2024-03-30
- 增强复杂表格处理能力
- 支持多级表头的识别和处理
- 正确处理水平和垂直合并单元格
- 智能识别表头行数最多支持3行表头
- 优化表头和数据的关联方式
- 提供更清晰的数据结构表示
- 完善空值和特殊情况的处理
- 确保合并单元格数据的完整性
### 2024-03-31
- 优化表格处理错误处理机制
- 增加多层错误处理,确保程序稳定性
- 改进合并单元格处理逻辑
- 优化空值和异常值的处理方式
- 提供更详细的错误位置信息
- 确保部分错误不影响整体处理
- 完善日志输出,便于问题定位
### 2024-04-01
- 深度优化复杂表格处理
- 改进多级表头的识别和处理算法
- 优化表头层级结构的分析方法
- 增强跨行跨列单元格的处理能力
- 改进表头标识符的构建逻辑
- 提升数据与表头的关联准确性
- 支持更复杂的表格结构解析
- 完善异常情况的处理机制
### 2024-04-02
- 优化表头处理逻辑
- 消除表头中的重复前缀
- 优化多级表头的组合方式
- 改进跨列表头的处理机制
- 增加表头去重功能
- 保持表头层级的清晰性
- 提高表格数据的可读性
- 减少冗余信息
## 功能特性
- 支持doc和docx格式的文档处理
- 清理文档中的页眉页脚
- 保留文档中的表格并维持其原始位置
- 支持附录的单独处理
- 文本去重功能
- 批量处理目录下的所有文档
- 支持多种文档格式的处理
- PDF 文件
- Word 文档 (.doc, .docx)
- HTML 文件 (.html, .htm)
- Excel 文件 (.xls, .xlsx)
- 自动提取文档中的表格和图片
- 智能清理和格式化文本内容
- 支持多种输出格式:
- Word 文档 (.docx)
- 纯文本文件 (.txt)
- Markdown 文件 (.md)
- 提供 RESTful API 接口
- 高级OCR图像识别功能
- 独立的OCR测试Web界面
- 多种图像预处理算法
- 支持中文优化的OCR处理
- 自动选择最佳OCR结果
- 直观显示不同处理方法的效果对比
- 可视化处理前后的图像变化
## 最近更新
### 2024年5月21日
- **增强复杂表格处理的安全性和稳定性**
- 全面优化索引安全处理,解决表格解析中的索引越界问题
- 增加表格行列索引检查机制,防止数组访问异常
- 引入垂直合并传播机制,自动填充复杂表格中的间隔空白单元格
- 增强多级分类表格处理,针对药品分类等特殊表格格式的优化
- 改进空白单元格智能填充算法,分析上下文识别合并单元格模式
- 优化错误日志记录,提供精确的错误位置信息便于问题定位
- 统一文本和Markdown输出处理逻辑确保不同格式输出的一致性
- 修复多行多列复杂表格中的内容缺失问题,提高数据完整性
### 2024年5月20日
- **增强复杂表格合并单元格识别能力**
- 改进垂直合并单元格的识别逻辑,即使未被正确标记的合并单元格也能被识别出来
- 特别优化对药品分类等表格中常见的第一列垂直合并单元格的处理
- 新增内容一致性检测机制,自动识别内容相同但分布在不同行的单元格
- 添加空白单元格智能填充机制,推断可能的合并单元格内容
- 统一文本与Markdown表格输出中对合并单元格的处理逻辑
- 对合并单元格内容进行正确复制,确保表格展示的结构完整性
- 提高对格式不规范表格的兼容性和处理能力
### 2024年5月19日
- **表格处理模块化重构**
- 将表格处理相关代码抽取到独立模块`cxs_table_processor.py`
- 实现`TableProcessor`类,封装所有表格相关的处理功能
- 保留`TableData`类作为表格数据的标准存储结构
- 优化代码结构,提高可维护性和扩展性
- 改进表格处理模块与主程序的交互接口
- 实现统一的表格处理方法调用方式
- 便于后续对表格处理功能的单独升级和优化
### 2024-05-16
- 增强了复杂表格处理能力
- 优化了对合并单元格表格的识别和处理
- 降低了表格有效性的判断门槛,能够识别更多种类的表格
- 改进了表格到Markdown的转换添加了HTML格式输出选项
- 完善了对垂直合并单元格的处理逻辑
- 增强了表格内容矩阵的构建和处理
- 优化了药品分类等复杂结构表格的识别
- 改进了表格文本输出格式,提高可读性
### 2024-05-15
- 修复了cxs_doc_cleaner.py中的语法错误
- 解决了处理文档元素时的try-except块和缩进结构问题
- 改进了表格和段落处理的代码结构
- 确保所有异常处理逻辑正确嵌套
- 优化了错误信息输出格式
### 2024-05-12
- 修复了`Table`类初始化错误问题
- 创建了自定义`TableData`类代替直接使用`docx.table.Table`
- 解决了`Table.__init__() missing 2 required positional arguments: 'tbl' and 'parent'`错误
- 重构了相关代码以适配新的类结构
- 优化了列属性处理方式
- 改进了单元格访问方法
- 添加了`test_big_file.py`工具
- 专门用于从大型复杂Word文档中提取表格
- 支持自动识别文档中的所有表格
- 实现了更强大的表格内容提取逻辑
- 可以处理合并单元格和复杂结构
- 生成格式友好的表格文本输出
### 2024-05-02
- 修复文档中图片OCR无法识别的问题
- 添加pytesseract显式导入确保OCR组件可用
- 优化Tesseract OCR路径自动检测和配置流程
- 增加pytesseract版本检测机制确保OCR组件正常工作
- 改进OCR处理流程添加直接调用pytesseract模式不再完全依赖pdf_processor
- 添加多重OCR尝试策略提高图片文字识别成功率
- 增强OCR错误处理和日志记录便于问题诊断
- 简化单图片OCR处理逻辑增加文本预览功能
- 修复PdfProcessor类中缺少_is_valid_image方法的问题解决图片验证失败的错误
### 2024-01-20
- 修复文档中图片OCR识别问题
- 解决了从Word文档中提取的图片无法被OCR识别的问题
- 增加了Tesseract OCR路径自动检测和配置功能
- 添加了独立的图片OCR测试工具方便排查问题
- 优化了图像提取和OCR处理流程提高识别成功率
- 增强了图像预处理算法,改进对不同格式图片的支持
- 新增fix_tesseract_path.py脚本提供一键修复和测试功能
### 2024-01-19
- 修复Word文档图像提取和OCR功能
- 修复了Word文档处理流程中图像提取功能未被正确调用的问题
- 增强了调试日志系统提供更详细的图像提取和OCR处理信息
- 优化了文档处理流程确保图像OCR结果被正确保存和展示
- 添加了更多统计信息包括图像提取数量和OCR识别成功率
- 改进了不同文件格式的处理逻辑,确保最大兼容性
- 增强了错误处理能力,提高了系统的鲁棒性和稳定性
### 2024-01-18
- 增强了DOCX文档中图像的提取与OCR识别能力
- 实现了三种不同的图像提取方法确保各种格式的Word文档中的图像都能被正确提取
- 为图像提取和OCR处理添加了全面的调试日志记录每个步骤的处理结果
- 增加了原始图像和处理后图像的保存功能便于分析OCR失败的原因
- 优化了图像过滤机制,自动识别并排除无效的小图像和非图像文件
- 引入中文优化的二次识别机制,提高中文图像的文字识别率
- 添加了详细的处理统计信息,包括成功识别率和处理时间
- 改进了错误处理和异常报告,提高系统稳定性
- 增加了中文OCR专用图像优化模块 `_optimize_for_chinese.py`
- 提供9种针对中文文本的图像处理方法适用于不同场景
- 支持图像倾斜校正,提高不规则拍摄图像的识别率
- 针对中文笔画特点优化的形态学处理算法
- 提供一体化的中文OCR预处理流程
- 自动判断最佳处理方法优化OCR结果
### 2024-01-17
- 新增OCR测试Web界面便于快速测试图像文字识别效果
- 开发了独立的OCR API服务支持多种图像处理模式
- 引入自动选择最佳OCR结果的机制通过对比不同处理方法的结果选择最优方案
- 可视化展示图像处理细节和实时预览效果
- 简化OCR测试流程支持拖放上传图像并一键处理
- 提供快速启动脚本,自动检查环境和依赖
- 优化调试文件存储和显示,方便问题分析
### 2024-01-16
- 大幅增强了OCR图像识别能力提高了复杂图像的文字识别率
- 引入了9种新的图像预处理方法并自动进行组合尝试
- 新增图像纠偏和倾斜校正,提高对歪斜文本的识别能力
- 增加针对中文处理的专项优化
- 增加超高DPI处理提高小字体和复杂字符的识别率
- 引入基于PSM模式的多方案OCR处理自动选择最佳结果
- 改进OCR结果评估机制综合考虑文本长度和置信度
- 增加图像处理调试功能,自动保存处理前后的图像用于分析
- 优化OCR结果报表提供各种处理方法的对比和详细统计
### 2024-01-15
- 增强了OCR功能
- 改进了PDF处理器中的OCR文本识别功能
- 增强了Tesseract OCR路径检测
- 添加了OCR识别重试机制
### 2024-01-14
- 优化文档图片OCR识别功能大幅提升Word文档内图片文本识别率
- 增强图片处理逻辑,添加文件类型验证,防止非图片文件误处理
- 增加OCR失败重试机制提高文本识别成功率
- 添加更多调试信息,帮助排查图片处理问题
- 改进Markdown输出中图片显示方式正确标识无法识别文本的图片
### 2024-01-13
- 添加openpyxl依赖库修复Excel文件处理的依赖问题
- 更新requirements.txt文件确保Excel文件可以正确读取
- 优化Excel文件处理逻辑解决处理大型Excel文件时卡住的问题
- 增加直接处理Excel文件选项不必转换为Word格式
- 限制处理的Excel行数提高大型文件处理效率
- 改进临时文件处理机制,增加文件删除重试功能
- 优化Excel文件句柄管理确保文件资源及时释放
### 2024-01-12
- 优化Markdown文档输出格式使其更接近原文排版
- 改进复杂表格的Markdown转换提高可读性
- 修复图片无法在Markdown中正确显示的问题
- 增强文档结构识别,自动识别标题层级
### 2024-01-11
- 修复缺少uuid库导入的问题
- 改进图片目录创建逻辑
- 优化文件路径处理机制
### 2024-01-10
- 新增支持 HTML 文件处理
- 新增支持 Excel 文件处理
- 优化文件格式处理逻辑
- 改进错误处理机制
### 2024-01-09
- 新增 Markdown 格式输出支持
- 优化文件处理逻辑
- 改进错误处理
## 安装说明
1. 克隆项目代码
2. 安装依赖:
```bash
pip install -r requirements.txt
```
3. 配置环境变量(可选):
- OLLAMA_HOSTOllama 服务器地址
- TESSERACT_CMDTesseract OCR 可执行文件路径
## 使用说明
### 文件上传
- 支持的文件格式:
- PDF (.pdf)
- Word (.doc, .docx)
- HTML (.html, .htm)
- Excel (.xls, .xlsx)
### API 接口
- 文件上传POST `/api/upload`
- 文件下载GET `/api/download/{filename}`
### 输出格式
- Word 文档:`response_文件名_output.docx`
- 纯文本文件:`response_文件名_output.txt`
- Markdown 文件:`response_文件名_output.md`
### OCR图像识别测试
使用OCR测试功能可以快速测试图像文字识别效果无需处理完整文档。
#### 快速启动方法
1. 运行项目根目录下的启动脚本:
```bash
python start_ocr_test.py
```
脚本会自动:
- 检查必要依赖是否安装
- 验证Tesseract OCR配置
- 启动OCR API服务
- 在浏览器中打开测试界面
2. 如果自动打开浏览器失败,请手动访问:
```
http://localhost:8001/static/ocr_test.html
```
#### 使用方法
1. 在测试界面上传图片文件(支持拖放上传)
2. 选择识别语言(默认为中文+英文)
3. 选择处理模式:
- **自动模式**:自动尝试最佳处理方法
- **标准模式**使用基本OCR处理速度最快
- **中文优化**:专为中文文本优化的处理方法
- **高级模式**:使用多种图像处理方法,并比较结果
4. 点击"执行OCR"按钮开始处理
5. 查看处理结果:
- **识别文本**:显示识别出的文本内容
- **处理详情**:显示不同处理方法的结果对比
- **处理图像**:显示处理前后的图像效果
#### 处理模式说明
- **自动模式**:适合大多数图像,自动选择最佳处理方法
- **标准模式**:适合清晰、对比度高的图像,处理速度最快
- **中文优化**:适合包含中文的图像,特别是小字体或模糊的中文文本
- **高级模式**:适合复杂图像,会尝试多种处理方法并选择最佳结果,处理时间较长
## 注意事项
1. Excel 文件处理时会将每个工作表转换为单独的章节
2. HTML 文件会保留基本的文本格式和表格结构
3. 所有临时文件会保存在 `temp` 目录下
## 开发说明
### 目录结构
```
doc-etl/
├── cxs/
│ ├── static/ # 前端文件
│ │ └── ocr_test.html # OCR测试界面
│ ├── main.py # 主程序
│ ├── cxs_doc_cleaner.py # 文档处理核心
│ ├── cxs_table_processor.py # 表格处理模块
│ ├── cxs_pdf_cleaner.py # PDF处理模块
│ ├── _optimize_for_chinese.py # 中文OCR优化模块
│ └── ocr_api.py # OCR API服务
├── temp/
│ ├── uploads/ # 上传文件
│ ├── outputs/ # 输出文件
│ ├── images/ # 临时图片
│ └── debug/ # OCR处理调试图像
├── start_ocr_test.py # OCR测试启动脚本
└── requirements.txt # 依赖清单
```
### 开发环境
- Python 3.8+
- 依赖详见 requirements.txt
## 图像OCR问题排查
### 问题: 文档中图片OCR无法识别
如果您遇到文档处理时图片OCR识别失败的问题很可能是因为Tesseract OCR工具的路径配置不正确。系统在初始化时会尝试自动查找Tesseract但如果系统环境变量中没有正确配置OCR功能可能无法正常工作。
### 解决方案
我们提供了一个修复脚本`fix_tesseract_path.py`,它可以:
1. 自动查找系统中已安装的Tesseract OCR
2. 正确设置Tesseract路径
3. 处理您的文档并启用图片OCR功能
使用方法:
```bash
# 直接处理指定文档
python fix_tesseract_path.py --file 您的文档.docx
# 指定Tesseract路径
python fix_tesseract_path.py --file 您的文档.docx --tesseract "C:\Program Files\Tesseract-OCR\tesseract.exe"
# 交互式模式
python fix_tesseract_path.py
```
### 注意事项
1. 确保已安装Tesseract OCR如未安装请从[官方GitHub](https://github.com/UB-Mannheim/tesseract/wiki)下载并安装
2. 安装时选择中文语言包以支持中文OCR识别
3. 建议将Tesseract添加到系统PATH环境变量中或在配置文件中明确指定路径
### 手动设置Tesseract路径
如果您希望永久解决这个问题,可以:
1. 将Tesseract安装目录(通常是`C:\Program Files\Tesseract-OCR`)添加到系统PATH环境变量
2. 设置环境变量`TESSERACT_CMD`为Tesseract可执行文件的完整路径

120
cxs/README.md Normal file
View File

@@ -0,0 +1,120 @@
# 文档处理系统
这是一个基于 FastAPI 的文档处理系统,可以将 DOC、DOCX 和 PDF 文件转换为纯文本格式。
## 系统要求
### 必需组件
1. Python 3.8 或更高版本
2. LibreOffice用于文档格式转换
- 下载地址https://www.libreoffice.org/download/download/
- 安装后需要将安装目录(通常是 `C:\Program Files\LibreOffice\program`)添加到系统 PATH 环境变量
3. Tesseract OCR用于图片文字识别
- 下载地址https://github.com/UB-Mannheim/tesseract/wiki
- 安装时选择"添加到系统路径"选项
### Python 依赖
所有必需的 Python 包都列在 `requirements.txt` 文件中。使用以下命令安装:
```bash
pip install -r requirements.txt
```
## 功能特点
- 支持 DOC、DOCX 和 PDF 文件格式
- 提供简单的拖拽上传界面
- 自动清理文档内容,去除冗余信息
- 输出整洁的纯文本文件
- 自动提取文档正文内容
- 支持图片中的文字识别OCR
- 自动分离正文和附录内容
- 自动下载处理后的文本文件
## 安装说明
1. 克隆项目到本地:
```bash
git clone <repository_url>
cd doc-etl
```
2. 创建虚拟环境(推荐):
```bash
python -m venv venv
source venv/bin/activate # Linux/Mac
venv\Scripts\activate # Windows
```
3. 安装依赖:
```bash
pip install -r requirements.txt
```
## 运行说明
1. 启动服务器:
```bash
uvicorn main:app --reload
```
2. 打开浏览器访问:
```
http://localhost:8000
```
3. 在网页界面上传文件,系统会自动处理并返回转换后的文本文件。
## 目录结构
```
doc-etl/
├── main.py # 主应用程序
├── requirements.txt # 项目依赖
├── README.md # 项目说明
├── static/ # 静态文件
│ └── index.html # 上传页面
├── temp/ # 临时文件目录
└── cxs/ # 文档处理模块
└── cxs_doc_cleaner.py
```
## 注意事项
1. 确保系统已安装 Python 3.8 或更高版本
2. 处理 PDF 文件时需要安装额外的依赖
3. 所有临时文件会在以下情况自动清理:
- 文件处理完成后
- 发生错误时
- 文件下载完成后
- 程序退出时
4. 临时文件存储在 `temp` 目录中,该目录会在程序启动时自动清理
## 更新日志
### 2024-03-21
- 初始版本发布
- 支持基本的文档处理功能
- 添加文件上传界面
### 2024-03-xx
- 优化了文件处理逻辑
- 添加了更详细的错误处理
- 改进了文件类型验证
- 添加了处理进度显示
- 增强了临时文件的自动清理机制
### 2024-03-22
- 优化了批量处理逻辑,改为顺序处理文件
- 添加了文件处理状态实时显示
- 改进了临时文件清理机制
- 增强了错误处理和提示信息
### 2024-03-23
- 优化了图片文件夹命名规则,使用"文件名_随机ID"格式
- 改进了文件清理机制,处理完立即清理
- 添加了更多的日志输出,方便调试问题
- 优化了临时目录的管理和清理时机

View File

@@ -0,0 +1,285 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
针对中文OCR的图像预处理优化
"""
import cv2
import numpy as np
from typing import Optional, Tuple, List, Dict, Any
def optimize_for_chinese(image: np.ndarray) -> np.ndarray:
"""
针对中文文本的图像优化处理
Args:
image: 输入图像的NumPy数组
Returns:
优化后的图像NumPy数组
"""
# 确保图像不为空
if image is None or image.size == 0:
raise ValueError("输入图像为空")
# 转换为灰度图
if len(image.shape) == 3:
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
else:
gray = image.copy()
# 1. 自适应二值化 - 对于不同分辨率和对比度的图像很有效
binary = cv2.adaptiveThreshold(
gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY_INV, 25, 15
)
# 2. 对二值化图像进行形态学操作,使文字更清晰
# 创建一个长方形核,水平方向较小,垂直方向较大
# 这有助于保持中文字符的笔画连接
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 3))
# 闭运算 - 用于连接断开的部分,尤其对于中文细笔画非常有效
morph = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel, iterations=1)
# 3. 降噪 - 去除小的噪点
# 查找所有轮廓
contours, _ = cv2.findContours(morph, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# 创建一个空白图像
cleaned = np.zeros_like(morph)
# 筛选轮廓 - 保留较大的轮廓(文字),去除较小的轮廓(噪点)
min_contour_area = 20 # 最小轮廓面积,可以根据实际情况调整
for contour in contours:
if cv2.contourArea(contour) > min_contour_area:
cv2.drawContours(cleaned, [contour], -1, 255, -1)
# 4. 反转回来 - 因为OCR通常需要黑底白字
cleaned_inverted = cv2.bitwise_not(cleaned)
# 5. 对图像进行锐化,提高轮廓清晰度
# 创建一个锐化核
sharpen_kernel = np.array([[-1,-1,-1],
[-1, 9,-1],
[-1,-1,-1]])
sharpened = cv2.filter2D(cleaned_inverted, -1, sharpen_kernel)
# 6. 确保图像完全二值化
_, final = cv2.threshold(sharpened, 127, 255, cv2.THRESH_BINARY)
return final
def optimize_for_chinese_advanced(image: np.ndarray) -> List[np.ndarray]:
"""
针对中文文本的多种高级图像优化处理,返回多种优化结果
Args:
image: 输入图像的NumPy数组
Returns:
优化后的图像NumPy数组列表
"""
# 确保图像不为空
if image is None or image.size == 0:
raise ValueError("输入图像为空")
# 转换为灰度图
if len(image.shape) == 3:
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
else:
gray = image.copy()
results = []
# 方法1: 自适应二值化基础版
binary1 = cv2.adaptiveThreshold(
gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY, 25, 15
)
results.append(binary1)
# 方法2: 自适应二值化增强版
binary2 = cv2.adaptiveThreshold(
gray, 255, cv2.ADAPTIVE_THRESH_MEAN_C,
cv2.THRESH_BINARY, 35, 15
)
results.append(binary2)
# 方法3: Otsu二值化
_, binary3 = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
results.append(binary3)
# 方法4: 应用高斯模糊后再Otsu二值化
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
_, binary4 = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
results.append(binary4)
# 方法5: 增强对比度后的二值化
# 创建CLAHE对象
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
# 应用CLAHE增强对比度
contrast_enhanced = clahe.apply(gray)
_, binary5 = cv2.threshold(contrast_enhanced, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
results.append(binary5)
# 方法6: 使用基本优化函数
basic_optimized = optimize_for_chinese(image)
results.append(basic_optimized)
# 方法7: 形态学操作
# 先进行二值化
_, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
# 创建一个椭圆核
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
# 开运算去除噪点
opened = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations=1)
# 闭运算连接断开的笔画
morph = cv2.morphologyEx(opened, cv2.MORPH_CLOSE, kernel, iterations=1)
results.append(morph)
# 方法8: 锐化处理
sharpen_kernel = np.array([[-1,-1,-1],
[-1, 9,-1],
[-1,-1,-1]])
sharpened = cv2.filter2D(gray, -1, sharpen_kernel)
_, binary8 = cv2.threshold(sharpened, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
results.append(binary8)
# 方法9: 边缘增强
# 先进行高斯模糊
blurred = cv2.GaussianBlur(gray, (0, 0), 3)
# 使用unsharp masking技术
edge_enhanced = cv2.addWeighted(gray, 1.5, blurred, -0.5, 0)
_, binary9 = cv2.threshold(edge_enhanced, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
results.append(binary9)
return results
def detect_and_correct_skew(image: np.ndarray, angle_range: Tuple[int, int] = (-15, 15), angle_step: float = 0.5) -> np.ndarray:
"""
检测并修正图像中文本的倾斜
Args:
image: 输入图像的NumPy数组
angle_range: 搜索倾斜角度的范围
angle_step: 角度搜索的步长
Returns:
修正倾斜后的图像
"""
# 确保图像不为空
if image is None or image.size == 0:
raise ValueError("输入图像为空")
# 转换为灰度图
if len(image.shape) == 3:
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
else:
gray = image.copy()
# 二值化
_, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
# 计算每个旋转角度的像素和
scores = []
angles = np.arange(angle_range[0], angle_range[1] + angle_step, angle_step)
# 获取中心点
center = (binary.shape[1] // 2, binary.shape[0] // 2)
for angle in angles:
# 旋转图像
rotation_matrix = cv2.getRotationMatrix2D(center, angle, 1.0)
rotated = cv2.warpAffine(binary, rotation_matrix, (binary.shape[1], binary.shape[0]),
flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_CONSTANT, borderValue=0)
# 计算每行像素和
row_sums = np.sum(rotated, axis=1)
# 计算方差作为评分
score = np.var(row_sums)
scores.append(score)
# 找到最佳角度
best_angle_index = np.argmax(scores)
best_angle = angles[best_angle_index]
# 旋转原始图像
rotation_matrix = cv2.getRotationMatrix2D(center, best_angle, 1.0)
rotated_image = cv2.warpAffine(image, rotation_matrix, (image.shape[1], image.shape[0]),
flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_CONSTANT)
return rotated_image
def process_image_for_chinese_ocr(image: np.ndarray, correct_skew: bool = True) -> Dict[str, Any]:
"""
完整的中文OCR图像预处理流程
Args:
image: 输入图像的NumPy数组
correct_skew: 是否进行倾斜校正
Returns:
字典,包含多种处理结果和原始图像
"""
result = {
'original': image.copy()
}
# 步骤1: 倾斜校正(如果需要)
if correct_skew:
corrected = detect_and_correct_skew(image)
result['deskewed'] = corrected
# 使用校正后的图像进行后续处理
working_image = corrected
else:
working_image = image
# 步骤2: 应用基本的中文优化
optimized = optimize_for_chinese(working_image)
result['optimized'] = optimized
# 步骤3: 应用高级优化,获取多种处理结果
advanced_results = optimize_for_chinese_advanced(working_image)
for i, img in enumerate(advanced_results):
result[f'method_{i+1}'] = img
return result
if __name__ == "__main__":
# 简单的测试代码
import sys
if len(sys.argv) > 1:
input_image_path = sys.argv[1]
output_dir = sys.argv[2] if len(sys.argv) > 2 else "."
# 读取图像
image = cv2.imread(input_image_path)
if image is None:
print(f"无法读取图像: {input_image_path}")
sys.exit(1)
# 处理图像
result = process_image_for_chinese_ocr(image)
# 保存结果
cv2.imwrite(f"{output_dir}/original.png", result['original'])
cv2.imwrite(f"{output_dir}/optimized.png", result['optimized'])
if 'deskewed' in result:
cv2.imwrite(f"{output_dir}/deskewed.png", result['deskewed'])
for i in range(1, 10):
key = f'method_{i}'
if key in result:
cv2.imwrite(f"{output_dir}/{key}.png", result[key])
print(f"处理完成,结果已保存到 {output_dir}")
else:
print("使用方法: python _optimize_for_chinese.py <输入图像路径> [输出目录]")

1897
cxs/cxs_doc_cleaner.py Normal file

File diff suppressed because it is too large Load Diff

1173
cxs/cxs_pdf_cleaner.py Normal file

File diff suppressed because it is too large Load Diff

961
cxs/cxs_table_processor.py Normal file
View File

@@ -0,0 +1,961 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import re
from typing import List, Dict, Any, Optional
import os
from docx.oxml import parse_xml
from docx.oxml.ns import nsdecls
# 自定义TableData类用于存储表格数据
class TableData:
def __init__(self):
"""
初始化表格数据结构
"""
self.rows = []
self.style = None
self.columns = [] # 添加列属性
def cell(self, row_idx: int, col_idx: int) -> Dict[str, Any]:
"""
获取表格单元格
Args:
row_idx: 行索引
col_idx: 列索引
Returns:
Dict: 单元格数据
"""
try:
# 首先检查行索引是否有效
if row_idx < 0 or row_idx >= len(self.rows):
return {'text': '', 'gridspan': 1, 'vmerge': None}
# 然后检查列索引是否有效
if col_idx < 0 or col_idx >= len(self.rows[row_idx]):
return {'text': '', 'gridspan': 1, 'vmerge': None}
# 如果需要,进行额外的安全检查
cell = self.rows[row_idx][col_idx]
if not isinstance(cell, dict):
print(f"警告:单元格数据格式错误 [{row_idx},{col_idx}]")
return {'text': str(cell) if cell is not None else '', 'gridspan': 1, 'vmerge': None}
return cell
except Exception as e:
print(f"获取单元格时出错 [{row_idx},{col_idx}]: {str(e)}")
return {'text': '', 'gridspan': 1, 'vmerge': None}
class TableProcessor:
def __init__(self):
"""
初始化表格处理器
"""
print("初始化表格处理器")
def _extract_table_row(self, row_element, namespace):
"""
提取表格行数据,增强的表格行处理
Args:
row_element: 行元素
namespace: XML命名空间
Returns:
List: 行数据列表
"""
row = []
try:
# 处理单元格
for cell_element in row_element.findall('.//w:tc', namespaces=namespace):
cell_text = ''
# 提取单元格中的所有文本
for paragraph in cell_element.findall('.//w:p', namespaces=namespace):
for run in paragraph.findall('.//w:t', namespaces=namespace):
if run.text:
cell_text += run.text
# 在段落后添加换行符
cell_text += '\n'
# 移除末尾换行
cell_text = cell_text.rstrip('\n')
# 检查单元格合并属性
gridspan = self._get_gridspan_value(cell_element)
vmerge = self._get_vmerge_value(cell_element)
# 创建单元格数据
cell = {
'text': cell_text,
'gridspan': gridspan,
'vmerge': vmerge
}
row.append(cell)
# 如果行为空,创建至少一个空单元格
if not row:
row.append({'text': '', 'gridspan': 1, 'vmerge': None})
return row
except Exception as e:
print(f"提取表格行数据时出错: {str(e)}")
# 返回至少有一个单元格的行
return [{'text': '', 'gridspan': 1, 'vmerge': None}]
def _preprocess_table(self, element, namespace):
"""
对表格进行预处理,加强特殊表格的识别能力
Args:
element: 表格元素
namespace: XML命名空间
Returns:
TableData: 预处理后的表格数据
"""
table = TableData()
# 检查并处理表格行
rows_elements = element.findall('.//w:tr', namespaces=namespace)
# 表格为空的特殊处理
if not rows_elements:
# 尝试寻找更深层次的表格元素,可能是嵌套在其他元素中的表格
nested_rows = element.findall('.//*//w:tr', namespaces=namespace)
if nested_rows:
rows_elements = nested_rows
print(f"已找到嵌套表格行:{len(rows_elements)}")
else:
# 创建一个默认行,避免表格为空
print("未找到表格行,创建默认行")
table.rows.append([{'text': '', 'gridspan': 1, 'vmerge': None}])
return table
# 处理每一行
for row_element in rows_elements:
row = self._extract_table_row(row_element, namespace)
table.rows.append(row)
# 如果表格为空,创建默认行
if not table.rows:
table.rows.append([{'text': '', 'gridspan': 1, 'vmerge': None}])
# 分析表格,确定列数
max_cols = 0
for row in table.rows:
# 计算考虑gridspan的实际列数
effective_cols = sum(cell.get('gridspan', 1) for cell in row)
max_cols = max(max_cols, effective_cols)
# 确保每行都有足够的列
for i, row in enumerate(table.rows):
current_cols = sum(cell.get('gridspan', 1) for cell in row)
if current_cols < max_cols:
# 添加空单元格来填充行
padding_cells = max_cols - current_cols
for _ in range(padding_cells):
row.append({'text': '', 'gridspan': 1, 'vmerge': None})
# 设置列索引
table.columns = [i for i in range(max_cols)]
# 增强对垂直合并单元格的处理
self._enhance_vertical_merges(table)
# 额外执行一次垂直合并内容传播,修复复杂表格中的合并单元格
self._propagate_vertical_merges(table)
return table
def _propagate_vertical_merges(self, table: TableData):
"""
专门处理复杂表格中的垂直合并单元格,向下传播内容
Args:
table: TableData对象
"""
rows = len(table.rows)
cols = len(table.columns) if table.columns else 0
if rows <= 1 or cols == 0:
return
# 创建一个矩阵记录每个单元格位置的内容
matrix = []
for i in range(rows):
row = []
for j in range(cols):
try:
if j < len(table.rows[i]):
cell = table.rows[i][j]
row.append(cell.get('text', '').strip())
else:
row.append('')
except (IndexError, KeyError):
row.append('') # 防止索引越界
matrix.append(row)
# 对每一列进行垂直合并检查
for j in range(cols):
# 从上到下传播非空内容
last_non_empty = None
last_non_empty_idx = -1
for i in range(rows):
try:
# 安全访问表格单元格
current_text = ''
if j < len(table.rows[i]):
cell = table.rows[i][j]
current_text = cell.get('text', '').strip()
# 如果当前单元格为空,但上方有非空单元格,考虑垂直合并
if not current_text and last_non_empty:
# 检查这是否可能是垂直合并
if i - last_non_empty_idx <= 3: # 限制垂直检查范围,避免过度填充
# 根据上下文判断是否真的是合并单元格
# 1. 检查该列其他单元格是否有相似模式
pattern_match = False
for k in range(rows):
if k != i and k != last_non_empty_idx:
# 查找相似模式:空单元格下方接非空单元格
if k > 0 and not matrix[k-1][j] and matrix[k][j]:
pattern_match = True
break
# 2. 检查第一列特殊情况 - 可能是分类表
is_first_columns = j < 2 # 前两列更可能是分类信息
if pattern_match or is_first_columns:
if j < len(table.rows[i]):
# 安全地更新当前单元格
table.rows[i][j]['text'] = last_non_empty
table.rows[i][j]['is_inferred_merge'] = True
matrix[i][j] = last_non_empty # 更新矩阵
print(f"传播合并内容到位置 [{i},{j}]: {last_non_empty[:20]}...")
# 更新最后一个非空单元格
if current_text:
last_non_empty = current_text
last_non_empty_idx = i
except Exception as e:
print(f"处理垂直合并传播时出错 [{i},{j}]: {str(e)}")
# 第二轮:处理常见的分类表格模式(第一列相同值表示同一类别)
for j in range(min(2, cols)): # 只处理前两列
# 查找具有相同值的行组
groups = {}
for i in range(rows):
try:
if j < len(table.rows[i]):
value = table.rows[i][j].get('text', '').strip()
if value:
if value not in groups:
groups[value] = []
groups[value].append(i)
except Exception as e:
print(f"分组时出错 [{i},{j}]: {str(e)}")
# 处理每个组
for value, indices in groups.items():
if len(indices) >= 2: # 至少有两行具有相同值
# 检查这些行之间是否有空行
indices.sort()
for idx in range(len(indices) - 1):
start_row = indices[idx]
end_row = indices[idx + 1]
# 如果两行不相邻,检查中间行
if end_row - start_row > 1:
for mid_row in range(start_row + 1, end_row):
try:
# 检查中间行的单元格是否为空
if j < len(table.rows[mid_row]):
mid_cell = table.rows[mid_row][j]
if not mid_cell.get('text', '').strip():
# 这可能是被合并的单元格,填充内容
mid_cell['text'] = value
mid_cell['is_inferred_merge'] = True
print(f"填充中间行合并单元格 [{mid_row},{j}]: {value[:20]}...")
except Exception as e:
print(f"填充中间行时出错 [{mid_row},{j}]: {str(e)}")
def _enhance_vertical_merges(self, table: TableData):
"""
增强对垂直合并单元格的处理
处理逻辑包括:
1. 检查并处理第一列和第二列的特殊情况
2. 在表格中识别内容相似的单元格
Args:
table: TableData对象
"""
rows = len(table.rows)
cols = len(table.columns) if table.columns else 0
if rows <= 1 or cols == 0:
return
# 检查第一列和第二列的特殊情况
for j in range(min(2, cols)): # 检查前两列,因为合并单元格可能出现在这两列中
# 检查是否有垂直合并单元格
for i in range(1, rows):
try:
if j < len(table.rows[i]): # 确保索引有效
cell = table.rows[i][j]
# 如果单元格为空且没有标记为合并单元格,检查上面行的内容
if not cell.get('text', '').strip() and cell.get('vmerge') is None:
# 安全访问上一行
if j < len(table.rows[i-1]):
prev_cell = table.rows[i-1][j]
if prev_cell.get('text', '').strip():
# 如果上面行有内容,这可能是合并单元格
print(f"在位置 [{i},{j}] 检测到可能的垂直合并单元格")
# 将内容复制到当前单元格
cell['text'] = prev_cell['text']
cell['is_inferred_merge'] = True # 标记为推导出的合并单元格
except IndexError as e:
print(f"增强垂直合并处理索引错误 [{i},{j}]: {str(e)}")
except Exception as e:
print(f"增强垂直合并处理一般错误 [{i},{j}]: {str(e)}")
# 特殊情况:检查分类表格中的模式
try:
# 在分类表格中,同一列的内容如果重复出现,可能是合并单元格
content_groups = self._identify_content_groups(table, j)
# 处理内容相似的单元格
for group_indices in content_groups:
if len(group_indices) > 1: # 如果有多个相同的单元格
if group_indices[0] < len(table.rows) and j < len(table.rows[group_indices[0]]):
group_text = table.rows[group_indices[0]][j].get('text', '')
if group_text.strip(): # 如果单元格有内容
print(f"在列 {j} 中发现可能的内容合并组: {group_indices}")
# 将这些单元格标记为具有相同的内容
for idx in group_indices:
if idx < len(table.rows) and j < len(table.rows[idx]):
table.rows[idx][j]['content_group'] = group_indices
except Exception as e:
print(f"处理内容组时出错 [列 {j}]: {str(e)}")
def _identify_content_groups(self, table: TableData, col_idx: int) -> List[List[int]]:
"""
根据内容相似性识别合并单元格
Args:
table: TableData对象
col_idx: 要分析的列索引
Returns:
List[List[int]]: 可能合并单元格的行索引组
"""
rows = len(table.rows)
# 存储每个唯一内容的所有行索引
content_groups = {}
for i in range(rows):
try:
if col_idx < len(table.rows[i]):
cell_text = table.rows[i][col_idx].get('text', '').strip()
if cell_text:
if cell_text not in content_groups:
content_groups[cell_text] = []
content_groups[cell_text].append(i)
except IndexError:
# 安全跳过索引越界情况
continue
except Exception as e:
print(f"识别内容组时出错 [{i},{col_idx}]: {str(e)}")
# 返回包含多个行索引的组
return [indices for text, indices in content_groups.items() if len(indices) > 1]
def _is_valid_table(self, table: TableData) -> bool:
"""
检查表格是否有效(至少有一行一列且含有有意义的内容)
Args:
table: TableData对象
Returns:
bool: 表格是否有效
"""
try:
# 检查表格尺寸
rows = len(table.rows)
cols = len(table.columns) if table.columns else 0
# 如果没有行或列,表格无效
if rows < 1 or cols < 1:
print(f"表格无效: 没有行或列 (行数={rows}, 列数={cols})")
return False
# 检查表格XML结构是否包含表格标记
# 此步骤可以简单检测表格是否有表格相关的XML标记
try:
# 以下逻辑是为了特殊处理可能被误判的表格
# 判断是否是特殊表格(如药品分类表)
first_cell_text = ""
if rows > 0 and len(table.rows[0]) > 0:
first_cell_text = table.cell(0, 0).get('text', '').strip()
# 检查首行首列是否包含特定文本模式(如编号、分类名称等)
# 这些模式暗示这可能是一个重要表格
special_patterns = [
r'^\d{2}-\d{2}', # 类似 01-01 的编码
r'^[一二三四五六七八九十]+级', # 中文级别(一级、二级等)
r'^\d+\.\d+', # 类似 1.1 的编号格式
r'类[别型]|分类|编码', # 包含分类相关词汇
r'\s*\d+', # 表格编号(如"表1"
r'产品|器械|设备|材料' # 常见医疗或药品分类术语
]
for pattern in special_patterns:
if re.search(pattern, first_cell_text):
print(f"检测到特殊表格模式: '{first_cell_text}',强制视为有效表格")
return True
except Exception as e:
# 特殊检测失败,继续常规检测
print(f"特殊表格检测时出错: {str(e)}")
# 计算表格中的有效内容
total_cells = 0
non_empty_cells = 0
total_text_length = 0
for i in range(rows):
for j in range(min(cols, len(table.rows[i]))): # 防止越界
total_cells += 1
cell_text = table.cell(i, j)['text'].strip()
if cell_text:
non_empty_cells += 1
total_text_length += len(cell_text)
# 计算非空单元格比例
non_empty_ratio = non_empty_cells / total_cells if total_cells > 0 else 0
# 表格行列数检查 - 如果行数或列数足够多,更可能是有效表格
has_multiple_rows = rows >= 3
has_multiple_cols = cols >= 3
# 实际单元格内容检查
# 进一步放宽标准,只要有内容就视为可能有效
is_meaningful = (
# 1. 标准条件至少有2个单元格有内容
non_empty_cells >= 2 or
# 2. 极低门槛至少有1个单元格有内容且文本长度>=1个字符
(non_empty_cells > 0 and total_text_length >= 1) or
# 3. 表格足够大至少有3行3列可能是重要表格
(has_multiple_rows and has_multiple_cols) or
# 4. 非空率较高:即使单元格少,但如果填充率高,也可能是有意义的
(non_empty_ratio >= 0.5 and total_text_length > 0)
)
if not is_meaningful:
print(f"表格无效: 内容不足 (非空单元格={non_empty_cells}/{total_cells}, 文本长度={total_text_length})")
return is_meaningful
except Exception as e:
print(f"警告:检查表格有效性时出错: {str(e)}")
import traceback
traceback.print_exc()
# 出错时默认认为有效,避免丢失潜在有用的表格
return True
def _extract_plain_text_from_table(self, table: TableData) -> str:
"""
从表格中提取纯文本,用于将无效表格作为普通文本处理
Args:
table: docx表格对象
Returns:
str: 表格内容的纯文本表示
"""
try:
text_parts = []
for row in table.rows:
for cell in row:
cell_text = cell['text'].strip()
if cell_text:
text_parts.append(cell_text)
return " ".join(text_parts)
except Exception as e:
print(f"警告:从表格提取文本时出错: {str(e)}")
return "【表格文本提取失败】"
def _convert_table_to_text(self, table: TableData) -> str:
"""
将表格转换为文本格式,使用简化易读的表格表示
Args:
table: TableData对象
Returns:
str: 表格的文本表示
"""
try:
# 获取表格的行数和列数
rows = len(table.rows)
cols = len(table.columns) if table.columns else 0
if rows == 0 or cols == 0:
return "【空表格】"
# 构建一个完整的表格矩阵,处理合并单元格
matrix = []
for i in range(rows):
row = [""] * cols
matrix.append(row)
# 首先安全地处理所有已知的单元格内容
for i in range(rows):
for j in range(cols):
try:
if j < len(table.rows[i]):
cell = table.rows[i][j]
text = cell.get('text', '').strip()
matrix[i][j] = text
except IndexError:
continue # 跳过索引越界
# 填充矩阵,处理合并单元格
for i in range(rows):
j = 0
while j < cols:
try:
if j >= len(table.rows[i]):
j += 1
continue
cell = table.rows[i][j]
text = cell.get('text', '').strip()
# 特殊处理:检查是否有内容组标记
content_group = cell.get('content_group', [])
if content_group:
# 如果这是内容组的一部分
if i in content_group and content_group[0] < len(table.rows) and j < len(table.rows[content_group[0]]):
group_text = table.rows[content_group[0]][j].get('text', '').strip()
if group_text:
text = group_text
# 处理水平合并(gridspan)
gridspan = cell.get('gridspan', 1)
# 处理垂直合并(vmerge)和推断的合并
if cell.get('vmerge') == 'continue' or cell.get('is_inferred_merge'):
# 如果是继续合并的单元格或推断的合并,使用当前已有的文本
if not text:
# 如果当前单元格文本为空,尝试从上面行查找
for prev_i in range(i-1, -1, -1):
if prev_i < len(table.rows) and j < len(table.rows[prev_i]):
prev_cell = table.rows[prev_i][j]
prev_text = prev_cell.get('text', '').strip()
if prev_text:
text = prev_text
break
# 填充当前单元格
matrix[i][j] = text
# 处理水平合并,将内容复制到被合并的单元格
for k in range(1, gridspan):
if j + k < cols:
matrix[i][j+k] = text
# 如果这是垂直合并的起始单元格,复制内容到下面被合并的单元格
if text and (cell.get('vmerge') == 'restart' or not cell.get('vmerge')):
for next_i in range(i+1, rows):
if next_i < len(table.rows) and j < len(table.rows[next_i]):
next_cell = table.rows[next_i][j]
if next_cell.get('vmerge') == 'continue' or not next_cell.get('text', '').strip():
# 复制到下面被合并的单元格
matrix[next_i][j] = text
# 处理水平合并
next_gridspan = next_cell.get('gridspan', 1)
for k in range(1, next_gridspan):
if j + k < cols:
matrix[next_i][j+k] = text
else:
break
j += max(1, gridspan)
except IndexError as e:
print(f"表格转文本处理索引错误 [{i},{j}]: {str(e)}")
j += 1 # 确保进度
except Exception as e:
print(f"表格转文本一般错误 [{i},{j}]: {str(e)}")
j += 1
# 再次处理第一列和第二列中的空白单元格 - 增强垂直合并处理
for j in range(min(3, cols)): # 扩展到前三列
# 自上而下扫描
last_content = ""
for i in range(rows):
if matrix[i][j]:
last_content = matrix[i][j]
elif last_content and i > 0 and matrix[i-1][j]:
# 如果当前为空且上一行不为空,填充内容
matrix[i][j] = last_content
# 自下而上扫描,填充孤立的空单元格
for i in range(rows-2, 0, -1): # 从倒数第二行开始向上
if not matrix[i][j] and matrix[i-1][j] and matrix[i+1][j] and matrix[i-1][j] == matrix[i+1][j]:
# 如果当前为空且上下行内容相同,填充内容
matrix[i][j] = matrix[i-1][j]
# 如果有表头,提取它们
headers = matrix[0] if rows > 0 else ["" + str(j+1) for j in range(cols)]
# 确保表头不为空
for j in range(cols):
if not headers[j]:
headers[j] = "" + str(j+1)
# 构建结构化输出 - 使用统一简化格式
result = []
result.append("表格内容(简化格式):")
# 添加表头行
header_line = []
# 计算每列最大宽度
col_widths = [0] * cols
for j in range(cols):
col_widths[j] = max(len(headers[j]), col_widths[j])
# 计算数据行的宽度
for i in range(1, rows):
for j in range(cols):
if matrix[i][j]:
col_widths[j] = max(col_widths[j], len(matrix[i][j]))
# 加入表头与分隔线
for j in range(cols):
header_line.append(headers[j].ljust(col_widths[j]))
result.append(" | ".join(header_line))
# 添加分隔线
separator = []
for j in range(cols):
separator.append("-" * col_widths[j])
result.append(" | ".join(separator))
# 添加数据行
for i in range(1, rows):
row_line = []
has_content = False
for j in range(cols):
cell_text = matrix[i][j]
if cell_text:
has_content = True
# 始终添加单元格内容,即使为空
row_line.append(cell_text.ljust(col_widths[j]))
if has_content:
result.append(" | ".join(row_line))
return "\n".join(result)
except Exception as e:
print(f"警告:处理表格时出错: {str(e)}")
import traceback
traceback.print_exc()
return "【表格处理失败】"
def _convert_table_to_markdown(self, table: TableData) -> str:
"""
将表格转换为Markdown格式使用简化易读的表格表示
Args:
table: TableData对象
Returns:
str: 表格的Markdown表示
"""
try:
# 获取表格的行数和列数
rows = len(table.rows)
cols = len(table.columns) if table.columns else 0
if rows == 0 or cols == 0:
return "| 空表格 |"
# 构建一个完整的表格矩阵,处理合并单元格
matrix = []
for i in range(rows):
row = [""] * cols
matrix.append(row)
# 首先安全地处理所有已知的单元格内容
for i in range(rows):
for j in range(cols):
try:
if j < len(table.rows[i]):
cell = table.rows[i][j]
text = cell.get('text', '').strip()
matrix[i][j] = text
except IndexError:
continue # 跳过索引越界
# 填充矩阵,处理合并单元格
for i in range(rows):
j = 0
while j < cols:
try:
if j >= len(table.rows[i]):
j += 1
continue
cell = table.rows[i][j]
text = cell.get('text', '').strip()
# 特殊处理:检查是否有内容组标记
content_group = cell.get('content_group', [])
if content_group and i in content_group:
# 如果这是内容组的一部分,保证内容的一致性
if content_group[0] < len(table.rows) and j < len(table.rows[content_group[0]]):
group_text = table.rows[content_group[0]][j].get('text', '').strip()
if group_text:
text = group_text
# 处理水平合并(gridspan)
gridspan = cell.get('gridspan', 1)
# 处理垂直合并(vmerge)和推断的合并
if cell.get('vmerge') == 'continue' or cell.get('is_inferred_merge'):
# 如果是继续合并的单元格或推断的合并,使用当前已有的文本
if not text:
# 如果当前单元格文本为空,尝试从上面行查找
for prev_i in range(i-1, -1, -1):
if prev_i < len(table.rows) and j < len(table.rows[prev_i]):
prev_cell = table.rows[prev_i][j]
prev_text = prev_cell.get('text', '').strip()
if prev_text:
text = prev_text
break
# 填充当前单元格
matrix[i][j] = text
# 处理水平合并,将内容复制到被合并的单元格
for k in range(1, gridspan):
if j + k < cols:
matrix[i][j+k] = text
# 如果这是垂直合并的起始单元格,复制内容到下面被合并的单元格
if text and (cell.get('vmerge') == 'restart' or not cell.get('vmerge')):
for next_i in range(i+1, rows):
if next_i < len(table.rows) and j < len(table.rows[next_i]):
next_cell = table.rows[next_i][j]
if next_cell.get('vmerge') == 'continue' or not next_cell.get('text', '').strip():
# 复制到下面被合并的单元格
matrix[next_i][j] = text
# 处理水平合并
next_gridspan = next_cell.get('gridspan', 1)
for k in range(1, next_gridspan):
if j + k < cols:
matrix[next_i][j+k] = text
else:
break
j += max(1, gridspan)
except Exception as e:
print(f"Markdown表格处理错误 [{i},{j}]: {str(e)}")
j += 1
# 再次处理第一列中的空白单元格 - 增强垂直合并处理
for j in range(min(3, cols)): # 扩展到前三列
# 自上而下扫描
last_content = ""
for i in range(rows):
if matrix[i][j]:
last_content = matrix[i][j]
elif last_content and i > 0 and matrix[i-1][j]:
# 如果当前为空且上一行不为空,填充内容
matrix[i][j] = last_content
# 确保表头不为空
headers = matrix[0] if rows > 0 else []
for j in range(cols):
if j >= len(headers) or not headers[j]:
headers.append("" + str(j+1))
# 构建Markdown表格
markdown_rows = []
# 添加表头行
header_row = "| " + " | ".join(headers) + " |"
markdown_rows.append(header_row)
# 添加分隔行
separator = "| " + " | ".join(["---"] * cols) + " |"
markdown_rows.append(separator)
# 添加数据行
for i in range(1, rows):
row_data = []
has_content = False
for j in range(cols):
cell_text = matrix[i][j]
if cell_text:
has_content = True
row_data.append(cell_text)
if has_content:
markdown_rows.append("| " + " | ".join(row_data) + " |")
return "\n".join(markdown_rows)
except Exception as e:
print(f"警告处理Markdown表格时出错: {str(e)}")
import traceback
traceback.print_exc()
return "| 表格处理失败 |"
def _extract_table_text(self, table: TableData) -> str:
"""
提取表格中的文本内容,返回格式化的文本表示
Args:
table: docx表格对象
Returns:
str: 表格内容的文本表示
"""
# 调用优化后的表格处理函数,确保合并单元格被正确处理
return self._convert_table_to_text(table)
def _convert_small_table_to_text(self, table: TableData) -> str:
"""
将小型表格转换为更简洁的文本格式
Args:
table: TableData对象
Returns:
str: 表格的文本表示
"""
rows = len(table.rows)
cols = len(table.columns) if table.columns else 0
if rows == 0 or cols == 0:
return "【空表格】"
# 提取所有单元格文本
cell_texts = []
for i in range(rows):
row_texts = []
for j in range(min(cols, len(table.rows[i]))):
cell_text = table.cell(i, j)['text'].strip().replace('\n', ' ')
row_texts.append(cell_text)
cell_texts.append(row_texts)
# 计算每列的最大宽度
col_widths = [0] * cols
for i in range(rows):
for j in range(len(cell_texts[i])):
col_widths[j] = max(col_widths[j], len(cell_texts[i][j]))
# 生成表格文本
result = []
# 添加表头
header_row = cell_texts[0]
header_line = []
for j, text in enumerate(header_row):
width = min(col_widths[j], 30) # 限制最大宽度
header_line.append(text.ljust(width))
result.append(" | ".join(header_line))
# 添加分隔线
separator = []
for j in range(cols):
width = min(col_widths[j], 30)
separator.append("-" * width)
result.append(" | ".join(separator))
# 添加数据行
for i in range(1, rows):
row_line = []
for j, text in enumerate(cell_texts[i]):
width = min(col_widths[j], 30) # 限制最大宽度
row_line.append(text.ljust(width))
result.append(" | ".join(row_line))
return "\n".join(result)
def _get_vmerge_value(self, cell_element) -> str:
"""
获取单元格的垂直合并属性
Args:
cell_element: 单元格元素
Returns:
str: 垂直合并属性值
"""
vmerge = cell_element.xpath('.//w:vMerge')
if vmerge:
return vmerge[0].get(self._qn('w:val'), 'continue')
return None
def _get_gridspan_value(self, cell_element) -> int:
"""
获取单元格的水平合并数量
Args:
cell_element: 单元格元素
Returns:
int: 水平合并的列数
"""
try:
gridspan = cell_element.xpath('.//w:gridSpan')
if gridspan and gridspan[0].get(self._qn('w:val')):
return int(gridspan[0].get(self._qn('w:val')))
except (ValueError, TypeError, AttributeError) as e:
print(f"警告获取gridspan值时出错: {str(e)}")
return 1 # 默认返回1表示没有合并
def _get_vertical_span(self, table: TableData, start_row: int, col: int) -> int:
"""
计算垂直合并的行数
Args:
table: 表格对象
start_row: 起始行
col: 列号
Returns:
int: 垂直合并的行数
"""
span = 1
for i in range(start_row + 1, len(table.rows)):
cell = table.cell(i, col)
if cell.get('vmerge') == 'continue':
span += 1
else:
break
return span
def _qn(self, tag: str) -> str:
"""
将标签转换为带命名空间的格式
Args:
tag: 原始标签
Returns:
str: 带命名空间的标签
"""
prefix = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
return prefix + tag

View File

@@ -0,0 +1,183 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import re
import json
import argparse
def split_text_into_paragraphs(text):
"""
将连续文本智能分段
策略:
1. 识别表格标记,将表格内容作为独立段落处理
2. 对普通文本按照语义和长度适当分段约500字/段)
3. 确保分段不破坏语义完整性
"""
# 清理文本中可能存在的多余空格
text = re.sub(r'\s+', ' ', text).strip()
# 识别表格范围,表格以"表格 N 开始"和"表格 N 结束"标记
table_pattern = re.compile(r'表格\s*\d+\s*开始(.*?)表格\s*\d+\s*结束', re.DOTALL)
# 使用表格标记分割文本
parts = []
last_end = 0
for match in table_pattern.finditer(text):
# 添加表格前的文本
if match.start() > last_end:
parts.append(("text", text[last_end:match.start()]))
# 获取表格内容(去掉表格标记)
table_content = match.group(1).strip()
parts.append(("table", table_content))
last_end = match.end()
# 添加最后一个表格之后的文本
if last_end < len(text):
parts.append(("text", text[last_end:]))
# 如果没有找到表格,则整个文本作为一个文本片段
if not parts:
parts = [("text", text)]
# 对文本段落进行处理
final_paragraphs = []
# 可能表示段落边界或重要语义分割点的标记
paragraph_markers = [
r'^第.{1,3}章',
r'^第.{1,3}节',
r'^[一二三四五六七八九十][、.\s]',
r'^\d+[、.\s]',
r'^[IVX]+[、.\s]',
r'^附录',
r'^前言',
r'^目录',
r'^摘要',
r'^引言',
r'^结论',
r'^参考文献'
]
marker_pattern = re.compile('|'.join(paragraph_markers))
# 按句子分割的分隔符
sentence_separators = r'([。!?\!\?])'
# 目标段落长度(字符数)
target_length = 500
# 最小段落长度阈值
min_length = 100
# 最大段落长度阈值
max_length = 800
for part_type, content in parts:
# 如果是表格内容,直接添加为独立段落
if part_type == "table":
final_paragraphs.append(content)
continue
# 处理普通文本
# 按句子分割文本
sentences = re.split(sentence_separators, content)
# 将分割后的句子和标点符号重新组合
sentence_list = []
for i in range(0, len(sentences)-1, 2):
if i+1 < len(sentences):
sentence_list.append(sentences[i] + sentences[i+1])
else:
sentence_list.append(sentences[i])
# 如果最后一个元素不是句子结束符,添加它
if len(sentences) % 2 == 1:
if sentences[-1]:
sentence_list.append(sentences[-1])
# 构建段落
current_para = ""
for sentence in sentence_list:
# 检查是否是段落标记的开始
is_marker = marker_pattern.search(sentence)
# 如果当前段落已经足够长,或者遇到段落标记,则开始新段落
if ((len(current_para) >= target_length and len(current_para) + len(sentence) > max_length) or
(is_marker and current_para)):
if current_para.strip():
final_paragraphs.append(current_para.strip())
current_para = sentence
else:
current_para += sentence
# 添加最后一个段落
if current_para.strip():
final_paragraphs.append(current_para.strip())
# 对段落进行后处理,合并过短的段落
processed_paragraphs = []
temp_para = ""
for para in final_paragraphs:
if len(para) < min_length:
# 如果段落太短,尝试与临时段落合并
if temp_para:
temp_para += " " + para
else:
temp_para = para
else:
# 如果有临时段落,先处理它
if temp_para:
# 如果临时段落也很短,合并到当前段落
if len(temp_para) < min_length:
para = temp_para + " " + para
else:
processed_paragraphs.append(temp_para)
temp_para = ""
processed_paragraphs.append(para)
# 处理最后可能剩余的临时段落
if temp_para:
if processed_paragraphs and len(temp_para) < min_length:
processed_paragraphs[-1] += " " + temp_para
else:
processed_paragraphs.append(temp_para)
return processed_paragraphs
def save_to_json(paragraphs, output_file):
"""将段落保存为JSON格式"""
data = {
"total_paragraphs": len(paragraphs),
"paragraphs": paragraphs
}
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"成功将文本分成 {len(paragraphs)} 个段落并保存到 {output_file}")
def main():
parser = argparse.ArgumentParser(description="将连续文本智能分段并保存为JSON")
parser.add_argument("input_file", help="输入文本文件路径")
parser.add_argument("--output", "-o", default="paragraphs.json", help="输出JSON文件路径")
args = parser.parse_args()
# 读取输入文件
try:
with open(args.input_file, 'r', encoding='utf-8') as f:
text = f.read()
except Exception as e:
print(f"读取文件出错: {e}")
return
# 分段
paragraphs = split_text_into_paragraphs(text)
# 保存为JSON
save_to_json(paragraphs, args.output)
if __name__ == "__main__":
main()

500
cxs/main.py Normal file
View File

@@ -0,0 +1,500 @@
from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
import os
import tempfile
from pathlib import Path
import uuid
import sys
import shutil
import glob
import asyncio
from typing import List
import json
import atexit
import re
import time # 添加time模块导入
# 获取当前文件所在目录的绝对路径
CURRENT_DIR = Path(os.path.dirname(os.path.abspath(__file__)))
if str(CURRENT_DIR) not in sys.path:
sys.path.append(str(CURRENT_DIR))
# 定义目录
TEMP_DIR = CURRENT_DIR / "temp"
STATIC_DIR = CURRENT_DIR / "static"
UPLOAD_DIR = TEMP_DIR / "uploads"
OUTPUT_DIR = TEMP_DIR / "outputs"
IMAGES_DIR = TEMP_DIR / "images" # 添加图片目录
# 确保所有必要的目录都存在
def ensure_directories():
"""确保所有必要的目录都存在且具有正确的权限"""
directories = [TEMP_DIR, STATIC_DIR, UPLOAD_DIR, OUTPUT_DIR, IMAGES_DIR]
for directory in directories:
try:
# 只在目录不存在时创建
if not directory.exists():
directory.mkdir(parents=True, exist_ok=True)
print(f"创建目录: {directory}")
# 在 Windows 上设置目录权限
if os.name == 'nt':
os.system(f'icacls "{directory}" /grant Everyone:(OI)(CI)F /T')
print(f"设置目录权限: {directory}")
except Exception as e:
print(f"创建目录失败 {directory}: {e}")
raise
def clean_temp_directories():
"""清理临时目录中的内容,但保留目录结构"""
try:
# 只清理临时目录中的内容
for directory in [UPLOAD_DIR, OUTPUT_DIR, IMAGES_DIR]:
if directory.exists():
print(f"清理目录: {directory}")
# 删除目录中的所有文件和子目录
for item in directory.glob("*"):
try:
if item.is_file():
item.unlink()
print(f"删除文件: {item}")
elif item.is_dir():
shutil.rmtree(str(item))
print(f"删除目录: {item}")
except Exception as e:
print(f"清理项目失败 {item}: {e}")
except Exception as e:
print(f"清理临时目录失败: {e}")
# 初始化目录
ensure_directories()
try:
from cxs_doc_cleaner import DocCleaner
except ImportError as e:
print(f"导入错误: {e}")
print(f"当前目录: {CURRENT_DIR}")
print(f"Python路径: {sys.path}")
raise
app = FastAPI(debug=True)
# 配置CORS
origins = ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["*"],
expose_headers=["*"]
)
# API 路由
@app.options("/api/upload/")
async def upload_options():
return {}
@app.post("/api/upload/")
async def upload_files(request: Request, files: List[UploadFile] = File(...)):
"""处理文件上传"""
print(f"收到上传请求: {request.method} {request.url}")
print(f"请求头: {request.headers}")
print(f"收到的文件数量: {len(files)}")
# 确保目录存在
ensure_directories()
# 检查是否有文件上传
if not files:
return {
"results": [],
"error": "没有上传文件"
}
results = []
cleaner = None
try:
# 创建文档处理器
cleaner = DocCleaner()
print("成功创建DocCleaner实例")
# 一次只处理一个文件
for index, file in enumerate(files):
print(f"\n开始处理第 {index + 1}/{len(files)} 个文件: {file.filename}")
temp_file = None
output_file = None
try:
# 保存上传的文件
temp_file, save_error = await save_uploaded_file(file)
if save_error or not temp_file:
print(f"保存文件失败: {save_error}")
results.append({
"filename": file.filename,
"status": "error",
"error": save_error or "保存文件失败",
"output_file": None,
"markdown_file": None,
"content": None
})
continue
print(f"文件已保存到临时位置: {temp_file}")
# 检查文件类型
file_ext = Path(file.filename).suffix.lower()
supported_formats = {
'.doc': 'word',
'.docx': 'word',
'.pdf': 'pdf',
'.html': 'html',
'.htm': 'html',
'.xls': 'excel',
'.xlsx': 'excel'
}
if file_ext not in supported_formats:
print(f"不支持的文件类型: {file_ext}")
results.append({
"filename": file.filename,
"status": "error",
"error": f"不支持的文件类型: {file_ext}",
"output_file": None,
"markdown_file": None,
"content": None
})
if temp_file.exists():
temp_file.unlink()
continue
# 确保文件存在
if not temp_file.exists():
print(f"错误:临时文件不存在: {temp_file}")
results.append({
"filename": file.filename,
"status": "error",
"error": "临时文件不存在",
"output_file": None,
"markdown_file": None,
"content": None
})
continue
print(f"开始处理文件内容: {temp_file}")
# 处理文件
output_file, text_content, markdown_file, error = await process_single_file(str(temp_file), cleaner)
# 处理完成后删除临时文件
if temp_file and temp_file.exists():
# 修改为使用安全删除函数
if safe_delete_file(temp_file):
print(f"删除临时文件: {temp_file}")
else:
print(f"警告:无法完全删除临时文件,但处理已成功完成: {temp_file}")
if error:
print(f"处理文件时出错: {error}")
results.append({
"filename": file.filename,
"status": "error",
"error": str(error),
"output_file": None,
"markdown_file": None,
"content": None
})
continue
# 创建响应文件
response_file = OUTPUT_DIR / f"response_{Path(file.filename).stem}_output.txt"
response_markdown = OUTPUT_DIR / f"response_{Path(file.filename).stem}_output.md"
print(f"创建响应文件: {response_file}")
print(f"创建Markdown响应文件: {response_markdown}")
if output_file and Path(output_file).exists():
shutil.copy2(output_file, str(response_file))
print(f"复制输出文件到响应文件: {output_file} -> {response_file}")
# 复制Markdown文件
if markdown_file and Path(markdown_file).exists():
shutil.copy2(markdown_file, str(response_markdown))
print(f"复制Markdown文件到响应文件: {markdown_file} -> {response_markdown}")
# 删除原始输出文件
Path(output_file).unlink()
print(f"删除原始输出文件: {output_file}")
# 删除原始Markdown文件
if markdown_file and Path(markdown_file).exists():
Path(markdown_file).unlink()
print(f"删除原始Markdown文件: {markdown_file}")
else:
print(f"警告:输出文件不存在: {output_file}")
results.append({
"filename": file.filename,
"status": "error",
"error": "处理后的文件不存在",
"output_file": None,
"markdown_file": None,
"content": None
})
continue
# 添加成功结果
results.append({
"filename": file.filename,
"status": "success",
"error": None,
"output_file": response_file.name,
"markdown_file": response_markdown.name,
"content": text_content or ""
})
print(f"文件处理完成: {file.filename}")
except Exception as e:
print(f"处理文件时出错: {file.filename}, 错误: {str(e)}")
results.append({
"filename": file.filename,
"status": "error",
"error": f"处理文件时发生错误: {str(e)}",
"output_file": None,
"markdown_file": None,
"content": None
})
# 确保清理临时文件
if temp_file and temp_file.exists():
try:
# 修改为使用安全删除函数
safe_delete_file(temp_file)
except Exception as cleanup_error:
print(f"清理临时文件失败: {cleanup_error}")
except Exception as e:
print(f"处理过程发生错误: {str(e)}")
return {
"results": results,
"error": f"处理过程发生错误: {str(e)}"
}
# 返回处理结果
return {
"results": results,
"error": None if results else "没有成功处理任何文件"
}
@app.get("/api/download/{filename:path}")
async def download_file(filename: str):
"""下载处理后的文件"""
# 确保输出目录存在
ensure_directories()
file_path = OUTPUT_DIR / filename
if not file_path.exists():
raise HTTPException(status_code=404, detail="文件不存在")
# 根据文件扩展名设置正确的MIME类型
file_extension = Path(filename).suffix.lower()
if file_extension == '.md':
media_type = 'text/markdown'
elif file_extension == '.txt':
media_type = 'text/plain'
elif file_extension == '.docx':
media_type = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
else:
media_type = 'application/octet-stream'
return FileResponse(
path=str(file_path),
filename=filename,
media_type=media_type
)
# 在应用启动时清理所有临时目录的内容
@app.on_event("startup")
async def startup_event():
"""应用启动时的初始化操作"""
ensure_directories()
clean_temp_directories()
# 在应用关闭时清理所有临时目录的内容
@app.on_event("shutdown")
async def shutdown_event():
"""应用关闭时的清理操作"""
clean_temp_directories()
# 挂载静态文件目录 - 放在所有API路由之后
app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static")
async def save_uploaded_file(file: UploadFile) -> tuple[Path, str]:
"""保存上传的文件并返回临时文件路径"""
try:
if not file or not file.filename:
return None, "无效的文件"
# 确保上传目录存在
if not UPLOAD_DIR.exists():
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
print(f"创建上传目录: {UPLOAD_DIR}")
# 生成唯一的文件名
unique_id = str(uuid.uuid4())
# 处理文件名,移除.tmp部分
original_name = Path(file.filename).name
if '.tmp.' in original_name:
# 如果文件名中包含.tmp.,则移除它
name_parts = original_name.split('.tmp.')
safe_filename = name_parts[-1] # 取.tmp.后面的部分
else:
safe_filename = original_name
# 确保文件名只包含安全字符
safe_filename = re.sub(r'[^\w\-_\.]', '_', safe_filename)
temp_file = UPLOAD_DIR / f"temp_{unique_id}_{safe_filename}"
print(f"准备保存文件到: {temp_file}")
# 读取文件内容
content = await file.read()
if not content:
return None, "文件内容为空"
# 保存文件
with open(temp_file, "wb") as buffer:
buffer.write(content)
# 验证文件是否成功保存
if not temp_file.exists():
return None, "文件保存失败"
print(f"文件成功保存到: {temp_file}")
return temp_file, None
except Exception as e:
print(f"保存文件时出错: {str(e)}")
return None, f"保存文件时发生错误: {str(e)}"
def safe_delete_file(file_path, max_retries=3, retry_delay=1.0):
"""
安全删除文件,带有重试机制
Args:
file_path: 要删除的文件路径
max_retries: 最大重试次数
retry_delay: 重试之间的延迟(秒)
Returns:
bool: 是否成功删除文件
"""
path = Path(file_path)
if not path.exists():
return True
for attempt in range(max_retries):
try:
path.unlink()
print(f"删除临时文件: {file_path}")
return True
except Exception as e:
print(f"尝试 {attempt+1}/{max_retries} 删除文件失败: {str(e)}")
if "WinError 32" in str(e):
# 如果是"另一个程序正在使用此文件"的错误,等待一会再重试
print(f"文件被锁定,等待 {retry_delay} 秒后重试...")
time.sleep(retry_delay)
else:
# 其他错误不继续尝试
print(f"删除文件时发生错误: {str(e)}")
return False
print(f"无法删除文件 {file_path},已尝试 {max_retries}")
return False
async def process_single_file(file_path: str, cleaner: DocCleaner) -> tuple[str, str, str, str]:
"""处理单个文件并返回结果文件路径、文件内容和Markdown文件路径"""
image_dir = None
output_file = None
temp_docx = None
try:
# 确保输入文件存在
file_path = Path(file_path)
if not file_path.exists():
print(f"错误:输入文件不存在: {file_path}")
raise FileNotFoundError(f"找不到输入文件: {file_path}")
# 规范化文件路径
file_path = str(file_path.resolve())
print(f"规范化后的文件路径: {file_path}")
# 处理文件名,移除.tmp部分
file_stem = Path(file_path).stem
if '.tmp.' in file_stem:
# 如果文件名中包含.tmp.,则移除它
name_parts = file_stem.split('.tmp.')
file_stem = name_parts[-1] # 取.tmp.后面的部分
# 生成唯一的图片目录名
unique_id = str(uuid.uuid4())[:8]
# 确保文件名只包含安全字符
safe_file_stem = re.sub(r'[^\w\-_\.]', '_', file_stem)
image_dir = IMAGES_DIR / f"{safe_file_stem}_{unique_id}"
# 确保图片目录存在
image_dir.mkdir(parents=True, exist_ok=True)
print(f"创建图片目录: {image_dir}")
# 生成输出文件路径
output_file = OUTPUT_DIR / f"{safe_file_stem}_output.txt"
markdown_file = OUTPUT_DIR / f"{safe_file_stem}_output.md"
docx_file = OUTPUT_DIR / f"{safe_file_stem}_output.docx"
print(f"输出文件路径: {output_file}")
print(f"Markdown文件路径: {markdown_file}")
print(f"Word文件路径: {docx_file}")
# 处理文档
print(f"开始处理文件: {file_path}")
print(f"图片将保存到: {image_dir}")
# 处理文档并保存所有格式
main_content, appendix, tables = cleaner.clean_doc(file_path)
print(f"文档处理完成: {file_path}")
# 保存为docx格式这个函数会同时生成txt和md文件
cleaner.save_as_docx(main_content, appendix, tables, str(docx_file))
# 合并正文和附录内容用于返回
all_content = main_content + ["附录"] + appendix if appendix else main_content
text_content = " ".join([t.replace("\n", " ").strip() for t in all_content if t.strip()])
# 验证所有文件是否成功创建
if not output_file.exists():
raise FileNotFoundError(f"TXT文件未能成功创建: {output_file}")
if not markdown_file.exists():
raise FileNotFoundError(f"Markdown文件未能成功创建: {markdown_file}")
return str(output_file), text_content, str(markdown_file), None
except Exception as e:
print(f"处理文件时出错: {str(e)}")
return None, None, None, str(e)
finally:
# 清理临时文件和目录
try:
if image_dir and image_dir.exists():
print(f"清理图片目录: {image_dir}")
shutil.rmtree(str(image_dir))
except Exception as cleanup_error:
print(f"清理图片目录时出错: {str(cleanup_error)}")
try:
if temp_docx and os.path.exists(temp_docx):
print(f"清理临时DOCX文件: {temp_docx}")
safe_delete_file(temp_docx) # 使用安全删除函数
temp_dir = os.path.dirname(temp_docx)
if os.path.exists(temp_dir):
try:
os.rmdir(temp_dir)
except Exception as dir_error:
print(f"清理临时目录时出错: {str(dir_error)}")
except Exception as cleanup_error:
print(f"清理临时DOCX文件时出错: {str(cleanup_error)}")

285
cxs/ocr_api.py Normal file
View File

@@ -0,0 +1,285 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import FastAPI, File, UploadFile, Form, HTTPException
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
import os
import tempfile
from pathlib import Path
import uuid
import time
import base64
import io
# 导入PDF处理器
try:
from cxs_pdf_cleaner import PdfProcessor
except ImportError:
try:
from cxs.cxs_pdf_cleaner import PdfProcessor
except ImportError:
# 如果导入失败添加当前目录到Python路径
import sys
current_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.append(current_dir)
from cxs_pdf_cleaner import PdfProcessor
# 获取当前文件所在目录
CURRENT_DIR = Path(os.path.dirname(os.path.abspath(__file__)))
# 定义目录
TEMP_DIR = CURRENT_DIR / "temp"
STATIC_DIR = CURRENT_DIR / "static"
DEBUG_DIR = TEMP_DIR / "debug"
# 确保所有必要的目录都存在
def ensure_directories():
"""确保所有必要的目录都存在"""
directories = [TEMP_DIR, STATIC_DIR, DEBUG_DIR]
for directory in directories:
directory.mkdir(parents=True, exist_ok=True)
print(f"确保目录存在: {directory}")
# 初始化目录
ensure_directories()
# 创建FastAPI应用
app = FastAPI(debug=True, title="OCR图像识别API",
description="提供高级图像OCR识别服务")
# 配置CORS
origins = ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["*"],
expose_headers=["*"]
)
# 初始化PDF处理器
pdf_processor = PdfProcessor()
# 设置静态文件
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app.mount("/debug", StaticFiles(directory=str(DEBUG_DIR)), name="debug")
@app.get("/")
async def root():
"""重定向到OCR测试页面"""
return {"message": "欢迎使用OCR图像识别API", "test_page": "/static/ocr_test.html"}
@app.post("/api/ocr")
async def ocr_image(
image: UploadFile = File(...),
lang: str = Form("chi_sim+eng"),
mode: str = Form("auto")
):
"""
对上传的图片进行OCR识别
- **image**: 要进行OCR识别的图片文件
- **lang**: OCR语言默认为中文简体+英文 (chi_sim+eng)
- **mode**: 处理模式auto=自动standard=标准advanced=高级chinese=中文优化
"""
print(f"接收到OCR请求: 文件名={image.filename}, 语言={lang}, 模式={mode}")
# 检查文件类型
valid_types = ["image/jpeg", "image/png", "image/bmp", "image/tiff", "image/gif"]
if image.content_type not in valid_types:
raise HTTPException(status_code=400, detail="不支持的文件类型,请上传图片文件")
# 创建一个唯一的ID用于此次处理
process_id = str(uuid.uuid4())[:8]
# 保存上传的图片
temp_dir = tempfile.mkdtemp(dir=TEMP_DIR)
temp_path = Path(temp_dir) / f"image_{process_id}{Path(image.filename).suffix}"
try:
# 保存上传的图片
content = await image.read()
with open(temp_path, "wb") as f:
f.write(content)
print(f"图片已保存到临时路径: {temp_path}")
# 记录开始时间
start_time = time.time()
# 执行OCR处理
ocr_results = []
best_result = ""
# 根据不同模式选择不同的处理参数
if mode == "standard":
# 标准模式 - 使用基本的OCR处理
ocr_text = pdf_processor.perform_ocr(str(temp_path), lang, retry_count=0)
best_result = ocr_text
ocr_results.append({
"name": "标准处理",
"text": ocr_text,
"length": len(ocr_text),
"confidence": 90.0,
"blocks": 1
})
elif mode == "chinese":
# 中文优化模式 - 使用中文专项处理
image = pdf_processor._read_image(str(temp_path))
if image is not None:
# 应用中文优化
processed = pdf_processor._optimize_for_chinese(image)
# 保存处理后的图像以供显示
debug_path = DEBUG_DIR / f"chinese_{process_id}.png"
pdf_processor._save_debug_image(processed, str(debug_path))
# 执行OCR
ocr_text = pdf_processor.perform_ocr(str(debug_path), lang, retry_count=1)
best_result = ocr_text
ocr_results.append({
"name": "中文优化",
"text": ocr_text,
"length": len(ocr_text),
"confidence": 90.0,
"blocks": 1
})
elif mode == "advanced":
# 高级模式 - 使用多种处理方法并比较结果
# 读取原始图像
image = pdf_processor._read_image(str(temp_path))
if image is not None:
# 使用多种图像处理方法
preprocessed_images = pdf_processor._apply_multiple_preprocessing(image)
# 对每个预处理后的图像执行OCR并比较结果
best_length = 0
best_confidence = 0
for method_name, processed_image in preprocessed_images:
# 保存处理后的图像以供显示
debug_path = DEBUG_DIR / f"{method_name.replace(' ', '_').lower()}_{process_id}.png"
pdf_processor._save_debug_image(processed_image, str(debug_path))
# 执行OCR
try:
import pytesseract
ocr_result = pytesseract.image_to_data(processed_image, lang=lang, output_type=pytesseract.Output.DICT)
# 提取文本
extracted_text = []
total_confidence = 0
valid_blocks = 0
for i in range(len(ocr_result['text'])):
confidence = ocr_result['conf'][i]
text = ocr_result['text'][i].strip()
if confidence > pdf_processor.min_text_confidence and text:
extracted_text.append(text)
total_confidence += confidence
valid_blocks += 1
# 合并结果
result_text = " ".join(extracted_text)
result_length = len(result_text)
avg_confidence = total_confidence / valid_blocks if valid_blocks > 0 else 0
ocr_results.append({
"name": method_name,
"text": result_text,
"length": result_length,
"confidence": avg_confidence,
"blocks": valid_blocks
})
# 更新最佳结果
if result_length > 0:
if (result_length > best_length * 1.5) or \
(result_length >= best_length * 0.8 and avg_confidence > best_confidence):
best_result = result_text
best_length = result_length
best_confidence = avg_confidence
except Exception as e:
print(f"处理方法 {method_name} 失败: {str(e)}")
else:
# 自动模式 - 使用完整的OCR处理流程
best_result = pdf_processor.perform_ocr(str(temp_path), lang, retry_count=3)
# 添加处理结果
ocr_results.append({
"name": "自动处理",
"text": best_result,
"length": len(best_result),
"confidence": 90.0,
"blocks": 1
})
# 计算处理时间
processing_time = time.time() - start_time
print(f"OCR处理完成耗时: {processing_time:.2f}")
# 收集处理后的图像列表
processed_images = []
try:
# 查找调试目录中的图像
debug_files = list(DEBUG_DIR.glob(f"*_{process_id}.png"))
for debug_file in debug_files:
# 提取处理方法名称
method_name = debug_file.stem.split('_')[0].replace('_', ' ').title()
# 创建图像URL
image_url = f"/debug/{debug_file.name}"
processed_images.append({
"name": method_name,
"url": image_url
})
except Exception as e:
print(f"收集处理图像时出错: {str(e)}")
# 根据OCR结果长度排序
ocr_results.sort(key=lambda x: x['length'], reverse=True)
# 返回OCR结果
response = {
"text": best_result,
"processing_time": processing_time,
"lang": lang,
"mode": mode,
"methods": ocr_results,
"processed_images": processed_images
}
return JSONResponse(content=response)
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"OCR处理失败: {str(e)}")
finally:
# 清理临时文件
try:
if temp_path.exists():
temp_path.unlink()
if Path(temp_dir).exists():
os.rmdir(temp_dir)
print(f"临时文件已清理")
except Exception as e:
print(f"清理临时文件时出错: {str(e)}")
if __name__ == "__main__":
import uvicorn
print("启动OCR API服务...")
print(f"当前工作目录: {os.getcwd()}")
print(f"静态文件目录: {STATIC_DIR}")
print(f"调试文件目录: {DEBUG_DIR}")
# 启动服务器
uvicorn.run(app, host="0.0.0.0", port=8001)

20
cxs/requirements.txt Normal file
View File

@@ -0,0 +1,20 @@
fastapi==0.104.1
python-multipart==0.0.6
uvicorn==0.24.0
python-docx==1.0.1
numpy>=1.26.2
scikit-learn>=1.3.2
requests>=2.32.2
reportlab==4.0.4
python-Levenshtein>=0.22.0
regex>=2023.0.0
pdf2docx>=0.5.6
pytesseract>=0.3.10
opencv-python>=4.8.0
Pillow>=10.0.0
beautifulsoup4>=4.12.0
html2text>=2020.1.16
pandas>=2.0.0
aiofiles>=23.1.0
openpyxl>=3.1.2
uuid>=1.30

468
cxs/static/index.html Normal file
View File

@@ -0,0 +1,468 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>文档处理系统</title>
<style>
body {
font-family: 'Microsoft YaHei', sans-serif;
max-width: 1000px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background-color: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 {
color: #333;
text-align: center;
margin-bottom: 30px;
}
.upload-area {
border: 2px dashed #ccc;
padding: 20px;
text-align: center;
margin-bottom: 20px;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
}
.upload-area:hover {
border-color: #666;
}
.upload-area.dragover {
border-color: #4CAF50;
background-color: #E8F5E9;
}
#file-input {
display: none;
}
.btn {
background-color: #4CAF50;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s ease;
margin: 0 5px;
}
.btn:hover {
background-color: #45a049;
}
.btn:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
#status {
margin-top: 20px;
padding: 10px;
border-radius: 4px;
display: none;
}
.success {
background-color: #E8F5E9;
color: #2E7D32;
}
.error {
background-color: #FFEBEE;
color: #C62828;
}
.file-list {
margin: 20px 0;
max-height: 300px;
overflow-y: auto;
}
.file-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px;
border: 1px solid #ddd;
margin-bottom: 5px;
border-radius: 4px;
}
.file-item .progress-container {
flex: 1;
margin: 0 20px;
background-color: #f0f0f0;
border-radius: 10px;
overflow: hidden;
}
.file-item .progress-bar {
height: 20px;
background-color: #4CAF50;
width: 0%;
transition: width 0.3s ease;
border-radius: 10px;
position: relative;
}
.progress-text {
position: absolute;
width: 100%;
text-align: center;
color: white;
font-size: 12px;
line-height: 20px;
}
.file-item .remove-btn {
background-color: #f44336;
color: white;
border: none;
padding: 5px 10px;
border-radius: 3px;
cursor: pointer;
}
.result-container {
margin-top: 20px;
border-top: 1px solid #ddd;
padding-top: 20px;
}
.result-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
border: 1px solid #ddd;
margin-bottom: 5px;
border-radius: 4px;
background-color: #fff;
}
.result-item.error {
background-color: #FFEBEE;
}
.result-item.success {
background-color: #E8F5E9;
}
.result-info {
flex: 1;
margin-right: 10px;
}
.button-group {
text-align: center;
margin: 20px 0;
}
.result-text {
max-height: 300px;
overflow-y: auto;
border: 1px solid #ddd;
padding: 10px;
margin-top: 10px;
background-color: #fff;
border-radius: 4px;
white-space: pre-wrap;
display: none;
}
.result-buttons {
display: flex;
gap: 10px;
}
</style>
</head>
<body>
<div class="container">
<h1>文档处理系统</h1>
<div class="upload-area" id="drop-area">
<p>点击或拖拽文件到此处上传</p>
<p>支持的格式:.doc, .docx, .pdf, .html, .htm, .xls, .xlsx</p>
<p>可以同时选择多个文件</p>
<input type="file" id="file-input" accept=".doc,.docx,.pdf,.html,.htm,.xls,.xlsx" multiple>
</div>
<div class="file-list" id="file-list"></div>
<div class="button-group">
<button id="upload-btn" class="btn" disabled>开始处理</button>
<button id="clear-btn" class="btn" style="background-color: #f44336;">清空列表</button>
</div>
<div id="status"></div>
<div class="result-container">
<h2>处理结果</h2>
<div id="result-list"></div>
</div>
</div>
<script>
const dropArea = document.getElementById('drop-area');
const fileInput = document.getElementById('file-input');
const uploadBtn = document.getElementById('upload-btn');
const clearBtn = document.getElementById('clear-btn');
const status = document.getElementById('status');
const fileList = document.getElementById('file-list');
const resultList = document.getElementById('result-list');
let files = new Map(); // 存储待处理的文件
let processing = false; // 是否正在处理文件
// 处理拖拽事件
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropArea.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter', 'dragover'].forEach(eventName => {
dropArea.addEventListener(eventName, highlight, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropArea.addEventListener(eventName, unhighlight, false);
});
function highlight(e) {
dropArea.classList.add('dragover');
}
function unhighlight(e) {
dropArea.classList.remove('dragover');
}
// 处理文件拖放
dropArea.addEventListener('drop', handleDrop, false);
function handleDrop(e) {
const dt = e.dataTransfer;
handleFiles(Array.from(dt.files));
}
// 点击上传区域触发文件选择
dropArea.addEventListener('click', () => {
fileInput.click();
});
fileInput.addEventListener('change', function() {
handleFiles(Array.from(this.files));
this.value = ''; // 清空input允许重复选择相同文件
});
// 清空按钮事件
clearBtn.addEventListener('click', () => {
if (!processing) {
files.clear();
updateFileList();
uploadBtn.disabled = true;
}
});
function handleFiles(newFiles) {
const validTypes = ['.doc', '.docx', '.pdf', '.html', '.htm', '.xls', '.xlsx'];
newFiles.forEach(file => {
const fileExtension = file.name.toLowerCase().slice(file.name.lastIndexOf('.'));
if (validTypes.includes(fileExtension)) {
files.set(file.name, {
file: file,
progress: 0,
status: 'pending' // pending, processing, completed, error
});
}
});
updateFileList();
uploadBtn.disabled = files.size === 0;
}
function updateFileList() {
fileList.innerHTML = '';
files.forEach((fileData, fileName) => {
const fileItem = document.createElement('div');
fileItem.className = 'file-item';
const nameSpan = document.createElement('span');
nameSpan.textContent = fileName;
const progressContainer = document.createElement('div');
progressContainer.className = 'progress-container';
const progressBar = document.createElement('div');
progressBar.className = 'progress-bar';
progressBar.style.width = fileData.progress + '%';
const progressText = document.createElement('div');
progressText.className = 'progress-text';
progressText.textContent = fileData.progress + '%';
const removeBtn = document.createElement('button');
removeBtn.className = 'remove-btn';
removeBtn.textContent = '删除';
removeBtn.onclick = () => {
if (!processing) {
files.delete(fileName);
updateFileList();
uploadBtn.disabled = files.size === 0;
}
};
progressBar.appendChild(progressText);
progressContainer.appendChild(progressBar);
fileItem.appendChild(nameSpan);
fileItem.appendChild(progressContainer);
fileItem.appendChild(removeBtn);
fileList.appendChild(fileItem);
});
}
// 处理文件上传
uploadBtn.addEventListener('click', async () => {
if (processing || files.size === 0) return;
processing = true;
uploadBtn.disabled = true;
status.style.display = 'none';
resultList.innerHTML = '';
try {
const results = [];
// 一个一个处理文件
for (const [fileName, fileData] of files.entries()) {
const formData = new FormData();
formData.append('files', fileData.file);
// 更新进度显示
fileData.status = 'processing';
updateFileList();
try {
const response = await fetch('/api/upload/', {
method: 'POST',
body: formData,
credentials: 'same-origin'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
console.log(`文件 ${fileName} 处理结果:`, result); // 调试日志
if (result.error) {
fileData.status = 'error';
showMessage(`文件 ${fileName} 处理失败: ${result.error}`);
} else if (result.results && result.results.length > 0) {
fileData.status = 'completed';
results.push(...result.results);
}
} catch (error) {
console.error(`文件 ${fileName} 处理错误:`, error);
fileData.status = 'error';
showMessage(`文件 ${fileName} 处理失败: ${error.message}`);
}
// 更新进度显示
fileData.progress = 100;
updateFileList();
// 等待一小段时间,确保文件处理完成
await new Promise(resolve => setTimeout(resolve, 500));
}
// 显示所有处理结果
displayResults(results);
} catch (error) {
console.error('处理错误:', error);
showMessage(`处理失败: ${error.message}`);
} finally {
processing = false;
uploadBtn.disabled = false;
files.clear();
updateFileList();
}
});
async function displayResults(results) {
if (results.length === 0) {
showMessage('没有文件被处理');
return;
}
results.forEach(result => {
const resultItem = document.createElement('div');
resultItem.className = `result-item ${result.status}`;
const resultInfo = document.createElement('div');
resultInfo.className = 'result-info';
if (result.status === 'success') {
resultInfo.innerHTML = `<strong>${result.filename}</strong> 处理成功`;
const buttonsDiv = document.createElement('div');
buttonsDiv.className = 'result-buttons';
// 下载TXT按钮
if (result.output_file) {
const downloadBtn = document.createElement('button');
downloadBtn.className = 'btn';
downloadBtn.textContent = '下载TXT';
downloadBtn.onclick = () => {
window.location.href = `/api/download/${result.output_file}`;
};
buttonsDiv.appendChild(downloadBtn);
}
// 下载Markdown按钮
if (result.markdown_file) {
const downloadMarkdownBtn = document.createElement('button');
downloadMarkdownBtn.className = 'btn';
downloadMarkdownBtn.style.backgroundColor = '#2196F3'; // 使用不同的颜色区分
downloadMarkdownBtn.textContent = '下载MD';
downloadMarkdownBtn.onclick = () => {
window.location.href = `/api/download/${result.markdown_file}`;
};
buttonsDiv.appendChild(downloadMarkdownBtn);
}
// 查看内容按钮
if (result.content) {
const showTextBtn = document.createElement('button');
showTextBtn.className = 'btn';
showTextBtn.textContent = '查看内容';
const textDiv = document.createElement('div');
textDiv.className = 'result-text';
textDiv.textContent = result.content;
textDiv.style.display = 'none';
showTextBtn.onclick = () => {
const isVisible = textDiv.style.display === 'block';
textDiv.style.display = isVisible ? 'none' : 'block';
showTextBtn.textContent = isVisible ? '查看内容' : '隐藏内容';
};
buttonsDiv.appendChild(showTextBtn);
resultItem.appendChild(textDiv);
}
resultItem.appendChild(resultInfo);
resultItem.appendChild(buttonsDiv);
} else {
resultInfo.innerHTML = `<strong>${result.filename}</strong> 处理失败: ${result.error || '未知错误'}`;
resultItem.appendChild(resultInfo);
}
resultList.appendChild(resultItem);
});
}
function showMessage(message) {
const statusDiv = document.getElementById('status');
statusDiv.textContent = message;
statusDiv.className = 'error';
statusDiv.style.display = 'block';
setTimeout(() => {
statusDiv.style.display = 'none';
statusDiv.textContent = '';
statusDiv.className = '';
}, 3000);
}
</script>
</body>
</html>

526
cxs/static/ocr_test.html Normal file
View File

@@ -0,0 +1,526 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OCR图像识别测试</title>
<style>
body {
font-family: 'Microsoft YaHei', Arial, sans-serif;
background-color: #f5f7fa;
margin: 0;
padding: 20px;
color: #333;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 25px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
text-align: center;
color: #2c3e50;
margin-bottom: 30px;
border-bottom: 2px solid #eee;
padding-bottom: 15px;
}
.subtitle {
color: #7f8c8d;
text-align: center;
margin-top: -20px;
margin-bottom: 30px;
}
.upload-container {
border: 2px dashed #3498db;
border-radius: 8px;
padding: 40px;
text-align: center;
margin-bottom: 20px;
background-color: #f8fafc;
transition: background-color 0.3s;
}
.upload-container.dragover {
background-color: #e1f0fa;
}
.upload-container p {
margin: 0;
color: #7f8c8d;
}
.upload-icon {
font-size: 50px;
color: #3498db;
margin-bottom: 15px;
}
.file-input {
display: none;
}
.upload-btn, .ocr-btn {
background-color: #3498db;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s;
margin: 10px 5px;
}
.upload-btn:hover, .ocr-btn:hover {
background-color: #2980b9;
}
.ocr-btn {
background-color: #2ecc71;
display: none;
}
.ocr-btn:hover {
background-color: #27ae60;
}
.ocr-btn:disabled {
background-color: #95a5a6;
cursor: not-allowed;
}
.preview-container {
margin-top: 20px;
text-align: center;
}
.image-preview {
max-width: 100%;
max-height: 400px;
border-radius: 4px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
display: none;
}
.settings {
background-color: #f8fafc;
padding: 15px;
border-radius: 8px;
margin: 20px 0;
display: none;
}
.settings h3 {
margin-top: 0;
color: #2c3e50;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: #34495e;
}
.form-control {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
font-family: inherit;
font-size: 16px;
}
.results {
margin-top: 30px;
display: none;
}
.tabs {
display: flex;
border-bottom: 1px solid #ddd;
margin-bottom: 20px;
}
.tab {
padding: 10px 20px;
cursor: pointer;
border: 1px solid transparent;
border-radius: 4px 4px 0 0;
margin-right: 5px;
background-color: #f8f9fa;
}
.tab.active {
border: 1px solid #ddd;
border-bottom-color: white;
background-color: white;
font-weight: bold;
}
.tab-content {
display: none;
padding: 20px;
border: 1px solid #ddd;
border-top: none;
border-radius: 0 0 4px 4px;
}
.tab-content.active {
display: block;
}
.ocr-text {
background-color: #f8f9fa;
padding: 15px;
border-radius: 4px;
white-space: pre-wrap;
font-family: 'Courier New', monospace;
line-height: 1.5;
max-height: 300px;
overflow-y: auto;
border: 1px solid #ddd;
}
.processing-info {
margin-top: 20px;
padding: 15px;
background-color: #f0f7fb;
border-radius: 4px;
border-left: 5px solid #3498db;
}
.method-result {
margin: 10px 0;
padding: 15px;
background-color: #f8fafc;
border-radius: 4px;
border: 1px solid #ddd;
}
.method-result h4 {
margin-top: 0;
color: #2c3e50;
}
.confidence-bar {
height: 10px;
background-color: #ecf0f1;
border-radius: 5px;
margin: 5px 0;
position: relative;
}
.confidence-value {
height: 100%;
background-color: #2ecc71;
border-radius: 5px;
position: absolute;
left: 0;
top: 0;
}
.processed-images {
display: flex;
flex-wrap: wrap;
gap: 15px;
margin-top: 20px;
}
.processed-image {
max-width: calc(50% - 15px);
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
background-color: white;
}
.processed-image h4 {
margin-top: 0;
text-align: center;
color: #2c3e50;
font-size: 14px;
}
.processed-image img {
max-width: 100%;
border-radius: 4px;
}
.loader {
border: 5px solid #f3f3f3;
border-top: 5px solid #3498db;
border-radius: 50%;
width: 30px;
height: 30px;
animation: spin 2s linear infinite;
margin: 20px auto;
display: none;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-message {
color: #e74c3c;
padding: 10px;
background-color: #fadbd8;
border-radius: 4px;
margin: 20px 0;
display: none;
}
</style>
</head>
<body>
<div class="container">
<h1>OCR图像识别测试</h1>
<p class="subtitle">上传图片并测试文字识别效果</p>
<div class="upload-container" id="uploadContainer">
<div class="upload-icon">📁</div>
<p>拖放图片到这里,或点击上传</p>
<input type="file" id="fileInput" class="file-input" accept="image/*">
<button class="upload-btn" id="uploadBtn">选择图片</button>
</div>
<div class="preview-container">
<img id="imagePreview" class="image-preview">
</div>
<div class="settings" id="settings">
<h3>OCR设置</h3>
<div class="form-group">
<label for="langSelect">识别语言</label>
<select id="langSelect" class="form-control">
<option value="chi_sim+eng" selected>中文简体+英文</option>
<option value="chi_sim">中文简体</option>
<option value="eng">英文</option>
<option value="chi_tra">中文繁体</option>
<option value="jpn">日语</option>
<option value="kor">韩语</option>
<option value="rus">俄语</option>
</select>
</div>
<div class="form-group">
<label for="modeSelect">处理模式</label>
<select id="modeSelect" class="form-control">
<option value="auto" selected>自动模式</option>
<option value="standard">标准模式</option>
<option value="chinese">中文优化</option>
<option value="advanced">高级模式</option>
</select>
</div>
<button class="ocr-btn" id="ocrBtn">执行OCR</button>
</div>
<div class="loader" id="loader"></div>
<div class="error-message" id="errorMessage"></div>
<div class="results" id="results">
<div class="tabs">
<div class="tab active" data-tab="text">识别文本</div>
<div class="tab" data-tab="details">处理详情</div>
<div class="tab" data-tab="images">处理图像</div>
</div>
<div class="tab-content active" id="textContent">
<h3>OCR识别结果</h3>
<div class="ocr-text" id="ocrText"></div>
<div class="processing-info" id="processingInfo"></div>
</div>
<div class="tab-content" id="detailsContent">
<h3>处理方法详情</h3>
<div id="methodsList"></div>
</div>
<div class="tab-content" id="imagesContent">
<h3>处理后的图像</h3>
<div class="processed-images" id="processedImages"></div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const uploadContainer = document.getElementById('uploadContainer');
const fileInput = document.getElementById('fileInput');
const uploadBtn = document.getElementById('uploadBtn');
const imagePreview = document.getElementById('imagePreview');
const settings = document.getElementById('settings');
const ocrBtn = document.getElementById('ocrBtn');
const results = document.getElementById('results');
const ocrText = document.getElementById('ocrText');
const processingInfo = document.getElementById('processingInfo');
const methodsList = document.getElementById('methodsList');
const processedImages = document.getElementById('processedImages');
const loader = document.getElementById('loader');
const errorMessage = document.getElementById('errorMessage');
const tabs = document.querySelectorAll('.tab');
const tabContents = document.querySelectorAll('.tab-content');
// 处理文件选择
fileInput.addEventListener('change', handleFileSelect);
uploadBtn.addEventListener('click', () => fileInput.click());
// 拖放功能
uploadContainer.addEventListener('dragover', (e) => {
e.preventDefault();
uploadContainer.classList.add('dragover');
});
uploadContainer.addEventListener('dragleave', () => {
uploadContainer.classList.remove('dragover');
});
uploadContainer.addEventListener('drop', (e) => {
e.preventDefault();
uploadContainer.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) {
fileInput.files = e.dataTransfer.files;
handleFileSelect(e);
}
});
// 处理OCR按钮点击
ocrBtn.addEventListener('click', performOCR);
// 处理标签页切换
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('active'));
tabContents.forEach(c => c.classList.remove('active'));
tab.classList.add('active');
const tabId = tab.getAttribute('data-tab');
document.getElementById(`${tabId}Content`).classList.add('active');
});
});
function handleFileSelect(e) {
const file = fileInput.files[0];
if (!file) return;
// 检查文件类型
if (!file.type.match('image.*')) {
showError('请选择图片文件');
return;
}
// 隐藏之前的错误消息和结果
errorMessage.style.display = 'none';
results.style.display = 'none';
// 更新预览
const reader = new FileReader();
reader.onload = function(e) {
imagePreview.src = e.target.result;
imagePreview.style.display = 'block';
settings.style.display = 'block';
ocrBtn.style.display = 'block';
ocrBtn.disabled = false;
};
reader.readAsDataURL(file);
}
function performOCR() {
const file = fileInput.files[0];
if (!file) {
showError('请先选择图片文件');
return;
}
const lang = document.getElementById('langSelect').value;
const mode = document.getElementById('modeSelect').value;
// 显示加载状态
loader.style.display = 'block';
ocrBtn.disabled = true;
errorMessage.style.display = 'none';
results.style.display = 'none';
// 创建FormData对象
const formData = new FormData();
formData.append('image', file);
formData.append('lang', lang);
formData.append('mode', mode);
// 发送OCR请求
fetch('/api/ocr', {
method: 'POST',
body: formData
})
.then(response => {
if (!response.ok) {
return response.json().then(err => {
throw new Error(err.detail || '处理图片时出错');
});
}
return response.json();
})
.then(data => {
// 隐藏加载状态
loader.style.display = 'none';
// 显示OCR结果
ocrText.textContent = data.text || '未识别到文本';
// 显示处理信息
processingInfo.innerHTML = `
<p><strong>处理时间:</strong> ${data.processing_time.toFixed(2)}秒</p>
<p><strong>识别语言:</strong> ${data.lang}</p>
<p><strong>处理模式:</strong> ${getModeLabel(data.mode)}</p>
<p><strong>识别文本长度:</strong> ${data.text ? data.text.length : 0}个字符</p>
`;
// 显示处理方法详情
methodsList.innerHTML = '';
if (data.methods && data.methods.length > 0) {
data.methods.forEach(method => {
const methodDiv = document.createElement('div');
methodDiv.className = 'method-result';
const confidencePercent = method.confidence || 0;
methodDiv.innerHTML = `
<h4>${method.name}</h4>
<p><strong>文本长度:</strong> ${method.length} 字符</p>
<p><strong>置信度:</strong> ${confidencePercent.toFixed(2)}%</p>
<div class="confidence-bar">
<div class="confidence-value" style="width: ${Math.min(100, confidencePercent)}%"></div>
</div>
<p><strong>文本块数:</strong> ${method.blocks}</p>
<div class="ocr-text">${method.text || '未识别到文本'}</div>
`;
methodsList.appendChild(methodDiv);
});
} else {
methodsList.innerHTML = '<p>没有可用的处理方法详情</p>';
}
// 显示处理后的图像
processedImages.innerHTML = '';
if (data.processed_images && data.processed_images.length > 0) {
data.processed_images.forEach(img => {
const imgDiv = document.createElement('div');
imgDiv.className = 'processed-image';
imgDiv.innerHTML = `
<h4>${img.name}</h4>
<img src="${img.url}" alt="${img.name}">
`;
processedImages.appendChild(imgDiv);
});
} else {
processedImages.innerHTML = '<p>没有处理后的图像可供显示</p>';
}
// 显示结果区域
results.style.display = 'block';
// 恢复OCR按钮
ocrBtn.disabled = false;
})
.catch(error => {
console.error('OCR处理失败:', error);
loader.style.display = 'none';
ocrBtn.disabled = false;
showError(error.message || '处理图片时出错,请重试');
});
}
function showError(message) {
errorMessage.textContent = message;
errorMessage.style.display = 'block';
}
function getModeLabel(mode) {
const modes = {
'auto': '自动模式',
'standard': '标准模式',
'chinese': '中文优化',
'advanced': '高级模式'
};
return modes[mode] || mode;
}
});
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,69 @@
# 文档内容
【文档信息】
作者: Lenovo
创建时间: 2025-05-15 08:30:10
修改时间: 2025-05-15 08:30:25
1111
【图片识别文本】
“ 完 善 了 异 常 处 理 , 防 止 惑 时 目 录 券 除 失 败 导 致 程 序 崖
4. 更 新 README.md
* 在 暨 近 更 新 部 分 记 录 了 临 时 文 件 处 理 机 制 的 改 进
。 添 加 了 Excel 文 件 句 柄 管 理
使 用 说 明
这 东 改 进 不 需 要 您 做 任 何 额 外 操 作 , 系 统 会 自 动 -
1. 在 处 理 Excel 文 件 时 正 球 关 闭 文 件 句 柄
2 当 尝 试 删 除 文 件 通 刨 “ 文 件 被 占 用 “ 错 误 时 , 自 动 等 待
并 重 试
3 即 使 无 法 券 除 临 时 文 件 , 也 不 影 响 处 #
如 果 仍 然 通 到 惧 时 文 件 问 题 , 系 统 会 在 下 次 启 动 时 自 动
清 理 所 有 临 时 文 件 , 不 会 影 响 系 统 功 能 。
以 上 优 化 星 觞 失 了 临 时 文 伟 删 除 问 题 , 又 保 持 了 系 统 的
稳 定 性 , 让 您 能 雪 顺 畅 地 处 理 Bxcel 文 件 。
## 图片内容
### 图片 1
![图片 1](images/image_1.png)
**OCR文本内容:**
“ 完 善 了 异 常 处 理 , 防 止 惑 时 目 录 券 除 失 败 导 致 程 序 崖
4. 更 新 README.md
* 在 暨 近 更 新 部 分 记 录 了 临 时 文 件 处 理 机 制 的 改 进
。 添 加 了 Excel 文 件 句 柄 管 理
使 用 说 明
这 东 改 进 不 需 要 您 做 任 何 额 外 操 作 , 系 统 会 自 动 -
1. 在 处 理 Excel 文 件 时 正 球 关 闭 文 件 句 柄
2 当 尝 试 删 除 文 件 通 刨 “ 文 件 被 占 用 “ 错 误 时 , 自 动 等 待
并 重 试
3 即 使 无 法 券 除 临 时 文 件 , 也 不 影 响 处 #
如 果 仍 然 通 到 惧 时 文 件 问 题 , 系 统 会 在 下 次 启 动 时 自 动
清 理 所 有 临 时 文 件 , 不 会 影 响 系 统 功 能 。
以 上 优 化 星 觞 失 了 临 时 文 伟 删 除 问 题 , 又 保 持 了 系 统 的
稳 定 性 , 让 您 能 雪 顺 畅 地 处 理 Bxcel 文 件 。

View File

@@ -0,0 +1 @@
【文档信息】 作者: Lenovo 创建时间: 2025-05-15 08:30:10 修改时间: 2025-05-15 08:30:25 1111 【图片识别文本】 “ 完 善 了 异 常 处 理 , 防 止 惑 时 目 录 券 除 失 败 导 致 程 序 崖 澎 澎 4. 更 新 README.md * 在 暨 近 更 新 部 分 记 录 了 临 时 文 件 处 理 机 制 的 改 进 。 添 加 了 Excel 文 件 句 柄 管 理 使 用 说 明 这 东 改 进 不 需 要 您 做 任 何 额 外 操 作 , 系 统 会 自 动 - 1. 在 处 理 Excel 文 件 时 正 球 关 闭 文 件 句 柄 2 当 尝 试 删 除 文 件 通 刨 “ 文 件 被 占 用 “ 错 误 时 , 自 动 等 待 并 重 试 3 即 使 无 法 券 除 临 时 文 件 , 也 不 影 响 处 # 如 果 仍 然 通 到 惧 时 文 件 问 题 , 系 统 会 在 下 次 启 动 时 自 动 清 理 所 有 临 时 文 件 , 不 会 影 响 系 统 功 能 。 以 上 优 化 星 觞 失 了 临 时 文 伟 删 除 问 题 , 又 保 持 了 系 统 的 稳 定 性 , 让 您 能 雪 顺 畅 地 处 理 Bxcel 文 件 。

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

1394
doc_cleaner_java.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,20 @@
python-docx==0.8.11
fastapi>=0.100.0
python-multipart>=0.0.6
uvicorn>=0.23.0
python-docx>=0.8.11
numpy>=1.24.0
scikit-learn>=1.0.2
requests>=2.31.0
reportlab==4.0.4
difflib
python-Levenshtein==0.22.0
regex>=2023.0.0
scikit-learn>=1.3.0
numpy>=1.24.0
requests>=2.31.0
pdf2docx>=0.5.6
pytesseract>=0.3.10
opencv-python>=4.8.0
Pillow>=10.0.0
beautifulsoup4>=4.12.0
html2text>=2020.1.16
pandas>=2.0.0
aiofiles>=23.1.0
openpyxl>=3.1.2
uuid>=1.30

23
sample_paragraphs.json Normal file

File diff suppressed because one or more lines are too long