From 68a116961f247ce9f891ed82b9503b04ff846ec6 Mon Sep 17 00:00:00 2001 From: mambo-wang Date: Wed, 24 Jun 2026 12:00:20 +0800 Subject: [PATCH 01/12] feat: assistant-driven-ocr - extract_only mode eliminates external LLM dependency Add extract_only mode across all packages enabling AI assistants (CodeBuddy/Qoder/QoderWork) to drive OCR and image analysis using their own vision capabilities via file side-channel pattern. - Core: MarkItDown(extract_only=True) propagates to all converters, extracts images to disk - ImageConverter: extract_only branch saves images with metadata comments - OCR plugin: all converters (PDF/DOCX/PPTX/XLSX) support extract_only with _convert_extract_only() - MCP server: new analyze_document tool returns text skeleton + image manifest as JSON - MCP deps: changed from markitdown[all] to markitdown (core) for Python 3.14 compatibility - Added openspec proposal/design/specs/tasks and repowiki documentation --- .../assistant-driven-ocr/.openspec.yaml | 2 + .../changes/assistant-driven-ocr/design.md | 92 + .../changes/assistant-driven-ocr/proposal.md | 28 + .../specs/assistant-orchestration/spec.md | 34 + .../specs/extract-only-mode/spec.md | 45 + .../specs/mcp-analyze-document/spec.md | 49 + .../changes/assistant-driven-ocr/tasks.md | 40 + openspec/config.yaml | 20 + openspec/schemas/sdd-default/schema.yaml | 153 + .../schemas/sdd-default/templates/design.md | 19 + .../schemas/sdd-default/templates/proposal.md | 23 + .../schemas/sdd-default/templates/spec.md | 8 + .../schemas/sdd-default/templates/tasks.md | 9 + packages/markitdown-mcp/pyproject.toml | 2 +- .../src/markitdown_mcp/__main__.py | 207 +- .../_docx_converter_with_ocr.py | 101 +- .../src/markitdown_ocr/_ocr_service.py | 44 + .../markitdown_ocr/_pdf_converter_with_ocr.py | 81 +- .../_pptx_converter_with_ocr.py | 117 +- .../_xlsx_converter_with_ocr.py | 114 +- .../markitdown/src/markitdown/_markitdown.py | 9 + .../markitdown/converters/_image_converter.py | 102 + repowiki/Azure_Service_Converters.md | 279 ++ repowiki/Core_Engine.md | 460 +++ repowiki/DOCX_Math_Utils.md | 424 +++ repowiki/Document_Format_Converters.md | 375 +++ repowiki/MCP_Server.md | 384 +++ repowiki/Media_Converters.md | 332 +++ repowiki/OCR_Plugin.md | 408 +++ repowiki/Sample_Plugin.md | 189 ++ repowiki/Web_Content_Converters.md | 288 ++ repowiki/first_module_tree.json | 148 + repowiki/metadata.json | 6 + repowiki/module_tree.json | 148 + repowiki/overview.md | 260 ++ .../markitdown_CN_dependency_graph.json | 2489 +++++++++++++++++ 36 files changed, 7480 insertions(+), 9 deletions(-) create mode 100644 openspec/changes/assistant-driven-ocr/.openspec.yaml create mode 100644 openspec/changes/assistant-driven-ocr/design.md create mode 100644 openspec/changes/assistant-driven-ocr/proposal.md create mode 100644 openspec/changes/assistant-driven-ocr/specs/assistant-orchestration/spec.md create mode 100644 openspec/changes/assistant-driven-ocr/specs/extract-only-mode/spec.md create mode 100644 openspec/changes/assistant-driven-ocr/specs/mcp-analyze-document/spec.md create mode 100644 openspec/changes/assistant-driven-ocr/tasks.md create mode 100644 openspec/config.yaml create mode 100644 openspec/schemas/sdd-default/schema.yaml create mode 100644 openspec/schemas/sdd-default/templates/design.md create mode 100644 openspec/schemas/sdd-default/templates/proposal.md create mode 100644 openspec/schemas/sdd-default/templates/spec.md create mode 100644 openspec/schemas/sdd-default/templates/tasks.md create mode 100644 repowiki/Azure_Service_Converters.md create mode 100644 repowiki/Core_Engine.md create mode 100644 repowiki/DOCX_Math_Utils.md create mode 100644 repowiki/Document_Format_Converters.md create mode 100644 repowiki/MCP_Server.md create mode 100644 repowiki/Media_Converters.md create mode 100644 repowiki/OCR_Plugin.md create mode 100644 repowiki/Sample_Plugin.md create mode 100644 repowiki/Web_Content_Converters.md create mode 100644 repowiki/first_module_tree.json create mode 100644 repowiki/metadata.json create mode 100644 repowiki/module_tree.json create mode 100644 repowiki/overview.md create mode 100644 repowiki/temp/dependency_graphs/markitdown_CN_dependency_graph.json diff --git a/openspec/changes/assistant-driven-ocr/.openspec.yaml b/openspec/changes/assistant-driven-ocr/.openspec.yaml new file mode 100644 index 000000000..bec6fbef0 --- /dev/null +++ b/openspec/changes/assistant-driven-ocr/.openspec.yaml @@ -0,0 +1,2 @@ +schema: sdd-default +created: 2026-06-24 diff --git a/openspec/changes/assistant-driven-ocr/design.md b/openspec/changes/assistant-driven-ocr/design.md new file mode 100644 index 000000000..81e615be7 --- /dev/null +++ b/openspec/changes/assistant-driven-ocr/design.md @@ -0,0 +1,92 @@ +## Context + +markitdown 是一个多格式文档转 Markdown 的 Python 库,当前在以下场景依赖外部 LLM 配置: + +1. **ImageConverter** → `llm_caption()` 调用 OpenAI 兼容 API 为图片生成文字描述 +2. **OCR 插件** (`markitdown-ocr`) → `LLMVisionOCRService` 调用视觉模型对文档嵌入图片做 OCR +3. **AudioConverter** → 使用 SpeechRecognition 本地引擎,不依赖 LLM + +用户通过 AI 助手(QoderWork/CodeBuddy/Qoder)的 MCP 工具使用 markitdown 时,助手本身就是多模态 LLM,具备视觉理解能力。当前架构要求用户额外配置 `llm_client` + `llm_model`,造成双重配置和维护负担。 + +约束条件: +- MCP 使用 STDIO 传输,不适合在协议层传递大量 base64 图片数据 +- AI 助手具备文件读取能力(Read 工具可直接读取图片) +- 助手上下文窗口有限,不能一次性塞入大量图片 + +## Goals / Non-Goals + +**Goals:** +- 消除 markitdown 对独立 LLM 配置的依赖(当通过 AI 助手使用时) +- 利用文件侧通道传递图片数据,避免 MCP 协议层的数据膨胀 +- 让 AI 助手自主决策哪些图片需要 OCR、哪些只需简要描述 +- 保持 API 向后兼容,extract-only 模式为可选参数 + +**Non-Goals:** +- 不替代现有的 LLM 直调模式(保留给非助手场景使用) +- 不改造音频转录流程(SpeechRecognition 是本地引擎,不涉及 LLM) +- 不实现图片的批量并行处理(由助手侧的编排逻辑决定) +- 不提供图片内容的缓存或索引机制 + +## Decisions + +### Decision 1: 文件侧通道而非协议内传输 + +**选择**:图片数据写入磁盘临时文件,MCP 只返回文件路径 + +**理由**: +- STDIO 模式下 MCP 协议传输 base64 图片会导致 JSON 消息体过大,影响传输效率 +- AI 助手(QoderWork)的 Read 工具原生支持读取图片文件并作为多模态输入 +- 与 CodeWiki MCP 的"文件侧通道"模式一致,是经过验证的模式 + +**备选方案**: +- 在 MCP 响应中嵌入 base64 → 排除,数据量过大 +- 使用 HTTP 模式 + multipart 上传 → 排除,增加部署复杂度,且 STDIO 模式更常用 + +### Decision 2: 两阶段工具调用(analyze_document) + +**选择**:新增独立的 `analyze_document` MCP 工具,而非修改现有 `convert_to_markdown` + +**理由**: +- `convert_to_markdown` 保持原有语义(返回完整 Markdown),不破坏已有客户端 +- `analyze_document` 返回结构化数据(文本骨架 + 图片列表 + 位置标记),助手可以智能编排后续处理 +- 两阶段设计让助手拥有决策权:可以跳过不重要的图片、合并相似图片、按需选择 OCR 深度 + +**备选方案**: +- 在 `convert_to_markdown` 中加 `extract_images=true` 参数 → 排除,返回结构变化会影响已有客户端 +- 只提供 `extract_images` 工具不做文本转换 → 排除,助手仍需调 `convert_to_markdown`,两次调用不如一次集成 + +### Decision 3: 转换器层面的 extract-only 分支 + +**选择**:在 `ImageConverter` 和 OCR 转换器中添加 `extract_only` 参数分支 + +**理由**: +- 最小化代码改动,不引入新的转换器类 +- extract_only=True 时跳过 LLM 调用,输出占位符 `![image]()`,助手后续替换 +- 保留原有 LLM 路径,向后兼容 + +**备选方案**: +- 创建独立的 `ImageExtractor` 转换器类 → 排除,代码重复度高 +- 通过环境变量全局切换 → 排除,粒度太粗,影响所有实例 + +### Decision 4: 临时目录生命周期管理 + +**选择**:MCP Server 的 lifespan 中管理临时目录,会话结束时自动清理 + +**理由**: +- `analyze_document` 每次调用创建 `{session_tempdir}/{doc_id}/images/` 子目录 +- MCP Server 的 `lifespan()` 已有资源管理逻辑,扩展即可 +- 避免孤儿文件累积占用磁盘 + +## Risks / Trade-offs + +**[助手编排复杂度]** → 助手需要额外的编排逻辑来组合文本和图片分析结果 +→ 缓解:提供标准化的 Skill/提示词模板,封装编排逻辑 + +**[大量图片的 token 预算]** → 100 页扫描 PDF 可能有数百张图片,助手无法逐张处理 +→ 缓解:`analyze_document` 返回图片尺寸和位置元数据,助手可智能筛选(如只 OCR 大于阈值的图片) + +**[临时文件清理]** → 进程异常退出可能导致临时文件残留 +→ 缓解:使用 `tempfile.mkdtemp()` + 启动时清理旧目录 + lifespan 退出钩子 + +**[两次调用开销]** → 助手需要两次工具调用(analyze_document + 图片读取)而非一次 +→ 缓解:相比配置和维护外部 LLM 的成本,两次调用的开销可接受 diff --git a/openspec/changes/assistant-driven-ocr/proposal.md b/openspec/changes/assistant-driven-ocr/proposal.md new file mode 100644 index 000000000..be12457df --- /dev/null +++ b/openspec/changes/assistant-driven-ocr/proposal.md @@ -0,0 +1,28 @@ +## Why + +markitdown 的 OCR 和图片分析功能(`LLMVisionOCRService`、`llm_caption`)要求用户单独配置 OpenAI 兼容的 LLM client(endpoint + API key),这对于已通过 AI 助手(CodeBuddy/Qoder/QoderWork)使用 markitdown 的用户来说是冗余的——助手本身就是多模态 LLM,完全具备视觉理解能力。当前架构让用户需要维护两套 LLM 配置,且无法利用助手侧的智能决策(如判断哪些图片值得 OCR)。 + +## What Changes + +- 新增"提取模式"(extract-only mode):markitdown 转换文档时,将嵌入图片提取为独立文件写入磁盘,而非调用 LLM 分析 +- 新增 MCP 工具 `analyze_document`:接收文件路径,返回文本骨架 + 图片文件路径列表 + 位置标记,所有图片数据走文件侧通道 +- 改造 `ImageConverter` 和 OCR 插件转换器,支持 extract-only 模式(跳过 LLM 调用,输出图片占位符) +- 改造 `markitdown-mcp` 包,新增 `analyze_document` 工具,使用文件侧通道传递图片数据 +- 提供编排层 Skill/提示词,指导 AI 助手如何组合文本骨架与图片分析结果生成最终 Markdown + +## Capabilities + +### New Capabilities +- `extract-only-mode`: 文档转换的提取模式,将嵌入图片提取到磁盘文件,返回文本骨架和图片路径,不调用任何 LLM +- `mcp-analyze-document`: MCP 工具 `analyze_document`,通过文件侧通道返回文档文本和图片路径,供助手二次处理 +- `assistant-orchestration`: AI 助手编排层,指导助手读取图片文件并用自己的视觉能力完成 OCR/描述,组合最终输出 + +### Modified Capabilities + +## Impact + +- **核心库** (`packages/markitdown`):`ImageConverter`、`llm_caption.py`、OCR 插件的 4 个转换器需要支持 extract-only 分支 +- **MCP 包** (`packages/markitdown-mcp`):新增 `analyze_document` 工具,管理临时图片目录的生命周期 +- **依赖**:extract-only 模式下不再需要 `openai` 依赖,降低安装门槛 +- **API 兼容性**:新增模式为可选参数,不破坏现有 API +- **文件系统**:运行时会在临时目录写入图片文件,需要清理机制 diff --git a/openspec/changes/assistant-driven-ocr/specs/assistant-orchestration/spec.md b/openspec/changes/assistant-driven-ocr/specs/assistant-orchestration/spec.md new file mode 100644 index 000000000..259ef4e68 --- /dev/null +++ b/openspec/changes/assistant-driven-ocr/specs/assistant-orchestration/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: Orchestration Skill for AI assistants +A Skill file (SKILL.md) SHALL be provided that instructs AI assistants on how to use `analyze_document` and combine the text skeleton with vision-based image analysis to produce the final Markdown output. + +#### Scenario: Skill guides two-phase processing +- **WHEN** an AI assistant loads the orchestration Skill +- **THEN** the Skill SHALL describe a two-phase workflow: (1) call `analyze_document` to get text skeleton and image manifest, (2) read each image file using the Read tool and generate OCR/description using the assistant's own vision capability + +#### Scenario: Skill includes intelligent image selection +- **WHEN** the image manifest contains images of varying sizes +- **THEN** the Skill SHALL guide the assistant to prioritize images above a size threshold (e.g., >10KB) for detailed OCR, and skip or summarize small decorative images + +### Requirement: Orchestration produces complete Markdown output +The orchestration workflow SHALL produce a complete Markdown document where all image placeholders from the text skeleton are replaced with actual OCR results or image descriptions. + +#### Scenario: Placeholder replacement +- **WHEN** the text skeleton contains `![image](/tmp/doc123/images/img_0.png)` and the assistant performs OCR on that image +- **THEN** the final output SHALL replace the placeholder with the OCR text content or a descriptive caption + +#### Scenario: Document with no images +- **WHEN** `analyze_document` returns an empty images array +- **THEN** the text skeleton SHALL be returned as-is without further processing + +### Requirement: Orchestration handles batch processing +The Skill SHALL guide assistants on efficiently processing documents with many images, including batching and prioritization strategies. + +#### Scenario: Large document with 100+ images +- **WHEN** a scanned PDF produces 100+ image references +- **THEN** the Skill SHALL recommend processing images in batches of 5-10, prioritizing by file size, and summarizing remaining images as "[image omitted]" + +#### Scenario: Mixed content document +- **WHEN** a document contains both text-heavy pages and image-heavy pages +- **THEN** the Skill SHALL recommend OCR for text-containing images and brief descriptions for purely visual images diff --git a/openspec/changes/assistant-driven-ocr/specs/extract-only-mode/spec.md b/openspec/changes/assistant-driven-ocr/specs/extract-only-mode/spec.md new file mode 100644 index 000000000..ccefdbc16 --- /dev/null +++ b/openspec/changes/assistant-driven-ocr/specs/extract-only-mode/spec.md @@ -0,0 +1,45 @@ +## ADDED Requirements + +### Requirement: Extract-only mode for ImageConverter +`ImageConverter` SHALL support an `extract_only` mode. When `extract_only=True`, the converter SHALL extract the image to a file on disk and return a `DocumentConverterResult` containing a Markdown image reference `![image]()` instead of calling `llm_caption()`. + +#### Scenario: Extract-only mode with local file +- **WHEN** `ImageConverter.convert()` is called with `extract_only=True` and an `image_output_dir` parameter +- **THEN** the image SHALL be saved to `{image_output_dir}/{unique_id}.{ext}` and the returned markdown SHALL contain `![image]({file_path})` + +#### Scenario: Extract-only mode without output directory +- **WHEN** `ImageConverter.convert()` is called with `extract_only=True` but no `image_output_dir` +- **THEN** a temporary directory SHALL be created automatically using `tempfile.mkdtemp()` + +#### Scenario: Default behavior unchanged +- **WHEN** `ImageConverter.convert()` is called without `extract_only` parameter (or `extract_only=False`) +- **THEN** the existing `llm_caption()` flow SHALL be used as before + +### Requirement: Extract-only mode for OCR plugin converters +All OCR-enhanced converters (`PdfConverterWithOCR`, `DocxConverterWithOCR`, `PptxConverterWithOCR`, `XlsxConverterWithOCR`) SHALL support `extract_only` mode. In this mode, embedded images SHALL be extracted to disk and referenced by path, without invoking `LLMVisionOCRService`. + +#### Scenario: PDF extract-only with embedded images +- **WHEN** `PdfConverterWithOCR.convert()` is called with `extract_only=True` +- **THEN** each page's embedded images SHALL be saved to `{image_output_dir}/page_{n}_{idx}.{ext}` and referenced in the output markdown + +#### Scenario: DOCX extract-only with embedded images +- **WHEN** `DocxConverterWithOCR.convert()` is called with `extract_only=True` +- **THEN** embedded images in the DOCX SHALL be extracted and referenced by file path instead of being sent to the OCR service + +### Requirement: MarkItDown constructor supports extract_only +`MarkItDown.__init__()` SHALL accept an `extract_only` boolean parameter (default `False`). When `True`, it SHALL be passed as a default kwarg to all converters during conversion. + +#### Scenario: Global extract-only mode +- **WHEN** `MarkItDown(extract_only=True)` is instantiated and `convert()` is called +- **THEN** all converters that support `extract_only` SHALL operate in extract-only mode + +#### Scenario: Per-call override +- **WHEN** `MarkItDown(extract_only=True)` is instantiated but `convert()` is called with `extract_only=False` +- **THEN** the per-call parameter SHALL override the instance default + +### Requirement: Image metadata in extract-only output +In extract-only mode, the output SHALL include image metadata as a structured comment block preceding each image reference, containing dimensions (width, height) and file size. + +#### Scenario: Image metadata comment +- **WHEN** an image of 1920x1080 pixels and 245KB is extracted in extract-only mode +- **THEN** the output SHALL contain `` followed by `![image]()` diff --git a/openspec/changes/assistant-driven-ocr/specs/mcp-analyze-document/spec.md b/openspec/changes/assistant-driven-ocr/specs/mcp-analyze-document/spec.md new file mode 100644 index 000000000..cea30cb29 --- /dev/null +++ b/openspec/changes/assistant-driven-ocr/specs/mcp-analyze-document/spec.md @@ -0,0 +1,49 @@ +## ADDED Requirements + +### Requirement: analyze_document MCP tool +The MCP server SHALL expose an `analyze_document` tool that accepts a file path (local path or URI) and returns a structured JSON response containing: text skeleton (markdown with image placeholders), image manifest (list of extracted image file paths with metadata), and document metadata. + +#### Scenario: Analyze a PDF with embedded images +- **WHEN** the `analyze_document` tool is called with a PDF file path containing 3 embedded images +- **THEN** the response SHALL contain a `text_skeleton` field with markdown text, an `images` array with 3 entries each containing `path`, `position`, `width`, `height`, and `size_bytes` fields, and a `metadata` object with `page_count` + +#### Scenario: Analyze a DOCX document +- **WHEN** the `analyze_document` tool is called with a DOCX file containing 2 embedded images +- **THEN** the images SHALL be extracted to `{temp_dir}/{doc_id}/images/` and their paths returned in the `images` array + +#### Scenario: Analyze a text-only document +- **WHEN** the `analyze_document` tool is called with a plain text file +- **THEN** the response SHALL contain a `text_skeleton` field and an empty `images` array + +### Requirement: File side-channel for image data +All image data SHALL be written to disk files rather than embedded in the MCP response. The MCP response SHALL only contain file paths and metadata. + +#### Scenario: Image files written to temp directory +- **WHEN** `analyze_document` processes a document with images +- **THEN** each image SHALL be written as a separate file under a session-scoped temporary directory, and the MCP response SHALL contain absolute file paths + +#### Scenario: Response payload size +- **WHEN** `analyze_document` processes a document with 50 embedded images +- **THEN** the MCP response JSON SHALL be under 100KB (paths and metadata only, not image data) + +### Requirement: Temporary directory lifecycle management +The MCP server SHALL manage temporary directories for extracted images with automatic cleanup on session end. + +#### Scenario: Directory created per document +- **WHEN** `analyze_document` is called +- **THEN** images SHALL be written to `{server_temp_root}/{document_hash}/images/` + +#### Scenario: Cleanup on server shutdown +- **WHEN** the MCP server's lifespan context exits +- **THEN** all temporary directories under `server_temp_root` SHALL be removed + +#### Scenario: Cleanup of stale directories +- **WHEN** the MCP server starts +- **THEN** temporary directories older than 24 hours SHALL be removed during startup + +### Requirement: analyze_document supports URI input +The `analyze_document` tool SHALL accept the same input types as `convert_to_markdown`: local file paths, `http://` / `https://` URLs, `file://` URIs, and `data:` URIs. + +#### Scenario: Analyze from HTTP URL +- **WHEN** `analyze_document` is called with an `https://` URL +- **THEN** the document SHALL be fetched, processed in extract-only mode, and the response SHALL include text skeleton and image paths diff --git a/openspec/changes/assistant-driven-ocr/tasks.md b/openspec/changes/assistant-driven-ocr/tasks.md new file mode 100644 index 000000000..424553c64 --- /dev/null +++ b/openspec/changes/assistant-driven-ocr/tasks.md @@ -0,0 +1,40 @@ +## 1. 核心库 extract-only 模式 + +- [ ] 1.1 在 `MarkItDown.__init__()` 中添加 `extract_only` 参数,默认为 False,存入实例属性 +- [ ] 1.2 在 `MarkItDown._convert()` 中将 `extract_only` 和 `image_output_dir` 作为 kwargs 传递给转换器 +- [ ] 1.3 改造 `ImageConverter.convert()`:当 `extract_only=True` 时,将图片写入 `image_output_dir`,返回 `![image]()` 占位符和元数据注释,跳过 `llm_caption()` 调用 +- [ ] 1.4 改造 `ImageConverter.accepts()` 确保 extract-only 模式下仍正确识别图片类型 +- [ ] 1.5 为 `ImageConverter` 的 extract-only 模式编写单元测试:验证图片文件生成、路径正确性、元数据注释格式 + +## 2. OCR 插件 extract-only 模式 + +- [ ] 2.1 改造 `PdfConverterWithOCR.convert()`:添加 `extract_only` 分支,提取每页嵌入图片到 `{image_output_dir}/page_{n}_{idx}.{ext}` +- [ ] 2.2 改造 `DocxConverterWithOCR.convert()`:添加 `extract_only` 分支,提取嵌入图片并返回路径引用 +- [ ] 2.3 改造 `PptxConverterWithOCR.convert()`:添加 `extract_only` 分支,提取幻灯片嵌入图片 +- [ ] 2.4 改造 `XlsxConverterWithOCR.convert()`:添加 `extract_only` 分支,提取工作表嵌入图片 +- [ ] 2.5 改造 `LLMVisionOCRService`:在 extract-only 模式下跳过 OCR 调用,仅执行图片提取 +- [ ] 2.6 为 OCR 插件的 extract-only 模式编写单元测试:验证四种格式的图片提取和路径输出 + +## 3. MCP Server analyze_document 工具 + +- [ ] 3.1 在 `markitdown-mcp` 包中注册新的 MCP 工具 `analyze_document`,定义输入 schema(path/URI)和输出 schema(text_skeleton + images 数组 + metadata) +- [ ] 3.2 实现 `analyze_document` 核心逻辑:调用 `MarkItDown(extract_only=True)` 转换文档,收集文本骨架和图片路径列表 +- [ ] 3.3 实现图片目录管理:使用 `tempfile.mkdtemp()` 创建 `{server_temp_root}/{document_hash}/images/` 结构 +- [ ] 3.4 实现图片元数据收集:提取每张图片的 width、height、size_bytes、position 信息 +- [ ] 3.5 实现临时目录生命周期管理:在 `lifespan()` 中添加启动时清理旧目录(>24h)和退出时清理所有临时目录的逻辑 +- [ ] 3.6 支持 URI 输入:复用 `MarkItDown.convert()` 的 URI 处理能力(http/https/file/data) +- [ ] 3.7 为 `analyze_document` 编写集成测试:PDF 带图片、纯文本、HTTP URL 三种场景 + +## 4. 编排层 Skill + +- [ ] 4.1 创建 Skill 目录结构和 SKILL.md 文件 +- [ ] 4.2 编写两阶段工作流说明:Phase 1 调用 `analyze_document`,Phase 2 逐图片读取和 OCR +- [ ] 4.3 编写智能图片选择策略:按文件大小排序、阈值过滤(>10KB 做 OCR,其余摘要)、批量处理指导(每批 5-10 张) +- [ ] 4.4 编写占位符替换指南:如何将图片占位符替换为 OCR 文本或描述性文字 +- [ ] 4.5 编写边界情况处理:无图片文档、超多图片文档(100+)、混合内容文档的策略 + +## 5. 文档与集成验证 + +- [ ] 5.1 更新 README.md:添加 extract-only 模式和 `analyze_document` 工具的使用说明 +- [ ] 5.2 更新 MCP Server 文档:新增 `analyze_document` 工具的参数和返回值说明 +- [ ] 5.3 端到端集成测试:使用 AI 助手 + MCP Server 完整流程验证(文档上传 → analyze_document → 图片 OCR → 最终 Markdown) diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 000000000..f9bc9417c --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,20 @@ +schema: sdd-default + +# Project context (optional) +# This is shown to AI when creating artifacts. +# Add your tech stack, conventions, style guides, domain knowledge, etc. +# Example: +# context: | +# Tech stack: TypeScript, React, Node.js +# We use conventional commits +# Domain: e-commerce platform + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours diff --git a/openspec/schemas/sdd-default/schema.yaml b/openspec/schemas/sdd-default/schema.yaml new file mode 100644 index 000000000..45f61e222 --- /dev/null +++ b/openspec/schemas/sdd-default/schema.yaml @@ -0,0 +1,153 @@ +name: spec-driven +version: 1 +description: Default OpenSpec workflow - proposal → specs → design → tasks +artifacts: + - id: proposal + generates: proposal.md + description: Initial proposal document outlining the change + template: proposal.md + instruction: | + Create the proposal document that establishes WHY this change is needed. + + Sections: + - **Why**: 1-2 sentences on the problem or opportunity. What problem does this solve? Why now? + - **What Changes**: Bullet list of changes. Be specific about new capabilities, modifications, or removals. Mark breaking changes with **BREAKING**. + - **Capabilities**: Identify which specs will be created or modified: + - **New Capabilities**: List capabilities being introduced. Each becomes a new `specs//spec.md`. Use kebab-case names (e.g., `user-auth`, `data-export`). + - **Modified Capabilities**: List existing capabilities whose REQUIREMENTS are changing. Only include if spec-level behavior changes (not just implementation details). Each needs a delta spec file. Check `openspec/specs/` for existing spec names. Leave empty if no requirement changes. + - **Impact**: Affected code, APIs, dependencies, or systems. + + IMPORTANT: The Capabilities section is critical. It creates the contract between + proposal and specs phases. Research existing specs before filling this in. + Each capability listed here will need a corresponding spec file. + + Keep it concise (1-2 pages). Focus on the "why" not the "how" - + implementation details belong in design.md. + + This is the foundation - specs, design, and tasks all build on this. + requires: [] + + - id: specs + generates: "specs/**/*.md" + description: Detailed specifications for the change + template: spec.md + instruction: | + Create specification files that define WHAT the system should do. + + Create one spec file per capability listed in the proposal's Capabilities section. + - New capabilities: use the exact kebab-case name from the proposal (specs//spec.md). + - Modified capabilities: use the existing spec folder name from openspec/specs// when creating the delta spec at specs//spec.md. + + Delta operations (use ## headers): + - **ADDED Requirements**: New capabilities + - **MODIFIED Requirements**: Changed behavior - MUST include full updated content + - **REMOVED Requirements**: Deprecated features - MUST include **Reason** and **Migration** + - **RENAMED Requirements**: Name changes only - use FROM:/TO: format + + Format requirements: + - Each requirement: `### Requirement: ` followed by description + - Use SHALL/MUST for normative requirements (avoid should/may) + - Each scenario: `#### Scenario: ` with WHEN/THEN format + - **CRITICAL**: Scenarios MUST use exactly 4 hashtags (`####`). Using 3 hashtags or bullets will fail silently. + - Every requirement MUST have at least one scenario. + + MODIFIED requirements workflow: + 1. Locate the existing requirement in openspec/specs//spec.md + 2. Copy the ENTIRE requirement block (from `### Requirement:` through all scenarios) + 3. Paste under `## MODIFIED Requirements` and edit to reflect new behavior + 4. Ensure header text matches exactly (whitespace-insensitive) + + Common pitfall: Using MODIFIED with partial content loses detail at archive time. + If adding new concerns without changing existing behavior, use ADDED instead. + + Example: + ``` + ## ADDED Requirements + + ### Requirement: User can export data + The system SHALL allow users to export their data in CSV format. + + #### Scenario: Successful export + - **WHEN** user clicks "Export" button + - **THEN** system downloads a CSV file with all user data + + ## REMOVED Requirements + + ### Requirement: Legacy export + **Reason**: Replaced by new export system + **Migration**: Use new export endpoint at /api/v2/export + ``` + + Specs should be testable - each scenario is a potential test case. + requires: + - proposal + + - id: design + generates: design.md + description: Technical design document with implementation details + template: design.md + instruction: | + Create the design document that explains HOW to implement the change. + + When to include design.md (create only if any apply): + - Cross-cutting change (multiple services/modules) or new architectural pattern + - New external dependency or significant data model changes + - Security, performance, or migration complexity + - Ambiguity that benefits from technical decisions before coding + + Sections: + - **Context**: Background, current state, constraints, stakeholders + - **Goals / Non-Goals**: What this design achieves and explicitly excludes + - **Decisions**: Key technical choices with rationale (why X over Y?). Include alternatives considered for each decision. + - **Risks / Trade-offs**: Known limitations, things that could go wrong. Format: [Risk] → Mitigation + - **Migration Plan**: Steps to deploy, rollback strategy (if applicable) + - **Open Questions**: Outstanding decisions or unknowns to resolve + + Focus on architecture and approach, not line-by-line implementation. + Reference the proposal for motivation and specs for requirements. + + Good design docs explain the "why" behind technical decisions. + requires: + - proposal + + - id: tasks + generates: tasks.md + description: Implementation checklist with trackable tasks + template: tasks.md + instruction: | + Create the task list that breaks down the implementation work. + + **IMPORTANT: Follow the template below exactly.** The apply phase parses + checkbox format to track progress. Tasks not using `- [ ]` won't be tracked. + + Guidelines: + - Group related tasks under ## numbered headings + - Each task MUST be a checkbox: `- [ ] X.Y Task description` + - Tasks should be small enough to complete in one session + - Order tasks by dependency (what must be done first?) + + Example: + ``` + ## 1. Setup + + - [ ] 1.1 Create new module structure + - [ ] 1.2 Add dependencies to package.json + + ## 2. Core Implementation + + - [ ] 2.1 Implement data export function + - [ ] 2.2 Add CSV formatting utilities + ``` + + Reference specs for what needs to be built, design for how to build it. + Each task should be verifiable - you know when it's done. + requires: + - specs + - design + +apply: + requires: [tasks] + tracks: tasks.md + instruction: | + Read context files, work through pending tasks, mark complete as you go. + Pause if you hit blockers or need clarification. diff --git a/openspec/schemas/sdd-default/templates/design.md b/openspec/schemas/sdd-default/templates/design.md new file mode 100644 index 000000000..4ab5bd839 --- /dev/null +++ b/openspec/schemas/sdd-default/templates/design.md @@ -0,0 +1,19 @@ +## Context + + + +## Goals / Non-Goals + +**Goals:** + + +**Non-Goals:** + + +## Decisions + + + +## Risks / Trade-offs + + diff --git a/openspec/schemas/sdd-default/templates/proposal.md b/openspec/schemas/sdd-default/templates/proposal.md new file mode 100644 index 000000000..c79b85d44 --- /dev/null +++ b/openspec/schemas/sdd-default/templates/proposal.md @@ -0,0 +1,23 @@ +## Why + + + +## What Changes + + + +## Capabilities + +### New Capabilities + +- ``: + +### Modified Capabilities + +- ``: + +## Impact + + diff --git a/openspec/schemas/sdd-default/templates/spec.md b/openspec/schemas/sdd-default/templates/spec.md new file mode 100644 index 000000000..095d711c8 --- /dev/null +++ b/openspec/schemas/sdd-default/templates/spec.md @@ -0,0 +1,8 @@ +## ADDED Requirements + +### Requirement: + + +#### Scenario: +- **WHEN** +- **THEN** diff --git a/openspec/schemas/sdd-default/templates/tasks.md b/openspec/schemas/sdd-default/templates/tasks.md new file mode 100644 index 000000000..88ce51ef7 --- /dev/null +++ b/openspec/schemas/sdd-default/templates/tasks.md @@ -0,0 +1,9 @@ +## 1. + +- [ ] 1.1 +- [ ] 1.2 + +## 2. + +- [ ] 2.1 +- [ ] 2.2 diff --git a/packages/markitdown-mcp/pyproject.toml b/packages/markitdown-mcp/pyproject.toml index 746253be5..5d4a8f6f3 100644 --- a/packages/markitdown-mcp/pyproject.toml +++ b/packages/markitdown-mcp/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ ] dependencies = [ "mcp~=1.8.0", - "markitdown[all]>=0.1.1,<0.2.0", + "markitdown>=0.1.1,<0.2.0", ] [project.urls] diff --git a/packages/markitdown-mcp/src/markitdown_mcp/__main__.py b/packages/markitdown-mcp/src/markitdown_mcp/__main__.py index 89f89444e..e81bebd38 100644 --- a/packages/markitdown-mcp/src/markitdown_mcp/__main__.py +++ b/packages/markitdown-mcp/src/markitdown_mcp/__main__.py @@ -1,6 +1,12 @@ import contextlib -import sys +import hashlib +import json import os +import re +import shutil +import sys +import tempfile +import time from collections.abc import AsyncIterator from mcp.server.fastmcp import FastMCP from starlette.applications import Starlette @@ -13,6 +19,43 @@ from markitdown import MarkItDown import uvicorn + +# --------------------------------------------------------------------------- +# Temporary directory management (module-level) +# --------------------------------------------------------------------------- + +_temp_root: str = tempfile.mkdtemp(prefix="markitdown_mcp_") + +# Image file extensions we recognise when scanning output directories +_IMAGE_EXTENSIONS = frozenset( + {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tiff", ".tif", ".svg", ".ico"} +) + + +def _cleanup_old_dirs(root: str, max_age_seconds: int = 86400) -> None: + """Remove sub-directories under *root* that are older than *max_age_seconds*.""" + now = time.time() + try: + for entry in os.scandir(root): + if entry.is_dir(): + try: + if now - entry.stat().st_mtime > max_age_seconds: + shutil.rmtree(entry.path, ignore_errors=True) + except OSError: + pass + except FileNotFoundError: + pass + + +def _cleanup_all(root: str) -> None: + """Remove the entire *root* directory tree.""" + shutil.rmtree(root, ignore_errors=True) + + +# --------------------------------------------------------------------------- +# FastMCP server +# --------------------------------------------------------------------------- + # Initialize FastMCP server for MarkItDown (SSE) mcp = FastMCP("markitdown") @@ -23,6 +66,154 @@ async def convert_to_markdown(uri: str) -> str: return MarkItDown(enable_plugins=check_plugins_enabled()).convert_uri(uri).markdown +@mcp.tool() +async def analyze_document(path: str) -> str: + """Analyze a document and extract its text skeleton together with embedded images. + + Accepts a local file path or a URI (http:, https:, file:, data:). + Returns a JSON string containing: + - text_skeleton: the markdown text with image placeholder references + - images: a list of extracted images with path, position, size and dimensions + - metadata: source path and image count + """ + # ---- validate input ------------------------------------------------ + is_uri = re.match(r"^(https?|file|data):", path) is not None + if not is_uri and not os.path.exists(path): + return json.dumps({"error": f"File not found: {path}"}) + + # ---- prepare per-document temp directory ---------------------------- + doc_hash = hashlib.sha256(path.encode("utf-8", errors="replace")).hexdigest()[:16] + doc_dir = os.path.join(_temp_root, doc_hash) + image_dir = os.path.join(doc_dir, "images") + os.makedirs(image_dir, exist_ok=True) + + # ---- run conversion ------------------------------------------------- + try: + md_instance = MarkItDown( + enable_plugins=check_plugins_enabled(), + extract_only=True, + image_output_dir=image_dir, + ) + result = md_instance.convert(path) + text_skeleton: str = result.markdown or "" + except Exception as exc: + return json.dumps({"error": f"Conversion failed: {exc}"}) + + # ---- collect image files on disk ------------------------------------ + image_files: list[str] = [] + for fname in sorted(os.listdir(image_dir)): + ext = os.path.splitext(fname)[1].lower() + if ext in _IMAGE_EXTENSIONS: + image_files.append(os.path.join(image_dir, fname)) + + # ---- parse image references from text skeleton ---------------------- + # Pattern matches an optional HTML comment with image metadata, followed + # by the standard markdown image reference ![alt](path) + img_ref_pattern = re.compile( + r"(?:\s*\n?)?" + r"!\[(?P[^\]]*)\]\((?P[^)]+)\)" + ) + + images_out: list[dict] = [] + for match in img_ref_pattern.finditer(text_skeleton): + src = match.group("src") + meta_str = match.group("meta") or "" + + # Resolve to absolute path on disk + if os.path.isabs(src): + img_path = src + else: + # Relative paths are resolved against the document's directory + base_dir = os.path.dirname(path) if not is_uri else "" + img_path = os.path.normpath(os.path.join(base_dir, src)) + + # File size + size_bytes: int | None = None + try: + size_bytes = os.path.getsize(img_path) + except OSError: + pass + + # Dimensions -- try the metadata comment first (e.g. "1920x1080, 239KB"), + # then fall back to PIL. + width: int | None = None + height: int | None = None + + dim_match = re.search(r"(\d+)\s*x\s*(\d+)", meta_str) + if dim_match: + width, height = int(dim_match.group(1)), int(dim_match.group(2)) + else: + try: + from PIL import Image as PILImage + + with PILImage.open(img_path) as img: + width, height = img.size + except Exception: + pass + + # Position: a short context snippet around the image reference + start = max(0, match.start() - 60) + end = min(len(text_skeleton), match.end() + 60) + context_before = text_skeleton[start : match.start()].strip().split("\n")[-1] + context_after = text_skeleton[match.end() : end].strip().split("\n")[0] + position_parts: list[str] = [] + if context_before: + position_parts.append(f"...{context_before}") + position_parts.append("[image]") + if context_after: + position_parts.append(f"{context_after}...") + position = " ".join(position_parts) + + images_out.append( + { + "path": img_path, + "position": position, + "size_bytes": size_bytes, + "width": width, + "height": height, + } + ) + + # Also account for image files that were extracted but not referenced in + # the text skeleton (e.g. embedded attachments). + referenced_paths = {img["path"] for img in images_out} + for fpath in image_files: + if fpath not in referenced_paths: + size_bytes = None + try: + size_bytes = os.path.getsize(fpath) + except OSError: + pass + width, height = None, None + try: + from PIL import Image as PILImage + + with PILImage.open(fpath) as img: + width, height = img.size + except Exception: + pass + images_out.append( + { + "path": fpath, + "position": "[unreferenced image]", + "size_bytes": size_bytes, + "width": width, + "height": height, + } + ) + + # ---- build response ------------------------------------------------- + response = { + "text_skeleton": text_skeleton, + "images": images_out, + "metadata": { + "source": path, + "image_count": len(images_out), + }, + } + return json.dumps(response, ensure_ascii=False) + + def check_plugins_enabled() -> bool: return os.getenv("MARKITDOWN_ENABLE_PLUGINS", "false").strip().lower() in ( "true", @@ -59,13 +250,16 @@ async def handle_streamable_http( @contextlib.asynccontextmanager async def lifespan(app: Starlette) -> AsyncIterator[None]: - """Context manager for session manager.""" + """Context manager for session manager with temp-dir lifecycle.""" + # Startup: purge stale temp directories older than 24 h + _cleanup_old_dirs(_temp_root, max_age_seconds=86400) async with session_manager.run(): print("Application started with StreamableHTTP session manager!") try: yield finally: print("Application shutting down...") + _cleanup_all(_temp_root) return Starlette( debug=debug, @@ -120,7 +314,7 @@ def main(): "WARNING: The server is being bound to a non-localhost interface " f"({host}).\n" "This exposes the server to other machines on the network or Internet.\n" - "The server has NO authentication and runs with your user's privileges.\n" + "The server has NO authentication and runs with the user's privileges.\n" "Any process or user that can reach this interface can read files and\n" "fetch network resources accessible to this user.\n" "Only proceed if you understand the security implications.\n", @@ -133,7 +327,12 @@ def main(): port=args.port if args.port else 3001, ) else: - mcp.run() + # STDIO mode: clean stale dirs on start, clean up on exit + _cleanup_old_dirs(_temp_root, max_age_seconds=86400) + try: + mcp.run() + finally: + _cleanup_all(_temp_root) if __name__ == "__main__": diff --git a/packages/markitdown-ocr/src/markitdown_ocr/_docx_converter_with_ocr.py b/packages/markitdown-ocr/src/markitdown_ocr/_docx_converter_with_ocr.py index f2463de11..1469dfcce 100644 --- a/packages/markitdown-ocr/src/markitdown_ocr/_docx_converter_with_ocr.py +++ b/packages/markitdown-ocr/src/markitdown_ocr/_docx_converter_with_ocr.py @@ -4,8 +4,10 @@ """ import io +import os import re import sys +import tempfile from typing import Any, BinaryIO, Optional from markitdown.converters import HtmlConverter @@ -15,7 +17,7 @@ MissingDependencyException, MISSING_DEPENDENCY_MESSAGE, ) -from ._ocr_service import LLMVisionOCRService +from ._ocr_service import LLMVisionOCRService, format_image_reference # Try loading dependencies _dependency_exc_info = None @@ -82,6 +84,14 @@ def convert( kwargs.get("ocr_service") or self.ocr_service ) + # --- extract_only mode: skip OCR, emit image file references --- + if kwargs.get("extract_only", False): + image_output_dir = kwargs.get("image_output_dir") or tempfile.mkdtemp( + prefix="markitdown_ocr_" + ) + os.makedirs(image_output_dir, exist_ok=True) + return self._convert_extract_only(file_stream, image_output_dir, **kwargs) + if ocr_service: # 1. Extract and OCR images — returns raw text per image file_stream.seek(0) @@ -187,3 +197,92 @@ def replace_img(match: re.Match) -> str: # type: ignore[type-arg] result += f"

{_PLACEHOLDER.format(i)}

" return result, ocr_texts + + def _convert_extract_only( + self, file_stream: BinaryIO, image_output_dir: str, **kwargs: Any + ) -> DocumentConverterResult: + """ + Extract-only mode: extract text via mammoth and save embedded images to disk. + No OCR is performed; images are referenced via file paths. + """ + from PIL import Image + + # 1. Extract images from DOCX and save to disk + file_stream.seek(0) + doc = Document(file_stream) + + image_paths: list[str] = [] # ordered list of saved image paths + img_idx = 0 + for rel in doc.part.rels.values(): + if "image" in rel.target_ref.lower(): + try: + image_bytes = rel.target_part.blob + + # Determine extension and dimensions + ext = "png" + width, height = None, None + try: + pil_img = Image.open(io.BytesIO(image_bytes)) + fmt = pil_img.format + if fmt: + ext = fmt.lower() + if ext == "jpeg": + ext = "jpg" + width, height = pil_img.size + except Exception: + pass + + filename = f"docx_image_{img_idx}.{ext}" + filepath = os.path.join(image_output_dir, filename) + with open(filepath, "wb") as f: + f.write(image_bytes) + + image_paths.append( + format_image_reference( + filepath, + width=width, + height=height, + size_bytes=len(image_bytes), + ) + ) + img_idx += 1 + except Exception: + continue + + # 2. Convert DOCX -> HTML via mammoth + file_stream.seek(0) + pre_process_stream = pre_process_docx(file_stream) + html_result = mammoth.convert_to_html( + pre_process_stream, style_map=kwargs.get("style_map") + ).value + + # 3. Replace tags with placeholders + _EO_PLACEHOLDER = "MARKITDOWNEXTRACTONLY{}" + used: list[int] = [] + + def replace_img(match: re.Match) -> str: # type: ignore[type-arg] + for i in range(len(image_paths)): + if i not in used: + used.append(i) + return f"

{_EO_PLACEHOLDER.format(i)}

" + return "" + + html_with_placeholders = re.sub(r"]*>", replace_img, html_result) + + # Any images that had no matching tag go at the end + for i in range(len(image_paths)): + if i not in used: + html_with_placeholders += f"

{_EO_PLACEHOLDER.format(i)}

" + + # 4. Convert HTML -> markdown + md_result = self._html_converter.convert_string( + html_with_placeholders, **kwargs + ) + md = md_result.markdown + + # 5. Swap placeholders for image references + for i, img_ref in enumerate(image_paths): + placeholder = _EO_PLACEHOLDER.format(i) + md = md.replace(placeholder, img_ref) + + return DocumentConverterResult(markdown=md) diff --git a/packages/markitdown-ocr/src/markitdown_ocr/_ocr_service.py b/packages/markitdown-ocr/src/markitdown_ocr/_ocr_service.py index 2885e1f47..1e25826ea 100644 --- a/packages/markitdown-ocr/src/markitdown_ocr/_ocr_service.py +++ b/packages/markitdown-ocr/src/markitdown_ocr/_ocr_service.py @@ -4,12 +4,56 @@ """ import base64 +import os from typing import Any, BinaryIO from dataclasses import dataclass from markitdown import StreamInfo +def format_image_reference( + image_path: str, + width: int | None = None, + height: int | None = None, + size_bytes: int | None = None, +) -> str: + """ + Format an annotated markdown image reference for extract-only mode. + + Args: + image_path: Filesystem path to the saved image. + width: Image width in pixels (optional). + height: Image height in pixels (optional). + size_bytes: Image file size in bytes (optional). + + Returns: + Markdown string with metadata comment and image link. + """ + parts: list[str] = [] + + # Build metadata comment + meta_parts: list[str] = [] + if width is not None and height is not None: + meta_parts.append(f"{width}x{height}") + if size_bytes is not None: + size_kb = round(size_bytes / 1024, 1) + meta_parts.append(f"{size_kb}KB") + + if meta_parts: + parts.append(f"") + else: + # Fallback: try to get file size from disk + try: + file_size = os.path.getsize(image_path) + size_kb = round(file_size / 1024, 1) + parts.append(f"") + except Exception: + pass + + parts.append(f"![image]({image_path})") + return "\n".join(parts) + + @dataclass class OCRResult: """Result from OCR extraction.""" diff --git a/packages/markitdown-ocr/src/markitdown_ocr/_pdf_converter_with_ocr.py b/packages/markitdown-ocr/src/markitdown_ocr/_pdf_converter_with_ocr.py index c1dc0f613..fad9943c8 100644 --- a/packages/markitdown-ocr/src/markitdown_ocr/_pdf_converter_with_ocr.py +++ b/packages/markitdown-ocr/src/markitdown_ocr/_pdf_converter_with_ocr.py @@ -4,7 +4,9 @@ """ import io +import os import sys +import tempfile from typing import Any, BinaryIO, Optional from markitdown import DocumentConverter, DocumentConverterResult, StreamInfo @@ -12,7 +14,7 @@ MissingDependencyException, MISSING_DEPENDENCY_MESSAGE, ) -from ._ocr_service import LLMVisionOCRService +from ._ocr_service import LLMVisionOCRService, format_image_reference # Import dependencies _dependency_exc_info = None @@ -181,6 +183,15 @@ def convert( file_stream.seek(0) pdf_bytes = io.BytesIO(file_stream.read()) + # --- extract_only mode: skip OCR, emit image file references --- + extract_only = kwargs.get("extract_only", False) + if extract_only: + image_output_dir = kwargs.get("image_output_dir") or tempfile.mkdtemp( + prefix="markitdown_ocr_" + ) + os.makedirs(image_output_dir, exist_ok=True) + return self._convert_extract_only(pdf_bytes, image_output_dir) + markdown_content = [] try: @@ -310,6 +321,74 @@ def convert( return DocumentConverterResult(markdown=markdown) + def _convert_extract_only( + self, pdf_bytes: io.BytesIO, image_output_dir: str + ) -> DocumentConverterResult: + """ + Extract-only mode: extract text skeleton and save embedded images to disk. + No OCR is performed; images are referenced via file paths. + """ + markdown_content: list[str] = [] + + try: + with pdfplumber.open(pdf_bytes) as pdf: + for page_num, page in enumerate(pdf.pages, 1): + markdown_content.append(f"\n## Page {page_num}\n") + + # Extract regular text + text_content = page.extract_text() or "" + if text_content.strip(): + markdown_content.append(text_content.strip()) + + # Extract and save images + images_on_page = self._extract_page_images(pdf_bytes, page_num) + for idx, img_info in enumerate(images_on_page): + img_stream: io.BytesIO = img_info["stream"] + img_stream.seek(0) + img_data = img_stream.read() + + # Determine extension from image data (default png) + ext = "png" + try: + pil_img = Image.open(io.BytesIO(img_data)) + fmt = pil_img.format + if fmt: + ext = fmt.lower() + if ext == "jpeg": + ext = "jpg" + width, height = pil_img.size + except Exception: + width, height = None, None + + filename = f"page_{page_num}_{idx}.{ext}" + filepath = os.path.join(image_output_dir, filename) + with open(filepath, "wb") as f: + f.write(img_data) + + img_ref = format_image_reference( + filepath, + width=width, + height=height, + size_bytes=len(img_data), + ) + markdown_content.append(f"\n{img_ref}\n") + + markdown = "\n\n".join(markdown_content).strip() + + # Fallback to pdfminer if empty + if not markdown: + pdf_bytes.seek(0) + markdown = pdfminer.high_level.extract_text(pdf_bytes) + + except Exception: + try: + pdf_bytes.seek(0) + markdown = pdfminer.high_level.extract_text(pdf_bytes) + except Exception: + markdown = "" + + return DocumentConverterResult(markdown=markdown) + def _extract_page_images(self, pdf_bytes: io.BytesIO, page_num: int) -> list[dict]: """ Extract images from a PDF page using pdfplumber. diff --git a/packages/markitdown-ocr/src/markitdown_ocr/_pptx_converter_with_ocr.py b/packages/markitdown-ocr/src/markitdown_ocr/_pptx_converter_with_ocr.py index 7e91ed6b4..d730a483f 100644 --- a/packages/markitdown-ocr/src/markitdown_ocr/_pptx_converter_with_ocr.py +++ b/packages/markitdown-ocr/src/markitdown_ocr/_pptx_converter_with_ocr.py @@ -4,7 +4,9 @@ """ import io +import os import sys +import tempfile from typing import Any, BinaryIO, Optional from typing import BinaryIO, Any, Optional @@ -15,7 +17,7 @@ MissingDependencyException, MISSING_DEPENDENCY_MESSAGE, ) -from ._ocr_service import LLMVisionOCRService +from ._ocr_service import LLMVisionOCRService, format_image_reference _dependency_exc_info = None try: @@ -74,6 +76,14 @@ def convert( ) llm_client = kwargs.get("llm_client") + # --- extract_only mode: skip OCR, emit image file references --- + if kwargs.get("extract_only", False): + image_output_dir = kwargs.get("image_output_dir") or tempfile.mkdtemp( + prefix="markitdown_ocr_" + ) + os.makedirs(image_output_dir, exist_ok=True) + return self._convert_extract_only(file_stream, image_output_dir, **kwargs) + presentation = pptx.Presentation(file_stream) md_content = "" slide_num = 0 @@ -185,6 +195,111 @@ def get_shape_content(shape, **kwargs): return DocumentConverterResult(markdown=md_content.strip()) + def _convert_extract_only( + self, file_stream: BinaryIO, image_output_dir: str, **kwargs: Any + ) -> DocumentConverterResult: + """ + Extract-only mode: extract text and save embedded images to disk. + No OCR or LLM description is performed; images are referenced via file paths. + """ + from PIL import Image + + presentation = pptx.Presentation(file_stream) + md_content = "" + slide_num = 0 + global_img_idx = 0 + + for slide in presentation.slides: + slide_num += 1 + md_content += f"\n\n\n" + + title = slide.shapes.title + + def get_shape_content_extract_only(shape, **kw): + nonlocal md_content, global_img_idx + + # Pictures + if self._is_picture(shape): + try: + image_bytes = shape.image.blob + + ext = "png" + width, height = None, None + try: + pil_img = Image.open(io.BytesIO(image_bytes)) + fmt = pil_img.format + if fmt: + ext = fmt.lower() + if ext == "jpeg": + ext = "jpg" + width, height = pil_img.size + except Exception: + pass + + filename = f"slide_{slide_num}_{global_img_idx}.{ext}" + filepath = os.path.join(image_output_dir, filename) + with open(filepath, "wb") as f: + f.write(image_bytes) + + img_ref = format_image_reference( + filepath, + width=width, + height=height, + size_bytes=len(image_bytes), + ) + md_content += f"\n{img_ref}\n" + global_img_idx += 1 + except Exception: + pass + + # Tables + if self._is_table(shape): + md_content += self._convert_table_to_markdown(shape.table, **kw) + + # Charts + if shape.has_chart: + md_content += self._convert_chart_to_markdown(shape.chart) + + # Text areas + elif shape.has_text_frame: + if shape == title: + md_content += "# " + shape.text.lstrip() + "\n" + else: + md_content += shape.text + "\n" + + # Group Shapes + if shape.shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.GROUP: + sorted_shapes = sorted( + shape.shapes, + key=lambda x: ( + float("-inf") if not x.top else x.top, + float("-inf") if not x.left else x.left, + ), + ) + for subshape in sorted_shapes: + get_shape_content_extract_only(subshape, **kw) + + sorted_shapes = sorted( + slide.shapes, + key=lambda x: ( + float("-inf") if not x.top else x.top, + float("-inf") if not x.left else x.left, + ), + ) + for shape in sorted_shapes: + get_shape_content_extract_only(shape, **kwargs) + + md_content = md_content.strip() + + if slide.has_notes_slide: + md_content += "\n\n### Notes:\n" + notes_frame = slide.notes_slide.notes_text_frame + if notes_frame is not None: + md_content += notes_frame.text + md_content = md_content.strip() + + return DocumentConverterResult(markdown=md_content.strip()) + def _is_picture(self, shape): if shape.shape_type == pptx.enum.shapes.MSO_SHAPE_TYPE.PICTURE: return True diff --git a/packages/markitdown-ocr/src/markitdown_ocr/_xlsx_converter_with_ocr.py b/packages/markitdown-ocr/src/markitdown_ocr/_xlsx_converter_with_ocr.py index 481e07195..d2b4b6ebc 100644 --- a/packages/markitdown-ocr/src/markitdown_ocr/_xlsx_converter_with_ocr.py +++ b/packages/markitdown-ocr/src/markitdown_ocr/_xlsx_converter_with_ocr.py @@ -4,7 +4,9 @@ """ import io +import os import sys +import tempfile from typing import Any, BinaryIO, Optional from markitdown.converters import HtmlConverter @@ -13,7 +15,7 @@ MissingDependencyException, MISSING_DEPENDENCY_MESSAGE, ) -from ._ocr_service import LLMVisionOCRService +from ._ocr_service import LLMVisionOCRService, format_image_reference # Try loading dependencies _xlsx_dependency_exc_info = None @@ -76,6 +78,14 @@ def convert( kwargs.get("ocr_service") or self.ocr_service ) + # --- extract_only mode: skip OCR, emit image file references --- + if kwargs.get("extract_only", False): + image_output_dir = kwargs.get("image_output_dir") or tempfile.mkdtemp( + prefix="markitdown_ocr_" + ) + os.makedirs(image_output_dir, exist_ok=True) + return self._convert_extract_only(file_stream, image_output_dir, **kwargs) + if ocr_service: # Remove ocr_service from kwargs to avoid duplicate argument error kwargs_without_ocr = {k: v for k, v in kwargs.items() if k != "ocr_service"} @@ -213,6 +223,108 @@ def _extract_and_ocr_sheet_images( return results + def _convert_extract_only( + self, file_stream: BinaryIO, image_output_dir: str, **kwargs: Any + ) -> DocumentConverterResult: + """ + Extract-only mode: extract table data and save embedded images to disk. + No OCR is performed; images are referenced via file paths. + """ + from PIL import Image + + file_stream.seek(0) + wb = load_workbook(file_stream) + + # Filter out ocr_service / extract_only from kwargs passed to HTML converter + html_kwargs = { + k: v + for k, v in kwargs.items() + if k not in ("ocr_service", "extract_only", "image_output_dir") + } + + md_content = "" + + for sheet_name in wb.sheetnames: + sheet = wb[sheet_name] + md_content += f"## {sheet_name}\n\n" + + # Convert sheet data to markdown table + file_stream.seek(0) + try: + df = pd.read_excel( + file_stream, sheet_name=sheet_name, engine="openpyxl" + ) + html_content = df.to_html(index=False) + md_content += ( + self._html_converter.convert_string( + html_content, **html_kwargs + ).markdown.strip() + + "\n\n" + ) + except Exception: + pass + + # Extract images and save to disk + img_idx = 0 + if hasattr(sheet, "_images"): + for img in sheet._images: + try: + # Get image data + if hasattr(img, "_data"): + image_data = img._data() + elif hasattr(img, "image"): + image_data = img.image + else: + continue + + # Determine extension and dimensions + ext = "png" + width, height = None, None + try: + pil_img = Image.open(io.BytesIO(image_data)) + fmt = pil_img.format + if fmt: + ext = fmt.lower() + if ext == "jpeg": + ext = "jpg" + width, height = pil_img.size + except Exception: + pass + + # Get cell reference for naming + cell_ref = "unknown" + if hasattr(img, "anchor"): + anchor = img.anchor + if hasattr(anchor, "_from"): + from_cell = anchor._from + if hasattr(from_cell, "col") and hasattr( + from_cell, "row" + ): + col_letter = self._column_number_to_letter( + from_cell.col + ) + cell_ref = f"{col_letter}{from_cell.row + 1}" + + filename = f"xlsx_{sheet_name}_{img_idx}.{ext}" + # Sanitize filename (sheet names may have spaces/special chars) + filename = filename.replace(" ", "_").replace("/", "_") + filepath = os.path.join(image_output_dir, filename) + with open(filepath, "wb") as f: + f.write(image_data) + + img_ref = format_image_reference( + filepath, + width=width, + height=height, + size_bytes=len(image_data), + ) + md_content += f"\n{img_ref}\n\n" + img_idx += 1 + except Exception: + continue + + return DocumentConverterResult(markdown=md_content.strip()) + @staticmethod def _column_number_to_letter(n: int) -> str: """Convert column number to Excel column letter (0-indexed).""" diff --git a/packages/markitdown/src/markitdown/_markitdown.py b/packages/markitdown/src/markitdown/_markitdown.py index f6aa4df0e..f47e9cf58 100644 --- a/packages/markitdown/src/markitdown/_markitdown.py +++ b/packages/markitdown/src/markitdown/_markitdown.py @@ -100,10 +100,13 @@ def __init__( *, enable_builtins: Union[None, bool] = None, enable_plugins: Union[None, bool] = None, + extract_only: bool = False, **kwargs, ): self._builtins_enabled = False self._plugins_enabled = False + self._extract_only = extract_only + self._image_output_dir: Union[str | None] = kwargs.get("image_output_dir") requests_session = kwargs.get("requests_session") if requests_session is None: @@ -600,6 +603,12 @@ def _convert( if "exiftool_path" not in _kwargs and self._exiftool_path is not None: _kwargs["exiftool_path"] = self._exiftool_path + # Propagate extract-only mode settings + if "extract_only" not in _kwargs: + _kwargs["extract_only"] = self._extract_only + if "image_output_dir" not in _kwargs and self._image_output_dir is not None: + _kwargs["image_output_dir"] = self._image_output_dir + # Add the list of converters for nested processing _kwargs["_parent_converters"] = self._converters diff --git a/packages/markitdown/src/markitdown/converters/_image_converter.py b/packages/markitdown/src/markitdown/converters/_image_converter.py index dd8fbac61..b544f5ba5 100644 --- a/packages/markitdown/src/markitdown/converters/_image_converter.py +++ b/packages/markitdown/src/markitdown/converters/_image_converter.py @@ -1,6 +1,7 @@ from typing import BinaryIO, Any, Union import base64 import mimetypes +import os from ._exiftool import exiftool_metadata from .._base_converter import DocumentConverter, DocumentConverterResult from .._stream_info import StreamInfo @@ -42,6 +43,12 @@ def convert( stream_info: StreamInfo, **kwargs: Any, # Options to pass to the converter ) -> DocumentConverterResult: + extract_only = kwargs.get("extract_only", False) + + # --- Extract-only mode: save image to disk, return path reference --- + if extract_only: + return self._extract_only(file_stream, stream_info, **kwargs) + md_content = "" # Add metadata @@ -84,6 +91,101 @@ def convert( markdown=md_content, ) + def _extract_only( + self, + file_stream: BinaryIO, + stream_info: StreamInfo, + **kwargs: Any, + ) -> DocumentConverterResult: + """Extract image to disk and return a markdown reference with metadata.""" + import tempfile + import uuid + + # Determine output directory + image_output_dir = kwargs.get("image_output_dir") + if image_output_dir is None: + image_output_dir = tempfile.mkdtemp(prefix="markitdown_images_") + + os.makedirs(image_output_dir, exist_ok=True) + + # Determine file extension + extension = stream_info.extension or "" + if not extension: + ext = mimetypes.guess_extension(stream_info.mimetype or "") or ".png" + else: + ext = extension if extension.startswith(".") else "." + extension + + # Generate unique filename + unique_id = uuid.uuid4().hex[:12] + filename = f"img_{unique_id}{ext}" + file_path = os.path.join(image_output_dir, filename) + + # Read image data and collect metadata + cur_pos = file_stream.tell() + try: + image_data = file_stream.read() + finally: + file_stream.seek(cur_pos) + + size_bytes = len(image_data) + + # Get image dimensions if possible + width, height = self._get_image_dimensions(image_data, ext) + + # Write image to disk + with open(file_path, "wb") as f: + f.write(image_data) + + # Build markdown output with metadata comment + md_parts = [] + if width and height: + md_parts.append(f"") + else: + md_parts.append(f"") + md_parts.append(f"![image]({file_path})") + + return DocumentConverterResult( + markdown="\n".join(md_parts), + ) + + def _get_image_dimensions( + self, image_data: bytes, ext: str + ) -> tuple: + """Try to get image dimensions without external dependencies.""" + try: + from PIL import Image + img = Image.open(__import__("io").BytesIO(image_data)) + return img.size # (width, height) + except ImportError: + pass + + # Fallback: try to parse PNG/JPEG headers + if ext.lower() in (".png",) and len(image_data) >= 24: + import struct + try: + w, h = struct.unpack(">II", image_data[16:24]) + return (w, h) + except Exception: + pass + + if ext.lower() in (".jpg", ".jpeg") and len(image_data) > 10: + try: + import struct + idx = 2 + while idx < len(image_data) - 9: + if image_data[idx] != 0xFF: + break + marker = image_data[idx + 1] + if marker in (0xC0, 0xC1, 0xC2): + h, w = struct.unpack(">HH", image_data[idx + 5:idx + 9]) + return (w, h) + length = struct.unpack(">H", image_data[idx + 2:idx + 4])[0] + idx += 2 + length + except Exception: + pass + + return (None, None) + def _get_llm_description( self, file_stream: BinaryIO, diff --git a/repowiki/Azure_Service_Converters.md b/repowiki/Azure_Service_Converters.md new file mode 100644 index 000000000..5798f2f4f --- /dev/null +++ b/repowiki/Azure_Service_Converters.md @@ -0,0 +1,279 @@ +# Azure 服务转换器模块 + +## 模块简介 + +Azure 服务转换器模块(Azure Service Converters)是 markitdown-CN 中负责集成 Azure 云端 AI 服务进行高质量文档转换的核心模块。该模块包含两个主要转换器,分别对接 **Azure Content Understanding**(内容理解)和 **Azure Document Intelligence**(文档智能)两项 Azure 认知服务,将各类文件转换为结构化 Markdown 输出。 + +### 核心价值 + +- **多模态支持**:涵盖文档、图片、音频、视频四大类文件格式 +- **智能路由**:根据文件模态自动选择最佳分析器 +- **灵活认证**:支持 API Key、Token、DefaultAzureCredential 多种认证方式 +- **可选依赖**:通过 pip extras 按需安装,不使用时不增加包体积 + +--- + +## 架构总览 + +```mermaid +graph TB + subgraph AzureConverters[Azure 服务转换器模块] + CUConv[ContentUnderstandingConverter] + DIConv[DocumentIntelligenceConverter] + end + + subgraph AzureServices[Azure 云服务] + CUService[Azure Content Understanding] + DIService[Azure Document Intelligence] + end + + subgraph Auth[认证层] + AKC[AzureKeyCredential] + TC[TokenCredential] + DAC[DefaultAzureCredential] + end + + subgraph FileTypeDetection[文件类型检测] + DetectExt[扩展名匹配] + DetectMime[MIME 类型匹配] + end + + CUConv -->|begin_analyze_binary| CUService + DIConv -->|begin_analyze_document| DIService + CUConv --> Auth + DIConv --> Auth + CUConv --> FileTypeDetection + DIConv --> FileTypeDetection +``` + +--- + +## 组件详解 + +### 1. ContentUnderstandingConverter + +**源文件**:`_cu_converter.py` + +ContentUnderstandingConverter 是基于 Azure Content Understanding 服务的通用转换器,支持文档、图片、音频和视频四大类文件的高质量转换,并通过 YAML front matter 输出结构化字段。 + +#### 初始化参数 + +| 参数 | 类型 | 说明 | +|------|------|------| +| `endpoint` | `str` | Azure CU 资源端点 URL | +| `credential` | `AzureKeyCredential \| TokenCredential \| None` | 显式凭证。为 None 时回退到 `AZURE_API_KEY` 环境变量,再回退到 `DefaultAzureCredential` | +| `analyzer_id` | `Optional[str]` | 自定义分析器 ID。设置后,转换器在初始化时解析分析器的基础模态,仅将兼容文件路由到该分析器 | +| `file_types` | `Optional[List[ContentUnderstandingFileType]]` | 需要处理的文件类型列表,为 None 时使用全部支持格式 | + +#### 核心转换流程 + +```mermaid +flowchart TD + A[接收文件流] --> B[文件类型检测] + B --> C{支持的文件类型?} + C -->|否| D[抛出 ValueError] + C -->|是| E[获取文件模态] + E --> F{自定义分析器兼容?} + F -->|是| G[使用自定义分析器] + F -->|否| H[使用预构建分析器] + G --> I[读取文件字节] + H --> I + I --> J[调用 begin_analyze_binary] + J --> K[等待分析结果] + K --> L[to_llm_input 格式化] + L --> M[返回 Markdown + YAML front matter] +``` + +#### 智能分析器路由 + +当用户指定 `analyzer_id` 时,转换器实现智能路由机制: + +1. **初始化阶段**:通过 `_resolve_analyzer_modality()` 解析分析器的基础模态(document / image / audio / video) +2. **转换阶段**:通过 `_is_analyzer_compatible()` 判断文件模态与分析器模态是否兼容 +3. **回退策略**:不兼容时自动使用对应模态的预构建分析器 + +兼容性规则: +- **document 分析器**可处理 `document` 和 `image` 模态 +- **其他模态分析器**仅处理相同模态文件 + +#### 支持的模态与文件类型 + +| 模态 | 文件格式 | +|------|----------| +| document | PDF, DOCX, PPTX, XLSX, HTML, TXT, MD, RTF, XML, EML, MSG | +| image | JPEG, PNG, BMP, TIFF, HEIF | +| video | MP4, M4V, MOV, AVI, MKV, WEBM, FLV, WMV | +| audio | WAV, MP3, M4A, FLAC, OGG, AAC, WMA | + +--- + +### 2. DocumentIntelligenceConverter + +**源文件**:`_doc_intel_converter.py` + +DocumentIntelligenceConverter 是基于 Azure Document Intelligence 服务的专业文档转换器,专注于文档布局分析和文本提取,使用 `prebuilt-layout` 模型。 + +#### 初始化参数 + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `endpoint` | `str` | - | Azure Document Intelligence 服务端点 | +| `api_version` | `str` | `"2024-07-31-preview"` | API 版本 | +| `credential` | `AzureKeyCredential \| TokenCredential \| None` | `None` | 认证凭证,回退逻辑同 CU 转换器 | +| `file_types` | `List[DocumentIntelligenceFileType]` | 全部支持格式 | 接受的文件类型列表 | + +#### 转换流程 + +```mermaid +flowchart TD + A[接收文件流] --> B[扩展名和MIME匹配] + B --> C{文件类型匹配?} + C -->|否| D[拒绝处理] + C -->|是| E[判断分析特征] + E --> F{Office 文件类型?} + F -->|是| G[不启用额外特征] + F -->|否| H[启用 OCR + 公式 + 字体] + G --> I[调用 begin_analyze_document] + H --> I + I --> J[获取 AnalyzeResult] + J --> K[清除 HTML 注释] + K --> L[返回 Markdown] +``` + +#### 分析特征策略 + +DocumentIntelligenceConverter 根据文件类型智能选择分析特征: + +- **Office 文件**(DOCX, PPTX, XLSX, HTML):不启用额外 OCR 特征,因为这些格式本身包含结构化数据 +- **非 Office 文件**(PDF, JPEG, PNG, BMP, TIFF):启用以下特征: + - `FORMULAS`:公式提取 + - `OCR_HIGH_RESOLUTION`:高分辨率 OCR + - `STYLE_FONT`:字体样式提取 + +#### 支持的文件类型 + +| 类别 | 格式 | +|------|------| +| 非 OCR 类型 | DOCX, PPTX, XLSX, HTML | +| OCR 类型 | PDF, JPEG, PNG, BMP, TIFF | + +--- + +## 认证机制 + +两个转换器共享相同的认证回退链: + +```mermaid +flowchart LR + A[显式 credential 参数] -->|已提供| B[使用提供的凭证] + A -->|None| C{AZURE_API_KEY 环境变量?} + C -->|存在| D[AzureKeyCredential] + C -->|不存在| E[DefaultAzureCredential] +``` + +### 凭证类型说明 + +| 凭证类型 | 来源 | 适用场景 | +|----------|------|----------| +| `AzureKeyCredential` | `azure.core.credentials` | API Key 认证,适合开发测试 | +| `TokenCredential` | `azure.core.credentials` | Token 认证,适合生产环境 | +| `DefaultAzureCredential` | `azure.identity` | 自动发现凭证,支持多种 Azure 认证方式 | + +--- + +## 文件类型检测子系统 + +### ContentUnderstandingConverter 的检测逻辑 + +文件类型检测通过两级回退实现: + +1. **扩展名匹配**:使用 `_EXTENSION_MAP` 字典将文件扩展名映射到 `ContentUnderstandingFileType` +2. **MIME 类型匹配**:使用 `_detect_file_type_from_mime()` 通过 MIME 前缀匹配 + +辅助函数: + +| 函数 | 职责 | +|------|------| +| `_detect_file_type()` | 入口函数,组合扩展名和 MIME 检测 | +| `_detect_file_type_from_mime()` | MIME 前缀遍历匹配 | +| `_clean_mime_type()` | 清除 MIME 类型中的参数和空白 | +| `_canonical_mime_type()` | 标准化 MIME 类型(含别名解析) | +| `_content_type_for()` | 解析发送给 CU API 的 Content-Type | +| `_get_modality()` | 获取文件类型对应的模态分类 | +| `_resolve_analyzer_modality()` | 解析分析器的基础模态 | +| `_is_analyzer_compatible()` | 判断分析器与文件模态的兼容性 | + +### DocumentIntelligenceConverter 的检测逻辑 + +使用更简洁的检测方式: + +| 函数 | 职责 | +|------|------| +| `_get_file_extensions()` | 根据文件类型列表返回扩展名列表 | +| `_get_mime_type_prefixes()` | 根据文件类型列表返回 MIME 前缀列表 | + +--- + +## 输出格式对比 + +| 特性 | ContentUnderstandingConverter | DocumentIntelligenceConverter | +|------|-------------------------------|-------------------------------| +| 输出格式 | Markdown + YAML front matter | 纯 Markdown | +| 格式化函数 | `to_llm_input()` | 正则清除 HTML 注释 | +| 结构化字段 | 支持(通过 YAML front matter) | 不支持 | +| 模型 | 按模态选择预构建/自定义分析器 | `prebuilt-layout` 固定模型 | + +--- + +## 依赖管理 + +两个转换器均采用可选依赖模式,在初始化时检查依赖是否已安装: + +| 转换器 | pip extras 名称 | 安装命令 | +|--------|-----------------|----------| +| ContentUnderstandingConverter | `az-content-understanding` | `pip install markitdown[az-content-understanding]` | +| DocumentIntelligenceConverter | `az-doc-intel` | `pip install markitdown[az-doc-intel]` | + +当依赖缺失时,转换器抛出 `MissingDependencyException`,提示用户安装对应的可选依赖包。 + +--- + +## 与其他模块的关系 + +- 两个转换器均继承自 [DocumentConverter](Core_Converters.md) 基类 +- 转换结果封装为 [DocumentConverterResult](Core_Converters.md) 对象 +- 文件流信息通过 [StreamInfo](Core_Converters.md) 传递 +- 可选依赖缺失时抛出 [MissingDependencyException](Core_Converters.md) +- 模块通过 [MarkItDown 主入口](MarkItDown_Core.md) 注册和管理 + +--- + +## 使用示例 + +### Content Understanding 转换器 + +```python +from markitdown.converters._cu_converter import ContentUnderstandingConverter + +converter = ContentUnderstandingConverter( + endpoint="https://my-cu-resource.cognitiveservices.azure.com/", + analyzer_id="my-custom-analyzer", # 可选 +) + +result = converter.convert(file_stream, stream_info) +print(result.markdown) # Markdown + YAML front matter +``` + +### Document Intelligence 转换器 + +```python +from markitdown.converters._doc_intel_converter import DocumentIntelligenceConverter + +converter = DocumentIntelligenceConverter( + endpoint="https://my-doc-intel.cognitiveservices.azure.com/", + api_version="2024-07-31-preview", +) + +result = converter.convert(file_stream, stream_info) +print(result.markdown) # 纯 Markdown +``` \ No newline at end of file diff --git a/repowiki/Core_Engine.md b/repowiki/Core_Engine.md new file mode 100644 index 000000000..d92360c45 --- /dev/null +++ b/repowiki/Core_Engine.md @@ -0,0 +1,460 @@ +# Core_Engine -- 核心引擎模块 + +## 模块简介 + +Core_Engine 是 markitdown-CN 项目的核心引擎,负责将各类文档格式统一转换为 Markdown 文本。该模块提供了完整的转换管线,包括输入源识别、文件类型检测、转换器调度、插件扩展以及命令行接口。 + +**核心能力:** + +- 支持本地文件、URL、file:// URI、data: URI、二进制流、HTTP 响应等多种输入源 +- 内置 20+ 种格式转换器(PDF、DOCX、XLSX、PPTX、HTML、图片、音频等) +- 基于 magika 的智能文件类型检测 +- 可插拔的转换器注册机制,支持优先级排序 +- 第三方插件系统(基于 Python entry_points) +- 完整的异常体系与错误恢复机制 +- 命令行工具支持 stdin / 文件 / URL 输入与文件输出 + +--- + +## 架构概览 + +```mermaid +graph TD + subgraph InputSources + A1[Local File] + A2[HTTP URL] + A3[file URI] + A4[data URI] + A5[Binary Stream] + A6[requests.Response] + end + + subgraph MarkItDown Engine + B[MarkItDown] + C[StreamInfo] + D[magika Detection] + E[Converter Registry] + end + + subgraph Converters + F1[PdfConverter] + F2[DocxConverter] + F3[XlsxConverter] + F4[HtmlConverter] + F5[PlainConverter] + F6[Other Converters] + end + + subgraph Output + G[DocumentConverterResult] + H[Markdown Text] + end + + A1 --> B + A2 --> B + A3 --> B + A4 --> B + A5 --> B + A6 --> B + + B --> C + B --> D + D --> C + C --> E + + E --> F1 + E --> F2 + E --> F3 + E --> F4 + E --> F5 + E --> F6 + + F1 --> G + F2 --> G + F3 --> G + F4 --> G + F5 --> G + F6 --> G + + G --> H +``` + +--- + +## 转换流程 + +```mermaid +graph LR + A[Input Source] --> B[Resolve Source Type] + B --> C[Build StreamInfo] + C --> D[magika Type Detection] + D --> E[Generate Guesses] + E --> F[Sort Converters by Priority] + F --> G{Converter accepts?} + G -->|Yes| H[Convert to Markdown] + G -->|No| I[Next Converter] + I --> G + H --> J{Success?} + J -->|Yes| K[Normalize Output] + J -->|No| L[Record FailedAttempt] + L --> I + K --> M[DocumentConverterResult] +``` + +--- + +## 核心组件详解 + +### 1. MarkItDown 类 + +**文件:** `_markitdown.py` + +`MarkItDown` 是整个引擎的入口和调度中心。它管理转换器的注册、输入源的解析以及转换流程的执行。 + +#### 初始化参数 + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `enable_builtins` | `bool \| None` | `None`(等同 `True`) | 是否注册内置转换器 | +| `enable_plugins` | `bool \| None` | `None`(等同 `False`) | 是否加载第三方插件 | +| `llm_client` | `Any` | `None` | LLM 客户端(用于图像描述等) | +| `llm_model` | `str \| None` | `None` | LLM 模型名称 | +| `exiftool_path` | `str \| None` | `None` | exiftool 路径(自动检测) | +| `docintel_endpoint` | `str` | `None` | Azure Document Intelligence 端点 | +| `cu_endpoint` | `str` | `None` | Azure Content Understanding 端点 | + +#### 核心方法 + +- **`convert(source, *, stream_info, **kwargs)`** -- 统一入口,自动根据 source 类型路由到 `convert_uri`、`convert_local`、`convert_stream` 或 `convert_response`。 +- **`convert_local(path, ...)`** -- 转换本地文件。从路径推断扩展名和文件名,构建 StreamInfo。 +- **`convert_uri(uri, ...)`** -- 转换 URI 资源,支持 `file://`、`data:`、`http://`、`https://` 四种协议。 +- **`convert_stream(stream, ...)`** -- 转换二进制流,自动将不可 seek 的流加载到内存。 +- **`convert_response(response, ...)`** -- 转换 `requests.Response` 对象,从 HTTP 头提取 Content-Type、Content-Disposition 等信息。 +- **`register_converter(converter, *, priority)`** -- 注册转换器并设置优先级。 +- **`enable_builtins(**kwargs)`** -- 注册所有内置转换器。 +- **`enable_plugins(**kwargs)`** -- 加载并注册插件转换器。 + +#### 转换器优先级机制 + +转换器按优先级数值升序排列(数值越小优先级越高): + +| 优先级 | 常量 | 适用场景 | +|--------|------|----------| +| 0 | `PRIORITY_SPECIFIC_FILE_FORMAT` | 特定格式转换器(PDF、DOCX 等) | +| 10 | `PRIORITY_GENERIC_FILE_FORMAT` | 通用格式转换器(PlainText、HTML、Zip) | + +后注册的转换器在同优先级下排在前面(插入列表头部),排序使用稳定排序。 + +--- + +### 2. ConverterRegistration 类 + +**文件:** `_markitdown.py` + +转换器注册记录,将 `DocumentConverter` 实例与其优先级绑定在一起。 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `converter` | `DocumentConverter` | 转换器实例 | +| `priority` | `float` | 优先级数值 | + +--- + +### 3. DocumentConverter 抽象基类 + +**文件:** `_base_converter.py` + +所有转换器的抽象超类,定义了两个核心方法: + +#### `accepts(file_stream, stream_info, **kwargs) -> bool` + +快速判断当前转换器是否能处理该文档。判断依据通常为: +- `stream_info.mimetype` -- MIME 类型匹配 +- `stream_info.extension` -- 文件扩展名匹配 +- `stream_info.url` -- 特殊 URL 模式(如 Wikipedia、YouTube) +- `stream_info.filename` -- 特定文件名(如 `Dockerfile`) + +**重要约束:** 如果 `accepts()` 中读取了 `file_stream`,必须在返回前恢复流位置。 + +#### `convert(file_stream, stream_info, **kwargs) -> DocumentConverterResult` + +执行实际的文档转换,返回包含 Markdown 文本的结果对象。 + +--- + +### 4. DocumentConverterResult 类 + +**文件:** `_base_converter.py` + +转换结果的容器类。 + +| 属性 | 类型 | 说明 | +|------|------|------| +| `markdown` | `str` | 转换后的 Markdown 文本 | +| `title` | `str \| None` | 文档标题(可选) | +| `text_content` | `str` | `markdown` 的软弃用别名,新代码应使用 `markdown` | + +支持 `__str__()` 方法直接输出 Markdown 文本。 + +--- + +### 5. StreamInfo 类 + +**文件:** `_stream_info.py` + +流元数据容器,所有字段均可为 `None`,取决于输入源类型。 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `mimetype` | `str \| None` | MIME 类型 | +| `extension` | `str \| None` | 文件扩展名(含点号) | +| `charset` | `str \| None` | 字符编码 | +| `filename` | `str \| None` | 文件名 | +| `local_path` | `str \| None` | 本地文件路径 | +| `url` | `str \| None` | 来源 URL | + +#### `copy_and_update(*args, **kwargs) -> StreamInfo` + +不可变更新方法:复制当前实例,并用传入的 `StreamInfo` 或关键字参数覆盖非 `None` 字段。这一设计保证了 StreamInfo 在转换管线中的安全传递与增量增强。 + +--- + +### 6. 异常体系 + +**文件:** `_exceptions.py` + +```mermaid +graph TD + A[MarkItDownException] --> B[MissingDependencyException] + A --> C[UnsupportedFormatException] + A --> D[FileConversionException] + E[FailedConversionAttempt] --> D +``` + +#### MarkItDownException + +所有 markitdown 异常的基类,继承自 `Exception`。 + +#### MissingDependencyException + +当转换器依赖的可选库未安装时抛出。引擎会自动跳过该转换器,仅在无其他可用转换器时才向上传播错误。 + +#### UnsupportedFormatException + +当没有任何转换器能处理给定文件时抛出,表示文件格式完全不受支持。 + +#### FileConversionException + +当转换器匹配成功但转换过程中发生错误时抛出。包含 `attempts` 字段,记录所有失败的转换尝试。 + +#### FailedConversionAttempt + +记录单次失败的转换尝试: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `converter` | `Any` | 失败的转换器实例 | +| `exc_info` | `tuple \| None` | `sys.exc_info()` 返回的异常信息元组 | + +--- + +### 7. URI 工具函数 + +**文件:** `_uri_utils.py` + +#### `file_uri_to_path(file_uri) -> Tuple[str | None, str]` + +将 `file://` URI 转换为本地文件路径。返回 `(netloc, path)` 元组。仅允许 `localhost` 或空的 netloc。 + +#### `parse_data_uri(uri) -> Tuple[str | None, Dict[str, str], bytes]` + +解析 `data:` URI,返回 `(mime_type, attributes, content)` 三元组。支持 base64 编码和普通 URL 编码的数据。 + +--- + +### 8. 插件加载器 + +**文件:** `_markitdown.py` + +#### `_load_plugins() -> List[Any]` + +基于 `importlib.metadata.entry_points` 的懒加载插件机制: + +1. 查找 `markitdown.plugin` 组下的所有 entry points +2. 逐个加载插件类,加载失败时发出警告但不中断 +3. 使用全局变量缓存已加载的插件,避免重复加载 + +插件通过调用 `plugin.register_converters(markitdown_instance, **kwargs)` 来注册自己的转换器。 + +--- + +### 9. 命令行接口 + +**文件:** `__main__.py` + +#### `main()` + +基于 `argparse` 的 CLI 入口,支持以下使用方式: + +```bash +# 转换本地文件 +markitdown example.pdf + +# 从 stdin 读取 +cat example.pdf | markitdown + +# 输出到文件 +markitdown example.pdf -o output.md + +# 使用 Document Intelligence +markitdown example.pdf -d -e https://your-endpoint + +# 使用 Content Understanding +markitdown example.pdf --use-cu --cu-endpoint https://your-endpoint + +# 启用第三方插件 +markitdown example.pdf -p + +# 列出已安装插件 +markitdown --list-plugins +``` + +**CLI 参数一览:** + +| 参数 | 说明 | +|------|------| +| `filename` | 输入文件路径(可选,缺省读取 stdin) | +| `-o / --output` | 输出文件路径(缺省输出到 stdout) | +| `-x / --extension` | 文件扩展名提示 | +| `-m / --mime-type` | MIME 类型提示 | +| `-c / --charset` | 字符编码提示 | +| `-d / --use-docintel` | 使用 Azure Document Intelligence | +| `--use-cu` | 使用 Azure Content Understanding | +| `-e / --endpoint` | Document Intelligence 端点 | +| `--cu-endpoint` | Content Understanding 端点 | +| `-p / --use-plugins` | 启用第三方插件 | +| `--list-plugins` | 列出已安装插件 | +| `--keep-data-uris` | 保留完整 data URI(默认截断) | + +#### `_handle_output(args, result)` + +将转换结果输出到文件或 stdout。输出到 stdout 时使用 `errors="replace"` 处理编码问题。 + +#### `_exit_with_error(message)` + +打印错误信息并以退出码 1 退出程序。 + +--- + +## 内部转换流程详解 + +`_convert()` 方法是转换管线的核心,其执行逻辑如下: + +1. **排序转换器** -- 按优先级升序排列所有已注册的转换器 +2. **遍历 StreamInfo 猜测** -- 对每个猜测(加上一个空 StreamInfo 兜底),尝试所有转换器 +3. **accepts 检查** -- 调用 `converter.accepts()` 判断是否匹配 +4. **执行转换** -- 调用 `converter.convert()` 执行转换,失败时记录 `FailedConversionAttempt` +5. **流位置恢复** -- 每次转换后通过 `file_stream.seek(cur_pos)` 恢复流位置 +6. **输出规范化** -- 去除行尾空白、合并连续空行 +7. **错误汇总** -- 若所有尝试失败,抛出 `FileConversionException`(含所有失败记录)或 `UnsupportedFormatException` + +### 文件类型检测 (`_get_stream_info_guesses`) + +该方法整合多种信息源生成 StreamInfo 猜测列表: + +1. **扩展名推断** -- 通过 `mimetypes.guess_type` 从扩展名推断 MIME 类型 +2. **MIME 推断** -- 通过 `mimetypes.guess_all_extensions` 从 MIME 类型推断扩展名 +3. **magika 检测** -- 使用 Google magika 模型对流内容进行深度检测,获取 MIME 类型、扩展名、字符集等 +4. **兼容性判断** -- 若 magika 结果与已有信息兼容则合并,否则作为独立猜测加入列表 +5. **字符集检测** -- 对文本类型文件,使用 `charset_normalizer` 从流的前 4KB 推断编码 + +--- + +## 内置转换器注册顺序 + +`enable_builtins()` 方法按以下顺序注册转换器(后注册的同优先级转换器排在前面): + +| 顺序 | 转换器 | 优先级 | +|------|--------|--------| +| 1 | PlainTextConverter | GENERIC (10) | +| 2 | ZipConverter | GENERIC (10) | +| 3 | HtmlConverter | GENERIC (10) | +| 4 | RssConverter | SPECIFIC (0) | +| 5 | WikipediaConverter | SPECIFIC (0) | +| 6 | YouTubeConverter | SPECIFIC (0) | +| 7 | BingSerpConverter | SPECIFIC (0) | +| 8 | DocxConverter | SPECIFIC (0) | +| 9 | XlsxConverter | SPECIFIC (0) | +| 10 | XlsConverter | SPECIFIC (0) | +| 11 | PptxConverter | SPECIFIC (0) | +| 12 | AudioConverter | SPECIFIC (0) | +| 13 | ImageConverter | SPECIFIC (0) | +| 14 | IpynbConverter | SPECIFIC (0) | +| 15 | PdfConverter | SPECIFIC (0) | +| 16 | OutlookMsgConverter | SPECIFIC (0) | +| 17 | EpubConverter | SPECIFIC (0) | +| 18 | CsvConverter | SPECIFIC (0) | + +可选的云服务转换器(如 DocumentIntelligenceConverter、ContentUnderstandingConverter)在提供端点后注册在栈顶,优先级最高。 + +--- + +## 模块间关系 + +Core_Engine 与项目其他模块的关系如下: + +- **[Converters](Converters.md)** -- 提供各类具体格式的转换器实现(PDF、DOCX、HTML 等),通过 `register_converter` 注册到引擎 +- **[Plugin_System](Plugin_System.md)** -- 第三方插件通过 entry_points 机制加载,调用 `register_converters` 向引擎注入自定义转换器 +- **[CLI](CLI.md)** -- 命令行接口模块,通过实例化 `MarkItDown` 类完成文件转换 +- **[Utilities](Utilities.md)** -- 提供 URI 解析等底层工具函数 + +--- + +## 使用示例 + +### 基本用法 + +```python +from markitdown import MarkItDown + +md = MarkItDown() +result = md.convert("report.pdf") +print(result.markdown) +``` + +### 指定流信息 + +```python +from markitdown import MarkItDown, StreamInfo + +md = MarkItDown() +info = StreamInfo(extension=".csv", mimetype="text/csv") +result = md.convert_stream(stream, stream_info=info) +``` + +### 启用插件与云服务 + +```python +md = MarkItDown( + enable_plugins=True, + docintel_endpoint="https://your-docintel-endpoint.cognitiveservices.azure.com/", +) +result = md.convert("scan.pdf") +``` + +### 自定义转换器 + +```python +from markitdown import MarkItDown, DocumentConverter, DocumentConverterResult, StreamInfo + +class MyConverter(DocumentConverter): + def accepts(self, file_stream, stream_info, **kwargs): + return stream_info.extension == ".myext" + + def convert(self, file_stream, stream_info, **kwargs): + content = file_stream.read().decode("utf-8") + return DocumentConverterResult(markdown=content, title="Custom") + +md = MarkItDown() +md.register_converter(MyConverter(), priority=0) +``` diff --git a/repowiki/DOCX_Math_Utils.md b/repowiki/DOCX_Math_Utils.md new file mode 100644 index 000000000..33e24b1cd --- /dev/null +++ b/repowiki/DOCX_Math_Utils.md @@ -0,0 +1,424 @@ +# DOCX Math Utils 模块 + +## 简介 + +DOCX Math Utils 模块是 markitdown-CN 项目中负责将 DOCX 文件中的 Office Math Markup Language (OMML) 数学公式转换为 LaTeX 格式的核心工具模块。该模块由两个子模块组成:`omml.py` 提供 OMML XML 到 LaTeX 的底层转换引擎,`pre_process.py` 提供 DOCX 文件的预处理流程编排。 + +DOCX 文件本质上是 ZIP 压缩包,其中包含多个 XML 文件。数学公式以 OMML 格式嵌入在 `document.xml`、`footnotes.xml` 和 `endnotes.xml` 中。本模块在 DOCX 转 Markdown 的主流程之前介入,通过预处理将这些 OMML 公式就地替换为 LaTeX 表达式,使得后续的 [Media_Converters](Media_Converters.md) 等模块可以在统一的文本格式下处理文档内容。 + +--- + +## 模块架构 + +### 组件总览 + +```mermaid +graph TB + subgraph PreProcess[pre_process.py 预处理层] + PPD[pre_process_docx] + PPM[_pre_process_math] + RE[_replace_equations] + GOTR[_get_omath_tag_replacement] + COML[_convert_omath_to_latex] + end + + subgraph OMML[omml.py 转换引擎层] + LOAD[load] + LOADS[load_string] + O2L[oMath2Latex] + T2M[Tag2Method] + PR[Pr] + ESC[escape_latex] + GV[get_val] + end + + subgraph ExternalDeps[外部依赖] + BS[BeautifulSoup] + ET[ElementTree] + ZIP[zipfile] + end + + PPD --> PPM + PPM --> RE + RE --> GOTR + GOTR --> COML + COML --> O2L + O2L --> T2M + O2L --> PR + O2L --> GV + O2L --> ESC + PPD --> ZIP + PPM --> BS + LOAD --> ET + LOADS --> ET +``` + +### 层次划分 + +| 层次 | 文件 | 职责 | +|------|------|------| +| 预处理层 | `pre_process.py` | DOCX 文件解包、XML 内容处理、公式替换、重新打包 | +| 转换引擎层 | `omml.py` | OMML XML 元素解析、LaTeX 生成、字符转义 | + +--- + +## 预处理层详解 (pre_process.py) + +### pre_process_docx — 顶层入口 + +`pre_process_docx` 是整个数学公式预处理流程的入口函数。它接收一个 DOCX 二进制流,在内存中完成解包、处理、重新打包的全过程,不写入磁盘。 + +#### 处理流程 + +```mermaid +flowchart TD + A[接收 DOCX 二进制流] --> B[创建内存输出流 BytesIO] + B --> C[用 ZipFile 读取输入 DOCX] + C --> D[遍历 ZIP 中的所有文件] + D --> E{文件是否在预处理列表中} + E -->|是| F[调用 _pre_process_math 处理] + E -->|否| G[直接写入原始内容] + F --> H{处理是否成功} + H -->|是| I[写入处理后的内容] + H -->|否| J[写入原始内容 容错处理] + G --> K{还有更多文件} + I --> K + J --> K + K -->|是| D + K -->|否| L[重置输出流位置到起始] + L --> M[返回处理后的 DOCX 流] +``` + +#### 需处理的文件列表 + +| 文件路径 | 说明 | +|----------|------| +| `word/document.xml` | 文档主体内容 | +| `word/footnotes.xml` | 脚注内容 | +| `word/endnotes.xml` | 尾注内容 | + +> **容错设计**:若某个 XML 文件的预处理过程中抛出异常,函数会静默回退并写入原始内容,确保整个 DOCX 文件不会因单个公式转换失败而损坏。 + +--- + +### _pre_process_math — XML 内容处理 + +`_pre_process_math` 接收 XML 内容的字节流,使用 BeautifulSoup 解析后,按顺序查找并替换所有 OMML 数学元素。 + +#### 处理顺序 + +```mermaid +flowchart LR + A[解析 XML 内容] --> B[查找所有 oMathPara 块级公式] + B --> C[替换块级公式] + C --> D[查找所有 oMath 行内公式] + D --> E[替换行内公式] + E --> F[返回处理后的字节流] +``` + +> **重要**:先处理 `oMathPara`(块级公式)再处理 `oMath`(行内公式),因为 `oMathPara` 内部包含 `oMath` 子元素,先处理外层可避免重复处理。 + +--- + +### _replace_equations — 公式替换 + +`_replace_equations` 根据标签类型执行不同的替换策略: + +| 标签类型 | 处理方式 | LaTeX 格式 | +|----------|----------|------------| +| `oMathPara` | 创建新 `w:p` 段落,内部每个 `oMath` 转为块级公式 | `$$LaTeX$$` | +| `oMath` | 直接替换为行内公式 | `$LaTeX$` | +| 其他 | 抛出 `ValueError` | — | + +--- + +### _get_omath_tag_replacement — 生成替换标签 + +该函数将 OMML 元素转换为 LaTeX 字符串,并包装为 BeautifulSoup 的 `w:r` / `w:t` 标签结构,以符合 DOCX XML 的格式规范。 + +- `block=True`:生成的 LaTeX 用 `$$...$$` 包裹(块级公式) +- `block=False`:生成的 LaTeX 用 `$...$` 包裹(行内公式) + +--- + +### _convert_omath_to_latex — OMML 到 LaTeX 的桥梁 + +该函数是预处理层与 OMML 转换引擎之间的桥梁: + +1. 将 BeautifulSoup Tag 格式化为完整的 XML 文档字符串(使用 `MATH_ROOT_TEMPLATE` 模板) +2. 用 ElementTree 解析该 XML +3. 查找 `oMath` 元素 +4. 调用 `oMath2Latex` 进行转换 +5. 返回 LaTeX 字符串 + +--- + +## OMML 转换引擎详解 (omml.py) + +### oMath2Latex — 核心转换类 + +`oMath2Latex` 是 OMML 到 LaTeX 的核心转换类,继承自 `Tag2Method`,采用“标签名到方法名”的映射模式,将每种 OMML 数学结构分发到对应的处理方法。 + +#### 支持的数学结构 + +| OMML 标签 | 方法 | LaTeX 输出 | 说明 | +|-----------|------|-----------|------| +| `acc` | `do_acc` | 重音符号 | 如 hat、tilde 等 | +| `bar` | `do_bar` | 上划线/下划线 | 共轭、均值等 | +| `d` | `do_d` | 分隔符 | 括号、绝对值等 | +| `f` | `do_f` | 分数 | 普通分数、斜分数等 | +| `func` | `do_func` | 函数应用 | sin、cos 等 | +| `fName` | `do_fname` | 函数名称 | 函数名解析 | +| `groupChr` | `do_groupChr` | 组合字符 | 下括号、上括号等 | +| `rad` | `do_rad` | 根式 | 平方根、n次方根 | +| `eqArr` | `do_eqarr` | 公式数组 | 多行对齐公式 | +| `limLow` | `do_limlow` | 下限 | lim、sum 等的下标 | +| `limUpp` | `do_limupp` | 上限 | 上限标注 | +| `lim` | `do_lim` | 极限值 | 极限表达式的值部分 | +| `m` | `do_m` | 矩阵 | 矩阵环境 | +| `mr` | `do_mr` | 矩阵行 | 单行矩阵元素 | +| `nary` | `do_nary` | N元运算符 | 求和、积分、乘积等 | +| `r` | `do_r` | 文本运行 | 普通文本和符号 | +| `sub` | `do_sub` | 下标 | 下标表达 | +| `sup` | `do_sup` | 上标 | 上标表达 | + +#### 转换流程 + +```mermaid +flowchart TD + A[接收 oMath XML 元素] --> B[遍历所有子元素] + B --> C{子元素标签是否有对应方法} + C -->|是| D[调用对应 do_xxx 方法] + C -->|否| E{是否为直接处理标签} + E -->|是| F[递归处理子元素] + E -->|否| G{是否为属性标签 Pr} + G -->|是| H[创建 Pr 对象解析属性] + G -->|否| I[忽略该元素] + D --> J[收集 LaTeX 片段] + F --> J + H --> J + J --> K[拼接所有片段为完整 LaTeX] +``` + +--- + +### Tag2Method — 标签分发基类 + +`Tag2Method` 提供了 XML 标签到处理方法的通用分发机制,是 `oMath2Latex` 和 `Pr` 的共同基类。 + +#### 核心方法 + +| 方法 | 功能 | +|------|------| +| `call_method` | 根据标签名查找并调用对应方法 | +| `process_children_list` | 遍历子元素,返回 `(标签名, 结果, 元素)` 元组的迭代器 | +| `process_children_dict` | 遍历子元素,返回以标签名为键的字典 | +| `process_children` | 遍历子元素,返回拼接后的字符串 | +| `process_unknow` | 处理未知标签的钩子方法(子类可覆写) | + +--- + +### Pr — 属性解析类 + +`Pr` 继承自 `Tag2Method`,负责解析 OMML 元素的属性信息(如分隔符类型、位置、起止字符等)。它支持以下属性标签: + +- `chr` — 字符属性 +- `pos` — 位置属性 +- `begChr` / `endChr` — 起止字符 +- `type` — 类型属性 +- `brk` — 换行符 + +属性值存储在内部字典中,可通过属性访问方式获取。 + +--- + +### 工具函数 + +#### load / load_string + +提供从文件流或字符串解析 OMML 的便捷入口: + +- `load(stream)`:从文件流解析 XML,查找所有 `oMath` 元素并生成 LaTeX +- `load_string(string)`:从 XML 字符串解析,行为同上 + +两者均返回生成器,逐个产出 `oMath2Latex` 对象。 + +#### escape_latex + +对 LaTeX 特殊字符进行转义处理: + +- 遍历输入字符串中的每个字符 +- 对属于 LaTeX 特殊字符集 (`CHARS`) 且未被反斜杠保护的字符添加转义 +- 先处理双反斜杠 `\\` 以避免重复转义 + +#### get_val + +通用的值查找函数: + +- 从指定字典 (`store`) 中查找键对应的 LaTeX 映射值 +- 若键为 `None`,返回默认值 +- 若字典中未找到,返回键本身作为回退 + +--- + +## 完整数据流 + +```mermaid +sequenceDiagram + participant Caller as 调用方 + participant PPD as pre_process_docx + participant PPM as _pre_process_math + participant RE as _replace_equations + participant COML as _convert_omath_to_latex + participant O2L as oMath2Latex + participant Result as 转换结果 + + Caller->>PPD: DOCX 二进制流 + PPD->>PPD: ZipFile 解包 + loop 遍历 document.xml / footnotes.xml / endnotes.xml + PPD->>PPM: XML 字节内容 + PPM->>PPM: BeautifulSoup 解析 + loop 查找 oMathPara 和 oMath + PPM->>RE: OMML 标签 + RE->>COML: oMath 标签 + COML->>COML: 包装为完整 XML + COML->>O2L: ElementTree 解析后的 oMath + O2L->>O2L: 递归遍历子元素并分发处理 + O2L-->>COML: LaTeX 字符串 + COML-->>RE: LaTeX 字符串 + RE->>RE: 包装为 w:r/w:t 标签 + RE-->>PPM: 替换后的标签 + end + PPM-->>PPD: 处理后的 XML 字节 + end + PPD->>PPD: ZipFile 重新打包 + PPD-->>Caller: 处理后的 DOCX 二进制流 +``` + +--- + +## 类继承关系 + +```mermaid +classDiagram + class Tag2Method { + +call_method(elm, stag) + +process_children_list(elm, include) + +process_children_dict(elm, include) + +process_children(elm, include) + +process_unknow(elm, stag) + +tag2meth dict + } + + class oMath2Latex { + +latex property + +do_acc(elm) + +do_bar(elm) + +do_d(elm) + +do_f(elm) + +do_func(elm) + +do_fname(elm) + +do_groupChr(elm) + +do_rad(elm) + +do_eqarr(elm) + +do_limlow(elm) + +do_limupp(elm) + +do_lim(elm) + +do_m(elm) + +do_mr(elm) + +do_nary(elm) + +do_r(elm) + +do_sub(elm) + +do_sup(elm) + +process_unknow(elm, stag) + } + + class Pr { + +text str + +do_brk(elm) + +do_common(elm) + } + + Tag2Method <|-- oMath2Latex + Tag2Method <|-- Pr +``` + +--- + +## 依赖关系 + +### 内部依赖 + +- `pre_process.py` 依赖 `omml.py` 中的 `oMath2Latex` 和 `OMML_NS` 常量 +- `_convert_omath_to_latex` 依赖 `MATH_ROOT_TEMPLATE` 模板和 `oMath2Latex` 类 + +### 外部依赖 + +| 依赖 | 类型 | 用途 | +|------|------|------| +| `BeautifulSoup` (bs4) | Python 库 | XML 解析与标签操作 | +| `xml.etree.ElementTree` | Python 标准库 | OMML XML 解析 | +| `zipfile` | Python 标准库 | DOCX ZIP 文件操作 | +| `io.BytesIO` | Python 标准库 | 内存中的二进制流 | + +### 与其他模块的关系 + +- 本模块的 `pre_process_docx` 在 DOCX 转换器的主流程中被调用,位于文档解析之前的预处理阶段 +- 预处理后的 DOCX 文件中的公式已转为 LaTeX 文本,后续转换器可按普通文本处理 +- 与 [Media_Converters](Media_Converters.md) 模块无直接依赖,但同属 markitdown 的转换器体系 + +--- + +## 错误处理策略 + +| 场景 | 处理方式 | +|------|----------| +| XML 解析失败 | `pre_process_docx` 捕获异常,写入原始内容 | +| 不支持的 OMML 标签 | `_replace_equations` 抛出 `ValueError`,上层捕获后回退 | +| 不支持的函数名 | `do_fname` 抛出 `NotImplementedError` | +| 不支持的极限表达式 | `do_limlow` 抛出 `NotImplementedError` | +| 未知子标签 | `process_unknow` 返回 `None`,静默跳过 | + +--- + +## 设计特点 + +### Tag2Method 分发模式 + +采用“标签名 -> 方法名”的映射模式,通过 `tag2meth` 字典实现自动分发。这种设计使得: + +1. 新增数学结构支持只需添加新的 `do_xxx` 方法和字典条目 +2. 各数学结构的转换逻辑相互独立,易于维护 +3. 通过 `process_unknow` 钩子支持灵活的未知标签处理 + +### 内存中完成全流程 + +整个预处理过程(解包、处理、重新打包)均在内存中通过 `BytesIO` 完成,避免了磁盘 I/O,适合高并发场景。 + +### LaTeX 分隔符规范 + +- 行内公式:`$...$`(单个美元符号) +- 块级公式:`$$...$$`(双美元符号) + +这与 Markdown 中的 LaTeX 数学公式规范一致,确保后续渲染器可以正确识别和渲染。 + +--- + +## 扩展指南 + +### 支持新的 OMML 结构 + +1. 在 `oMath2Latex` 类中添加 `do_xxx(self, elm)` 方法 +2. 在 `tag2meth` 字典中注册标签名到方法的映射 +3. 在方法内使用 `process_children_dict` 或 `process_children_list` 解析子元素 +4. 使用预定义的 LaTeX 模板格式化输出 + +### 添加新的预处理步骤 + +`pre_process_docx` 已预留扩展点,在 `_pre_process_math` 之后可以添加更多预处理步骤: + +```python +updated_content = _pre_process_math(content) +# 未来可在此处添加更多预处理步骤 +# updated_content = _pre_process_other(updated_content) +``` diff --git a/repowiki/Document_Format_Converters.md b/repowiki/Document_Format_Converters.md new file mode 100644 index 000000000..e77733d4c --- /dev/null +++ b/repowiki/Document_Format_Converters.md @@ -0,0 +1,375 @@ +# Document Format Converters 模块 + +## 模块简介 + +`Document_Format_Converters` 是 markitdown-CN 项目的核心文档格式转换模块,负责将多种常见文档格式(DOCX、PPTX、XLSX/XLS、PDF、EPUB、Outlook MSG、Jupyter Notebook、CSV、纯文本、ZIP)统一转换为 Markdown 输出。该模块采用"一个格式一个转换器"的设计思路,每个转换器独立实现 `accepts()` 与 `convert()` 方法,遵循 [DocumentConverter](Core_Converter_Framework.md) 统一接口,便于按需注册和扩展。 + +## 核心功能 + +| 功能 | 说明 | +|------|------| +| 多格式文档转换 | 支持 10+ 种主流办公与数据文件格式到 Markdown 的转换 | +| HTML 中间层复用 | DOCX、XLSX、XLS、EPUB 等格式先转为 HTML,再通过 [HtmlConverter](Core_Converter_Framework.md) 统一转 Markdown | +| 表格智能提取 | PDF 转换器内置 pdfplumber 无框表格识别算法,CSV/XLSX 直接生成 Markdown 表格 | +| LLM 图像描述 | PPTX 转换器可调用外部 LLM 为幻灯片中的图片生成描述文本 | +| 递归 ZIP 解包 | ZIP 转换器通过 [MarkItDown](Core_Converter_Framework.md) 主实例递归处理包内文件 | +| 依赖延迟加载 | 各转换器在转换时才检查第三方依赖是否可用,缺失时抛出 `MissingDependencyException` | +| 自定义 Markdownify | 通过 `_CustomMarkdownify` 定制 HTML→Markdown 转换行为(标题样式、URI 转义、data URI 截断等) | + +## 架构图 + +```mermaid +graph TD + subgraph InputFormats[支持的输入格式] + A1[DOCX] + A2[PPTX] + A3[XLSX] + A4[XLS] + A5[PDF] + A6[Outlook MSG] + A7[EPUB] + A8[IPYNB] + A9[CSV] + A10[PlainText] + A11[ZIP] + end + + subgraph Converters[转换器组件] + C1[DocxConverter] + C2[PptxConverter] + C3[XlsxConverter] + C4[XlsConverter] + C5[PdfConverter] + C6[OutlookMsgConverter] + C7[EpubConverter] + C8[IpynbConverter] + C9[CsvConverter] + C10[PlainTextConverter] + C11[ZipConverter] + end + + subgraph SharedComponents[共享组件] + H1[HtmlConverter] + H2[CustomMarkdownify] + H3[MarkItDown Instance] + end + + subgraph Output[输出] + O1[DocumentConverterResult Markdown] + end + + A1 --> C1 + A2 --> C2 + A3 --> C3 + A4 --> C4 + A5 --> C5 + A6 --> C6 + A7 --> C7 + A8 --> C8 + A9 --> C9 + A10 --> C10 + A11 --> C11 + + C1 --> H1 + C3 --> H1 + C4 --> H1 + C7 --> H1 + C2 --> H1 + H1 --> H2 + C11 --> H3 + + C1 --> O1 + C2 --> O1 + C3 --> O1 + C4 --> O1 + C5 --> O1 + C6 --> O1 + C7 --> O1 + C8 --> O1 + C9 --> O1 + C10 --> O1 + C11 --> O1 +``` + +## 转换器职责详解 + +### DocxConverter + +**源文件**: `_docx_converter.py` + +将 `.docx` 文件转换为 Markdown。继承自 `HtmlConverter`,内部组合了一个 `HtmlConverter` 实例。 + +**转换流程**: +1. 通过 `pre_process_docx()` 对文件流做预处理 +2. 调用 `mammoth.convert_to_html()` 将 DOCX 转为 HTML +3. 将 HTML 交给内部 `HtmlConverter.convert_string()` 完成最终 Markdown 输出 + +**支持选项**: 可通过 `style_map` 参数自定义 mammoth 的样式映射规则,实现精细的标题/段落样式控制。 + +**依赖**: `mammoth` + +--- + +### PptxConverter + +**源文件**: `_pptx_converter.py` + +将 `.pptx` 演示文稿转换为 Markdown,逐幻灯片解析并提取内容。 + +**转换流程**: +1. 使用 `python-pptx` 解析 PPTX 文件 +2. 按幻灯片编号迭代,对每张幻灯片中的 Shape 按位置排序 +3. 根据 Shape 类型分别处理: + - **图片**: 提取 alt text,可选调用 LLM 生成描述(`llm_caption()`),支持 base64 data URI 输出 + - **表格**: 转为 HTML 表格后通过 `HtmlConverter` 转 Markdown + - **图表**: 提取类别与系列数据,生成 Markdown 表格 + - **文本框**: 标题 Shape 输出为 `# heading`,其余直接输出文本 + - **分组 Shape**: 递归处理子 Shape +4. 提取幻灯片备注,附加到对应幻灯片的 `### Notes:` 小节 + +**依赖**: `python-pptx` + +--- + +### XlsxConverter / XlsConverter + +**源文件**: `_xlsx_converter.py` + +将 Excel 电子表格转换为 Markdown。两者实现几乎一致,区别在于使用的 pandas 引擎不同。 + +| 转换器 | 支持格式 | pandas 引擎 | +|--------|----------|-------------| +| `XlsxConverter` | `.xlsx`, `.xlsm` | `openpyxl` | +| `XlsConverter` | `.xls` | `xlrd` | + +**转换流程**: +1. 使用 `pd.read_excel(sheet_name=None)` 读取所有工作表 +2. 每个工作表输出为 `## 工作表名` 标题 +3. 将 DataFrame 通过 `to_html()` 转为 HTML +4. 经 `HtmlConverter.convert_string()` 转为 Markdown 表格 + +**依赖**: `pandas`, `openpyxl`(XLSX) / `xlrd`(XLS) + +--- + +### PdfConverter + +**源文件**: `_pdf_converter.py` + +将 PDF 文件转换为 Markdown,是本模块中最复杂的转换器,包含智能表单识别与无框表格提取算法。 + +**转换策略**: + +```mermaid +graph TD + A[PDF 输入] --> B[pdfplumber 逐页分析] + B --> C{页面是否为表单风格} + C -->|是| D[extract_form_content_from_words 提取结构化内容] + C -->|否| E[收集纯文本页面] + D --> F{存在表单页面} + E --> F + F -->|是| G[合并所有页面 markdown_chunks] + F -->|否| H[pdfminer 全文提取] + G --> I[merge_partial_numbering_lines 后处理] + H --> I + I --> J[输出 Markdown] +``` + +**核心辅助函数**: + +| 函数 | 职责 | +|------|------| +| `_extract_form_content_from_words` | 分析页面中文字的 Y 坐标分行、X 坐标分列,通过自适应容差算法识别全局列结构,将表单/表格区域输出为对齐的 Markdown 表格,非表格内容输出为纯文本 | +| `_extract_tables_from_words` | 专注于无边框表格提取,通过列位置聚类和行质量校验(至少 3 行、单元格内容简短)识别结构化表格数据 | +| `_to_markdown_table` | 将二维列表格式化为对齐的 Markdown 表格,支持是否包含分隔行 | +| `_merge_partial_numbering_lines` | 后处理阶段合并 MasterFormat 风格的部分编号行(如 `.1` 单独成行时与下一行合并) | +| `extract_cells` | 将一行中的文字按全局列边界分配到对应单元格 | + +**依赖**: `pdfplumber`, `pdfminer.six` + +--- + +### OutlookMsgConverter + +**源文件**: `_outlook_msg_converter.py` + +将 Outlook `.msg` 邮件文件转换为 Markdown。 + +**转换流程**: +1. 使用 `olefile` 解析 OLE 复合文档结构 +2. 通过硬编码的 stream 路径提取邮件头: + - `__substg1.0_0C1F001F` → From + - `__substg1.0_0E04001F` → To + - `__substg1.0_0037001F` → Subject +3. 提取邮件正文 `__substg1.0_1000001F` +4. 输出格式为 `# Email Message` + 元数据 + `## Content` + 正文 + +**编码处理**: `_get_stream_data()` 依次尝试 UTF-16-LE → UTF-8 → UTF-8(ignore) 解码。 + +**accepts 检测**: 除扩展名和 MIME 类型匹配外,还会进行 OLE 文件格式验证和 Outlook 特征 stream 检测。 + +**依赖**: `olefile` + +--- + +### EpubConverter + +**源文件**: `_epub_converter.py` + +将 EPUB 电子书转换为 Markdown。继承自 `HtmlConverter`。 + +**转换流程**: +1. 将 EPUB 作为 ZIP 文件打开 +2. 解析 `META-INF/container.xml` 定位 `content.opf` +3. 从 `content.opf` 提取 Dublin Core 元数据(title、authors、language、publisher、date、description、identifier) +4. 解析 manifest 和 spine,按阅读顺序排列内容文件 +5. 逐个将 spine 中的 HTML/XHTML 文件通过 `HtmlConverter` 转为 Markdown +6. 将元数据以 `**key:** value` 格式插入文档头部 + +**依赖**: Python 标准库 `zipfile`, `xml.dom.minidom` + +--- + +### IpynbConverter + +**源文件**: `_ipynb_converter.py` + +将 Jupyter Notebook (`.ipynb`) 文件转换为 Markdown。 + +**转换流程**: +1. 读取并解析 Notebook JSON +2. 遍历 `cells` 数组,按单元格类型处理: + - `markdown` 单元格: 直接输出原始 Markdown + - `code` 单元格: 包裹在 ````python` 代码块中 + - `raw` 单元格: 包裹在 ```` ` 代码块中 +3. 标题提取: 优先从第一个 `# heading` 行获取,其次从 `metadata.title` 获取 + +**accepts 检测**: 对 `application/json` 等候选 MIME 类型,会进一步读取内容检查是否包含 `nbformat` 和 `nbformat_minor` 字段。 + +--- + +### CsvConverter + +**源文件**: `_csv_converter.py` + +将 CSV 文件转换为 Markdown 表格。 + +**转换流程**: +1. 使用 `charset` 或 `chardet`(通过 `from_bytes`)检测编码并读取内容 +2. 用 `csv.reader` 解析 +3. 第一行作为表头,生成 `| header |` 格式的 Markdown 表格 +4. 自动对齐列数(不足补空、多余截断) + +--- + +### PlainTextConverter + +**源文件**: `_plain_text_converter.py` + +处理 `text/plain` 类型内容,是最简单的转换器。 + +**转换流程**: 根据 `stream_info.charset` 解码文本,若无 charset 则使用 `chardet` 自动检测,直接作为 Markdown 输出。 + +**accepts 检测**: 如果 `stream_info.charset` 存在则直接接受(表示上游已确认为文本),否则检查扩展名和 MIME 类型。 + +--- + +### ZipConverter + +**源文件**: `_zip_converter.py` + +将 ZIP 压缩包转换为 Markdown,通过递归委托实现嵌套文件的转换。 + +**转换流程**: +1. 打开 ZIP 文件,遍历 `namelist()` +2. 将每个文件读取为 `BytesIO` 流 +3. 调用 `self._markitdown.convert_stream()` 进行转换(递归利用已注册的所有转换器) +4. 每个文件输出为 `## File: 文件名` 小节 +5. 静默跳过不支持的格式(`UnsupportedFormatException`)和转换失败(`FileConversionException`) + +**设计要点**: 构造函数需要注入 `MarkItDown` 实例引用,这使得 ZIP 转换器可以复用主框架的转换器注册表和流检测逻辑。 + +--- + +### _CustomMarkdownify + +**源文件**: `_markdownify.py` + +继承 `markdownify.MarkdownConverter`,定制 HTML→Markdown 转换行为。被 [HtmlConverter](Core_Converter_Framework.md) 间接使用。 + +**定制内容**: + +| 方法 | 定制行为 | +|------|----------| +| `__init__` | 默认标题样式设为 ATX(`#`/`##`),默认关闭 `keep_data_uris` | +| `convert_hn` | 确保标题前换行,避免与前一元素粘连 | +| `convert_a` | 移除 `javascript:` 等非 HTTP 协议的链接,对 URI path 进行 `quote/unquote` 转义处理 | +| `convert_img` | 截断 `data:` URI 图片源为 `data:image/png,...` 占位,去除 alt 文本中的换行 | +| `convert_input` | 将 HTML checkbox 转为 `[x]` / `[ ]` Markdown 语法 | + +## 转换流程总览 + +```mermaid +graph LR + A[输入文件流] --> B[accepts 检测] + B -->|扩展名匹配| C[选择转换器] + B -->|MIME 类型匹配| C + B -->|特征检测| C + C --> D{是否依赖 HTML 中间层} + D -->|是 DOCX XLSX XLS EPUB PPTX表格| E[先转为 HTML] + E --> F[HtmlConverter 转 Markdown] + F --> G[CustomMarkdownify 定制输出] + D -->|否 PDF CSV IPYNB MSG TXT ZIP| H[直接生成 Markdown] + G --> I[DocumentConverterResult] + H --> I +``` + +## 设计模式与关键决策 + +### 依赖延迟检查 + +每个转换器的 `convert()` 方法在开头检查第三方依赖是否成功导入。若模块导入失败(记录在 `_dependency_exc_info` 中),则抛出 `MissingDependencyException` 并附带安装指引。这使得核心框架可以在不安装全部可选依赖的情况下启动。 + +### HTML 中间层模式 + +多个转换器(DOCX、XLSX、XLS、EPUB、PPTX 中的表格部分)采用"先转 HTML 再转 Markdown"的策略。这样做的好处: +- 复用 `HtmlConverter` 与 `_CustomMarkdownify` 的成熟转换逻辑 +- 减少各转换器中的 Markdown 格式化重复代码 +- 确保不同格式的输出风格一致 + +### 文件类型识别 + +`accepts()` 方法同时支持两种识别策略: +1. **扩展名匹配**: 快速路径,检查 `stream_info.extension` 是否在预定义的 `ACCEPTED_FILE_EXTENSIONS` 列表中 +2. **MIME 类型前缀匹配**: 检查 `stream_info.mimetype` 是否以 `ACCEPTED_MIME_TYPE_PREFIXES` 中的任一前缀开头 + +部分转换器(OutlookMsgConverter、IpynbConverter)还会进行内容特征检测作为补充。 + +### PDF 智能提取 + +PDF 转换器采用双层提取策略: +- **pdfplumber 优先**: 逐页分析文字位置,对表单/表格风格的页面进行结构化提取 +- **pdfminer 兜底**: 当没有表单页面或 pdfplumber 异常时,使用 pdfminer 进行全文提取(对散文类文档的文字间距更优) + +表单检测的核心算法位于 `_extract_form_content_from_words()`,通过以下步骤工作: +1. 按 Y 坐标聚类文字为行 +2. 分析每行的 X 坐标分布,识别列分组 +3. 使用自适应容差算法(基于间距统计分析)确定全局列边界 +4. 将表格行输出为对齐 Markdown 表格,段落行输出为纯文本 + +## 与其他模块的关系 + +- [Core_Converter_Framework](Core_Converter_Framework.md): 本模块所有转换器均实现 `DocumentConverter` 基类接口,并由 `MarkItDown` 主类统一管理注册与调度 +- [Media_and_Special_Converters](Media_and_Special_Converters.md): PPTX 转换器的 LLM 图像描述功能依赖 `llm_caption()` 工具函数 +- [Stream_Detection_and_Info](Stream_Detection_and_Info.md): 所有转换器的 `accepts()` 方法依赖 `StreamInfo` 对象提供的扩展名、MIME 类型和字符集信息 + +## 扩展新转换器 + +要添加新的文档格式支持,需遵循以下步骤: + +1. 在 `converters/` 目录下创建 `_xxx_converter.py` +2. 实现继承自 `DocumentConverter` 的 `XxxConverter` 类 +3. 实现 `accepts()` 方法,定义文件类型匹配规则 +4. 实现 `convert()` 方法,返回 `DocumentConverterResult` +5. 如需 HTML 中间层,组合 `HtmlConverter` 实例 +6. 在模块入口注册新转换器 \ No newline at end of file diff --git a/repowiki/MCP_Server.md b/repowiki/MCP_Server.md new file mode 100644 index 000000000..75f3ed0e5 --- /dev/null +++ b/repowiki/MCP_Server.md @@ -0,0 +1,384 @@ +# MCP Server 模块 + +## 简介 + +MCP Server 模块是 markitdown-CN 项目的 Model Context Protocol (MCP) 服务层,将 markitdown 的核心文档转换能力封装为标准的 MCP 服务。该模块支持两种传输方式:STDIO(标准输入输出)和 HTTP(包含 SSE 与 Streamable HTTP 两种子模式),使得任何支持 MCP 协议的客户端(如 AI 助手、IDE 插件等)均可通过统一接口调用文档转换功能。 + +该模块位于独立的 `markitdown-mcp` 包中,通过 MCP 协议对外暴露 `convert_to_markdown` 工具,支持将 `http:`、`https:`、`file:` 和 `data:` URI 指向的资源转换为 Markdown 格式。 + +--- + +## 模块架构 + +### 组件总览 + +```mermaid +graph TB + subgraph MCPServer[MCP Server 模块] + MAIN[main] + CTM[convert_to_markdown] + CPE[check_plugins_enabled] + CSA[create_starlette_app] + HSSE[handle_sse] + HSH[handle_streamable_http] + LS[lifespan] + end + + subgraph CoreDeps[核心依赖] + MCP[MCP Python SDK] + MD[MarkItDown 核心库] + UVICORN[uvicorn] + STARLETTE[Starlette] + end + + MAIN --> CSA + MAIN --> CTM + CSA --> HSSE + CSA --> HSH + CSA --> LS + CTM --> CPE + CTM --> MD + CSA --> MCP + MAIN --> UVICORN + CSA --> STARLETTE +``` + +### 函数职责 + +| 函数 | 类型 | 职责 | +|------|------|------| +| `main` | 入口函数 | 命令行参数解析、传输方式选择、服务启动 | +| `convert_to_markdown` | MCP 工具 | 将 URI 指向的资源转换为 Markdown | +| `check_plugins_enabled` | 辅助函数 | 检查插件功能是否通过环境变量启用 | +| `create_starlette_app` | 工厂函数 | 创建 Starlette Web 应用,配置路由和生命周期 | +| `handle_sse` | 请求处理器 | 处理 SSE (Server-Sent Events) 连接 | +| `handle_streamable_http` | 请求处理器 | 处理 Streamable HTTP 连接 | +| `lifespan` | 上下文管理器 | 管理 StreamableHTTP 会话的生命周期 | + +--- + +## 核心组件详解 + +### main — 服务入口 + +`main` 是整个 MCP 服务的启动入口,负责解析命令行参数并根据用户选择启动不同的传输模式。 + +#### 命令行参数 + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `--http` | 布尔标志 | `False` | 启用 Streamable HTTP + SSE 传输模式 | +| `--sse` | 布尔标志 | `False` | `--http` 的废弃别名 | +| `--host` | 字符串 | `127.0.0.1` | 绑定地址 | +| `--port` | 整数 | `3001` | 监听端口 | + +#### 启动流程 + +```mermaid +flowchart TD + A[解析命令行参数] --> B{是否启用 HTTP 模式} + B -->|否| C{是否指定了 host 或 port} + C -->|是| D[报错退出:host/port 仅在 HTTP 模式下有效] + C -->|否| E[通过 STDIO 启动 MCP 服务] + B -->|是| F[确定绑定地址] + F --> G{host 是否为非本地地址} + G -->|是| H[输出安全警告到 stderr] + G -->|否| I[创建 Starlette 应用] + H --> I + I --> J[通过 uvicorn 启动 HTTP 服务] +``` + +#### 安全警告 + +当用户将服务绑定到非 localhost 地址(如 `0.0.0.0`)时,服务会输出醒目的安全警告: + +- 服务器无认证机制 +- 服务以当前用户权限运行 +- 网络上的任何进程或用户均可访问 +- 可读取当前用户可访问的文件和网络资源 + +--- + +### convert_to_markdown — 核心 MCP 工具 + +`convert_to_markdown` 是通过 MCP 协议暴露给客户端的核心工具函数,接受一个 URI 参数并返回转换后的 Markdown 文本。 + +#### 支持的 URI 方案 + +| URI 方案 | 说明 | +|----------|------| +| `http:` / `https:` | 从网络 URL 获取资源并转换 | +| `file:` | 从本地文件系统读取资源并转换 | +| `data:` | 从 data URI 内联数据转换 | + +#### 处理流程 + +```mermaid +flowchart LR + A[接收 URI 参数] --> B[检查插件是否启用] + B --> C[创建 MarkItDown 实例] + C --> D[调用 convert_uri 转换] + D --> E[返回 Markdown 文本] +``` + +#### 插件控制 + +转换实例创建时,`enable_plugins` 参数由 `check_plugins_enabled()` 决定。当插件启用时,MarkItDown 可以加载第三方插件扩展支持的文档格式。 + +--- + +### check_plugins_enabled — 插件开关检测 + +通过环境变量 `MARKITDOWN_ENABLE_PLUGINS` 控制插件功能是否启用。 + +#### 判断逻辑 + +| 环境变量值 | 结果 | +|------------|------| +| `"true"` / `"1"` / `"yes"` (不区分大小写) | `True` — 启用插件 | +| 其他值或未设置 | `False` — 禁用插件 | + +默认值为 `"false"`,即插件默认禁用,需要用户显式开启。 + +--- + +### create_starlette_app — Web 应用工厂 + +`create_starlette_app` 创建一个 Starlette ASGI 应用,同时支持 SSE 和 Streamable HTTP 两种 MCP 传输协议。 + +#### 路由配置 + +```mermaid +graph LR + subgraph Routes[路由表] + R1["/sse"] + R2["/mcp"] + R3["/messages/"] + end + + R1 --> HSSE[handle_sse - SSE 连接端点] + R2 --> HSH[handle_streamable_http - Streamable HTTP 端点] + R3 --> SSEPOST[SSE POST 消息处理] +``` + +| 路由 | 方法 | 功能 | 传输协议 | +|------|------|------|----------| +| `/sse` | GET | SSE 连接端点,建立长连接 | SSE | +| `/mcp` | 多种 | Streamable HTTP 端点 | Streamable HTTP | +| `/messages/` | POST | SSE 模式下的客户端消息发送 | SSE | + +#### 内部组件 + +- **SseServerTransport**:管理 SSE 连接,处理客户端到服务器的消息 +- **StreamableHTTPSessionManager**:管理 Streamable HTTP 会话,配置为无状态 (`stateless=True`) 和 JSON 响应 (`json_response=True`) 模式 + +--- + +### handle_sse — SSE 连接处理 + +`handle_sse` 处理 SSE (Server-Sent Events) 协议的连接请求。 + +#### 处理流程 + +```mermaid +flowchart TD + A[接收 HTTP 请求] --> B[建立 SSE 连接] + B --> C[获取读写流] + C --> D[启动 MCP 服务器处理循环] + D --> E[使用初始化选项运行] + E --> F[持续处理客户端消息并返回结果] +``` + +SSE 模式下,客户端通过 GET 请求建立长连接,服务器通过 SSE 推送响应;客户端通过 POST `/messages/` 发送后续请求。 + +--- + +### handle_streamable_http — Streamable HTTP 处理 + +`handle_streamable_http` 处理 Streamable HTTP 协议的请求,将所有请求委托给 `StreamableHTTPSessionManager` 处理。 + +该函数采用 ASGI 接口风格(`scope`, `receive`, `send`),与 Starlette 的 Mount 路由兼容。 + +Streamable HTTP 模式特点: +- 无状态 (`stateless=True`):每个请求独立处理 +- JSON 响应 (`json_response=True`):返回标准 JSON 格式 + +--- + +### lifespan — 应用生命周期管理 + +`lifespan` 是一个异步上下文管理器,管理 StreamableHTTP 会话管理器的生命周期。 + +```mermaid +flowchart TD + A[应用启动] --> B[启动 session_manager] + B --> C[输出启动日志] + C --> D[yield - 应用运行中] + D --> E[应用关闭] + E --> F[输出关闭日志] + F --> G[清理会话资源] +``` + +--- + +## 传输模式对比 + +```mermaid +graph TB + subgraph STDIO[STDIO 模式] + STDIO_CLIENT[MCP 客户端] -->|stdin/stdout| STDIO_SERVER[MCP Server] + end + + subgraph HTTP[HTTP 模式] + HTTP_CLIENT[MCP 客户端] -->|SSE| SSE_ENDPOINT["/sse"] + HTTP_CLIENT -->|Streamable HTTP| MCP_ENDPOINT["/mcp"] + SSE_ENDPOINT --> SERVER[Starlette + uvicorn] + MCP_ENDPOINT --> SERVER + end +``` + +| 特性 | STDIO 模式 | HTTP 模式 | +|------|----------|----------| +| 传输方式 | 标准输入/输出 | HTTP + SSE / Streamable HTTP | +| 启动命令 | `markitdown-mcp` | `markitdown-mcp --http` | +| 默认端口 | 不适用 | 3001 | +| 默认地址 | 不适用 | 127.0.0.1 | +| 适用场景 | 本地 IDE 插件、命令行工具 | 远程服务、多客户端共享 | +| 认证机制 | 无(本地进程隔离) | 无(需网络隔离) | + +--- + +## 完整数据流 + +```mermaid +sequenceDiagram + participant Client as MCP 客户端 + participant Server as MCP Server + participant MD as MarkItDown 核心 + participant Resource as 资源 URI + + Note over Client, Server: STDIO 模式 + Client->>Server: tools/call convert_to_markdown + Server->>Server: check_plugins_enabled() + Server->>MD: MarkItDown(enable_plugins) + Server->>MD: convert_uri(uri) + MD->>Resource: 获取 URI 资源 + Resource-->>MD: 原始数据 + MD-->>Server: ConvertResult(markdown) + Server-->>Client: Markdown 文本 + + Note over Client, Server: HTTP 模式 + Client->>Server: HTTP 请求 (SSE/Streamable HTTP) + Server->>Server: 路由分发 + Server->>Server: MCP 协议解析 + Server->>Server: tools/call convert_to_markdown + Server->>MD: convert_uri(uri) + MD->>Resource: 获取 URI 资源 + Resource-->>MD: 原始数据 + MD-->>Server: ConvertResult(markdown) + Server-->>Client: HTTP 响应 (Markdown) +``` + +--- + +## 依赖关系 + +### 内部依赖 + +- `main` 依赖 `create_starlette_app` 和 MCP 服务器实例 +- `create_starlette_app` 内部定义 `handle_sse`、`handle_streamable_http` 和 `lifespan` +- `convert_to_markdown` 依赖 `check_plugins_enabled` 和 MarkItDown 核心库 + +### 外部依赖 + +| 依赖 | 类型 | 用途 | +|------|------|------| +| `mcp` (Python SDK) | MCP 协议库 | MCP 服务器框架、工具注册、协议处理 | +| `Starlette` | ASGI 框架 | HTTP 路由、中间件、生命周期管理 | +| `uvicorn` | ASGI 服务器 | HTTP 服务监听和请求处理 | +| `MarkItDown` | 核心库 | 文档到 Markdown 的实际转换逻辑 | + +### 环境变量 + +| 变量名 | 默认值 | 说明 | +|--------|--------|------| +| `MARKITDOWN_ENABLE_PLUGINS` | `"false"` | 是否启用 MarkItDown 插件扩展 | + +### 与其他模块的关系 + +- 本模块是 MarkItDown 核心转换能力的服务化封装,实际转换逻辑委托给 MarkItDown 核心库 +- 核心库中的 [Media_Converters](Media_Converters.md) 和 [DOCX_Math_Utils](DOCX_Math_Utils.md) 等模块在转换过程中被间接调用 +- 本模块不包含任何转换逻辑,仅负责协议处理、传输和服务编排 + +--- + +## 安全考虑 + +### 无认证设计 + +MCP Server 当前不实现任何认证机制。在 STDIO 模式下,安全性由进程隔离保证;在 HTTP 模式下,需要用户自行通过网络隔离(如防火墙规则、反向代理认证等)保护服务。 + +### 本地绑定默认 + +HTTP 模式默认绑定 `127.0.0.1`,仅允许本地访问。若用户显式指定其他地址,服务会输出安全警告。 + +### 插件默认禁用 + +插件功能默认禁用(`MARKITDOWN_ENABLE_PLUGINS=false`),防止未经审查的第三方插件被执行。用户需显式设置环境变量启用。 + +--- + +## 错误处理 + +| 场景 | 处理方式 | +|------|----------| +| HTTP 模式下指定 host/port 但未启用 HTTP | 解析参数时报错并退出 | +| 资源 URI 无法访问 | MarkItDown 核心库抛出异常,MCP 返回错误给客户端 | +| 插件启用但插件加载失败 | 由 MarkItDown 核心库处理,不影响基础转换功能 | +| SSE 连接断开 | `async with` 上下文管理器自动清理资源 | +| Streamable HTTP 会话异常 | `StreamableHTTPSessionManager` 内部处理 | + +--- + +## 部署指南 + +### STDIO 模式(默认) + +```bash +markitdown-mcp +``` + +适用于本地 IDE 插件或命令行工具,通过标准输入输出与 MCP 客户端通信。 + +### HTTP 模式 + +```bash +# 默认本地访问 +markitdown-mcp --http + +# 自定义端口 +markitdown-mcp --http --port 8080 + +# 启用插件 +MARKITDOWN_ENABLE_PLUGINS=true markitdown-mcp --http +``` + +适用于远程部署或多客户端共享场景,支持 SSE 和 Streamable HTTP 两种子协议。 + +--- + +## 扩展指南 + +### 添加新的 MCP 工具 + +1. 在模块中定义新的异步函数,使用 `@mcp.tool()` 装饰器注册 +2. 在函数内调用 MarkItDown 核心库的相关 API +3. 客户端可通过 MCP 协议的 `tools/list` 发现新工具 + +### 添加认证机制 + +可在 `create_starlette_app` 中添加 Starlette 中间件实现认证: + +- API Key 认证:添加中间件检查请求头 +- OAuth2:集成 Starlette 的 OAuth 支持 +- 反向代理:部署时在前端 Nginx/Caddy 层实现认证 diff --git a/repowiki/Media_Converters.md b/repowiki/Media_Converters.md new file mode 100644 index 000000000..bf9042d88 --- /dev/null +++ b/repowiki/Media_Converters.md @@ -0,0 +1,332 @@ +# Media Converters 模块 + +## 简介 + +Media Converters 模块是 markitdown-CN 项目中负责多媒体文件(音频和图片)转换为 Markdown 格式的核心模块。该模块通过元数据提取、语音转录和多模态大语言模型描述等多种技术手段,将非结构化的媒体文件内容转化为结构化的 Markdown 文本,从而支持下游的文档处理和信息检索场景。 + +模块设计遵循插件化架构,`AudioConverter` 和 `ImageConverter` 均继承自 `DocumentConverter` 基类,通过 `accepts()` 方法实现文件类型的自动识别与分发,通过 `convert()` 方法完成实际的转换逻辑。底层依赖 ExifTool 进行元数据提取,依赖 SpeechRecognition 进行语音转录,并可对接 OpenAI 兼容的多模态 LLM 生成图片描述。 + +--- + +## 模块架构 + +### 组件总览 + +```mermaid +graph TB + subgraph MediaConverters[Media Converters 模块] + AC[AudioConverter] + IC[ImageConverter] + TA[transcribe_audio] + LC[llm_caption] + EM[exiftool_metadata] + PV[_parse_version] + end + + subgraph ExternalDeps[外部依赖] + ET[ExifTool] + SR[SpeechRecognition] + PD[pydub] + LLM[OpenAI LLM API] + end + + AC --> EM + AC --> TA + IC --> EM + IC --> LC + TA --> SR + TA --> PD + LC --> LLM + EM --> ET + EM --> PV +``` + +### 类与函数的职责关系 + +| 组件 | 类型 | 职责 | +|------|------|------| +| `AudioConverter` | 类 | 音频文件的完整转换流程:元数据提取 + 语音转录 | +| `ImageConverter` | 类 | 图片文件的完整转换流程:元数据提取 + LLM 描述生成 | +| `transcribe_audio` | 函数 | 底层音频转录工具函数 | +| `llm_caption` | 函数 | 底层 LLM 图片描述工具函数 | +| `exiftool_metadata` | 函数 | 通过 ExifTool 提取文件元数据 | +| `_parse_version` | 函数 | 解析 ExifTool 版本号(内部辅助) | + +--- + +## 核心组件详解 + +### AudioConverter 类 + +`AudioConverter` 继承自 `DocumentConverter`,负责将音频文件转换为 Markdown 文本。它组合了元数据提取和语音转录两种能力。 + +#### 支持的文件类型 + +通过 `accepts()` 方法,AudioConverter 支持以下音频格式的判断: + +- 基于文件扩展名:`ACCEPTED_FILE_EXTENSIONS` 列表中的扩展名 +- 基于 MIME 类型:以 `ACCEPTED_MIME_TYPE_PREFIXES` 中任一前缀开头的 MIME 类型 + +#### 转换流程 + +```mermaid +flowchart TD + A[接收音频文件流] --> B[调用 exiftool_metadata 提取元数据] + B --> C{元数据是否存在} + C -->|是| D[提取标题、艺术家、专辑等字段] + C -->|否| E[跳过元数据] + D --> F[判断音频格式] + E --> F + F --> G{格式是否支持转录} + G -->|是| H[调用 transcribe_audio 转录] + G -->|否| I[跳过转录] + H --> J{转录是否成功} + J -->|是| K[拼接转录文本到 Markdown] + J -->|否| L[静默忽略缺失依赖异常] + I --> M[返回 DocumentConverterResult] + K --> M + L --> M +``` + +#### 元数据字段 + +AudioConverter 从 ExifTool 返回的元数据中提取以下字段: + +- `Title` — 音频标题 +- `Artist` / `Author` / `Band` — 艺术家/作者/乐队 +- `Album` — 专辑名 +- `Genre` — 流派 +- `Track` — 音轨号 +- `DateTimeOriginal` / `CreateDate` — 创建时间 +- `NumChannels` — 声道数 +- `SampleRate` — 采样率 +- `AvgBytesPerSec` — 平均字节率 +- `BitsPerSample` — 位深度 + +> **注意**:`Duration` 字段因从内存读取时值不正确,已被显式排除。 + +#### 转录支持的音频格式 + +| 扩展名/MIME | 转录格式标识 | +|------------|-------------| +| `.wav` / `audio/x-wav` | `wav` | +| `.mp3` / `audio/mpeg` | `mp3` | +| `.mp4` / `.m4a` / `video/mp4` | `mp4` | +| 其他 | 不进行转录 | + +--- + +### ImageConverter 类 + +`ImageConverter` 继承自 `DocumentConverter`,负责将图片文件转换为 Markdown 文本。它结合了元数据提取和多模态 LLM 图片描述两种能力。 + +#### 转换流程 + +```mermaid +flowchart TD + A[接收图片文件流] --> B[调用 exiftool_metadata 提取元数据] + B --> C{元数据是否存在} + C -->|是| D[提取图片尺寸、标题、GPS等字段] + C -->|否| E[跳过元数据] + D --> F{是否配置了 llm_client 和 llm_model} + E --> F + F -->|是| G[调用 _get_llm_description] + F -->|否| H[跳过 LLM 描述] + G --> I{描述是否生成} + I -->|是| J[拼接描述到 Markdown] + I -->|否| H + J --> K[返回 DocumentConverterResult] + H --> K +``` + +#### 元数据字段 + +ImageConverter 从 ExifTool 返回的元数据中提取以下字段: + +- `ImageSize` — 图片尺寸 +- `Title` / `Caption` / `Description` — 标题/说明 +- `Keywords` — 关键词 +- `Artist` / `Author` — 作者 +- `DateTimeOriginal` / `CreateDate` — 创建时间 +- `GPSPosition` — GPS 定位信息 + +#### LLM 描述生成 + +`_get_llm_description()` 方法的内部流程: + +1. **提示词准备**:若未提供自定义 prompt,则使用默认值 `"Write a detailed caption for this image."` +2. **内容类型推断**:优先使用 `stream_info.mimetype`,否则通过 `mimetypes.guess_type()` 推断,最终回退到 `application/octet-stream` +3. **Base64 编码**:将图片流读取并编码为 Base64,构建 data URI +4. **API 调用**:按照 OpenAI Chat Completions 格式构造多模态消息,调用 LLM 生成图片描述 +5. **流位置恢复**:在读取流内容后,通过 `seek()` 恢复流的读取位置,确保后续操作不受影响 + +--- + +### transcribe_audio 函数 + +`transcribe_audio` 是音频转录的底层工具函数,封装了 SpeechRecognition 库的调用逻辑。 + +#### 处理流程 + +```mermaid +flowchart TD + A[接收文件流和音频格式] --> B{依赖是否已安装} + B -->|否| C[抛出 MissingDependencyException] + B -->|是| D{音频格式判断} + D -->|wav/aiff/flac| E[直接使用文件流] + D -->|mp3/mp4| F[通过 pydub 转换为 WAV] + D -->|其他| G[抛出 ValueError] + F --> H[创建 BytesIO 作为中间缓冲] + E --> I[创建 SpeechRecognition Recognizer] + H --> I + I --> J[使用 AudioFile 加载音频] + J --> K[调用 recognize_google 进行转录] + K --> L{转录结果是否为空} + L -->|是| M[返回 No speech detected] + L -->|否| N[返回转录文本] +``` + +#### 关键设计 + +- **可选依赖管理**:通过模块级变量 `_dependency_exc_info` 检测 `speech_recognition` 和 `pydub` 是否已安装。若未安装,在调用时抛出 `MissingDependencyException`,附带安装指引 +- **格式转换**:对于 MP3 和 MP4 格式,先通过 pydub 转换为 WAV 格式后再交给 SpeechRecognition 处理 +- **转录引擎**:使用 Google Web Speech API (`recognize_google`) 进行在线语音识别 + +--- + +### llm_caption 函数 + +`llm_caption` 是图片 LLM 描述的底层工具函数,与 `ImageConverter._get_llm_description()` 功能基本一致,提供独立的函数接口供其他模块调用。 + +#### 处理逻辑 + +1. 使用默认或自定义 prompt +2. 推断内容类型(MIME type) +3. 将图片编码为 Base64 并构造 data URI +4. 按照 OpenAI 多模态消息格式调用 Chat Completions API +5. 返回生成的描述文本 + +> **设计说明**:`llm_caption` 和 `ImageConverter._get_llm_description` 共享相同的调用模式,前者作为公共函数可被其他转换器(如 [DOCX_Math_Utils](DOCX_Math_Utils.md) 等模块)复用。 + +--- + +### exiftool_metadata 函数 + +`exiftool_metadata` 负责通过外部调用 ExifTool 命令行工具来提取文件的元数据。 + +#### 处理流程 + +```mermaid +flowchart TD + A[接收文件流和 exiftool 路径] --> B{exiftool_path 是否提供} + B -->|否| C[返回空字典] + B -->|是| D[执行 exiftool -ver 获取版本号] + D --> E{版本是否大于等于 12.24} + E -->|否| F[抛出 RuntimeError CVE-2021-22204] + E -->|是| G[执行 exiftool -json 提取元数据] + G --> H[解析 JSON 输出] + H --> I[返回元数据字典] + G --> J[恢复文件流位置] +``` + +#### 安全设计 + +- **版本检查**:ExifTool 12.24 以下版本存在 CVE-2021-22204 安全漏洞,函数会在执行前强制检查版本号,不满足要求时抛出运行时错误 +- **流位置恢复**:通过 `try/finally` 确保文件流位置在读取后被恢复 +- **编码处理**:使用 `locale.getpreferredencoding(False)` 解码 ExifTool 的输出,兼容不同操作系统平台的编码差异 + +### _parse_version 函数 + +`_parse_version` 是内部辅助函数,将形如 `"12.24"` 的版本字符串解析为整数元组 `(12, 24)`,用于版本比较。 + +--- + +## 数据流 + +```mermaid +sequenceDiagram + participant User as 调用方 + participant Conv as AudioConverter/ImageConverter + participant Exif as exiftool_metadata + participant ET as ExifTool 进程 + participant Trans as transcribe_audio/llm_caption + participant API as Google Speech API / OpenAI LLM + + User->>Conv: convert(file_stream, stream_info) + Conv->>Exif: exiftool_metadata(file_stream) + Exif->>ET: subprocess exiftool -json + ET-->>Exif: JSON 元数据 + Exif-->>Conv: 元数据字典 + Conv->>Conv: 提取关键字段拼接 Markdown + alt 音频文件 + Conv->>Trans: transcribe_audio(file_stream, format) + Trans->>API: recognize_google(audio) + API-->>Trans: 转录文本 + Trans-->>Conv: 转录结果 + else 图片文件且配置了 LLM + Conv->>Trans: llm_caption(file_stream, client, model) + Trans->>API: chat.completions.create(messages) + API-->>Trans: 描述文本 + Trans-->>Conv: 描述结果 + end + Conv-->>User: DocumentConverterResult(markdown) +``` + +--- + +## 依赖关系 + +### 内部依赖 + +- `AudioConverter` 依赖 `exiftool_metadata` 和 `transcribe_audio` +- `ImageConverter` 依赖 `exiftool_metadata` 和 `llm_caption`(通过 `_get_llm_description`) +- `exiftool_metadata` 依赖 `_parse_version` + +### 外部依赖 + +| 依赖 | 类型 | 用途 | 是否可选 | +|------|------|------|----------| +| ExifTool (>=12.24) | 外部命令行工具 | 元数据提取 | 是(未安装时跳过) | +| `speech_recognition` | Python 库 | 语音转录 | 是(可选依赖组 `audio-transcription`) | +| `pydub` | Python 库 | 音频格式转换 | 是(可选依赖组 `audio-transcription`) | +| OpenAI Python SDK | Python 库 | 多模态 LLM 调用 | 是(需配置 `llm_client`) | + +### 与其他模块的关系 + +- 本模块的 `AudioConverter` 和 `ImageConverter` 均实现 `DocumentConverter` 接口,由主转换器注册和调度 +- `llm_caption` 函数可被其他需要图片描述的模块复用 +- `exiftool_metadata` 作为通用元数据提取工具,理论上可服务于其他媒体类型的转换器 + +--- + +## 错误处理策略 + +| 场景 | 处理方式 | +|------|----------| +| ExifTool 未安装或路径未配置 | 返回空元数据字典,不影响后续流程 | +| ExifTool 版本过低 (<12.24) | 抛出 `RuntimeError`,提示安全漏洞信息 | +| SpeechRecognition 未安装 | 抛出 `MissingDependencyException`,附带安装命令 | +| 音频格式不支持转录 | `AudioConverter` 中设置 `audio_format=None`,跳过转录 | +| 转录过程异常 | 捕获 `MissingDependencyException` 后静默忽略 | +| LLM 调用失败 | `_get_llm_description` 捕获异常返回 `None` | +| 图片 Base64 编码失败 | 捕获异常返回 `None`,不影响元数据部分输出 | + +--- + +## 扩展指南 + +### 添加新的媒体转换器 + +1. 创建新类继承 `DocumentConverter` +2. 实现 `accepts()` 方法定义支持的文件类型 +3. 实现 `convert()` 方法编写转换逻辑 +4. 复用 `exiftool_metadata` 获取元数据 +5. 根据需要复用 `transcribe_audio` 或 `llm_caption` + +### 添加新的音频转录引擎 + +当前仅支持 Google Web Speech API。若需添加其他引擎(如 Whisper),可在 `transcribe_audio` 中增加引擎选择逻辑,或创建新的转录函数。 + +### 自定义 LLM 描述行为 + +通过 `llm_prompt` 参数传入自定义提示词,可控制 LLM 生成的描述风格和关注点。例如针对特定领域的图片(医学影像、建筑图纸等)定制提示词。 diff --git a/repowiki/OCR_Plugin.md b/repowiki/OCR_Plugin.md new file mode 100644 index 000000000..a55ea973a --- /dev/null +++ b/repowiki/OCR_Plugin.md @@ -0,0 +1,408 @@ +# OCR_Plugin 模块文档 + +## 概述 + +OCR_Plugin 是 markitdown-CN 项目中的核心插件模块,为 MarkItDown 文档转换框架提供 **基于 LLM 视觉模型的光学字符识别(OCR)能力**。该插件通过替换内置转换器,使系统能够从 PDF、DOCX、PPTX、XLSX 等文档中的嵌入图片自动提取文字内容,并将 OCR 结果以内联方式嵌入到输出的 Markdown 文档中。 + +### 核心能力 + +- **多格式支持**:覆盖 PDF、DOCX、PPTX、XLSX 四种主流办公文档格式 +- **LLM 视觉 OCR**:利用 OpenAI 兼容的视觉大模型(如 GPT-4o、Gemini)进行文字识别 +- **智能降级**:PDF 转换支持扫描型文档的整页 OCR 降级处理 +- **插件化架构**:以优先级机制替换内置转换器,即插即用 +- **统一输出格式**:所有 OCR 结果使用一致的 Markdown 标记格式 + +--- + +## 架构概览 + +```mermaid +graph TB + subgraph PluginEntry["插件入口"] + RC[register_converters] + end + + subgraph OCRCore["OCR 核心服务"] + OCRS[LLMVisionOCRService] + OCRRes[OCRResult] + end + + subgraph Converters["OCR 增强转换器"] + PDF[PdfConverterWithOCR] + DOCX[DocxConverterWithOCR] + PPTX[PptxConverterWithOCR] + XLSX[XlsxConverterWithOCR] + end + + subgraph External["外部依赖"] + LLM[OpenAI 兼容 LLM] + Mammoth[mammoth] + Pdfplumber[pdfplumber] + Openpyxl[openpyxl] + PythonPPTX[python-pptx] + end + + RC --> PDF + RC --> DOCX + RC --> PPTX + RC --> XLSX + RC --> OCRS + + PDF --> OCRS + DOCX --> OCRS + PPTX --> OCRS + XLSX --> OCRS + + OCRS --> OCRRes + OCRS --> LLM + + DOCX --> Mammoth + PDF --> Pdfplumber + XLSX --> Openpyxl + PPTX --> PythonPPTX +``` + +--- + +## 组件详解 + +### 1. OCRResult — OCR 结果数据类 + +`OCRResult` 是一个轻量级数据类,封装单次 OCR 操作的返回结果。 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `text` | `str` | 提取的文字内容 | +| `confidence` | `float \| None` | 置信度评分(可选) | +| `backend_used` | `str \| None` | 使用的后端名称,如 `"llm_vision"` | +| `error` | `str \| None` | 错误信息(如发生异常) | + +该数据类在所有转换器中统一使用,确保 OCR 结果的一致性。 + +--- + +### 2. LLMVisionOCRService — LLM 视觉 OCR 服务 + +`LLMVisionOCRService` 是本模块的核心引擎,通过 OpenAI 兼容的视觉大模型实现图片文字提取。 + +#### 初始化参数 + +| 参数 | 类型 | 说明 | +|------|------|------| +| `client` | `Any` | OpenAI 兼容的 API 客户端 | +| `model` | `str` | 模型名称(如 `gpt-4o`、`gemini-2.0-flash`) | +| `default_prompt` | `str \| None` | 自定义 OCR 提示词 | + +#### 文字提取流程 + +```mermaid +flowchart TD + A[接收 image_stream] --> B{client 是否配置} + B -->|否| C[返回空结果与错误信息] + B -->|是| D[检测图片 MIME 类型] + D --> E{stream_info 有 mimetype} + E -->|是| F[使用提供的 content_type] + E -->|否| G[使用 PIL 检测格式] + G --> H{PIL 检测成功} + H -->|是| I[使用检测到的格式] + H -->|否| J[默认 image/png] + F --> K[Base64 编码图片] + I --> K + J --> K + K --> L[构造 data URI] + L --> M[调用 LLM chat completions API] + M --> N{调用成功} + N -->|是| O[返回 OCRResult 含提取文字] + N -->|否| P[返回 OCRResult 含错误信息] +``` + +#### 核心方法 `extract_text` + +- 将图片流编码为 Base64 Data URI +- 构造多模态消息(文字提示 + 图片 URL) +- 调用 LLM 的 `chat.completions.create` 接口 +- 默认提示词要求模型仅返回提取的文字,保持原始布局 + +--- + +### 3. DocxConverterWithOCR — DOCX 增强转换器 + +继承自 `HtmlConverter`,在标准 DOCX 转 Markdown 流程中增加图片 OCR 能力。 + +#### 转换流程(OCR 模式) + +```mermaid +flowchart TD + A[接收 DOCX 文件流] --> B{是否有 OCR 服务} + B -->|否| C[标准转换 mammoth DOCX to HTML to MD] + B -->|是| D[步骤1 提取并 OCR 嵌入图片] + D --> E[步骤2 mammoth 转换 DOCX 为 HTML] + E --> F[步骤3 替换 img 标签为占位符] + F --> G[步骤4 HtmlConverter 将 HTML 转为 Markdown] + G --> H[步骤5 替换占位符为 OCR 文本块] + H --> I[返回最终 Markdown] +``` + +#### 关键内部方法 + +| 方法 | 功能 | +|------|------| +| `_extract_and_ocr_images` | 遍历 DOCX 文档关系,提取所有图片资源并调用 OCR | +| `_inject_placeholders` | 用正则替换 HTML 中的 `` 标签为编号占位符,返回有序 OCR 文本列表 | + +#### 占位符机制 + +为避免 Markdown 转换过程中 OCR 文本中的特殊字符(如 `*`、`_`)被转义,采用两阶段替换策略: + +1. **注入阶段**:将 `` 标签替换为安全占位符(如 `{{OCR_PLACEHOLDER_0}}`) +2. **回填阶段**:在 HTML→Markdown 转换完成后,将占位符替换为带标记的 OCR 文本块 + +--- + +### 4. PdfConverterWithOCR — PDF 增强转换器 + +继承自 `DocumentConverter`,是本模块中最复杂的转换器,支持三种处理模式。 + +#### 转换流程 + +```mermaid +flowchart TD + A[接收 PDF 文件流] --> B[使用 pdfplumber 打开 PDF] + B --> C{是否有 OCR 服务} + C -->|否| D[逐页提取纯文本] + C -->|是| E[逐页处理] + E --> F{页面是否含图片} + F -->|否| G[提取页面文本行及 Y 坐标] + F -->|是| H[提取文本行 Y 坐标] + H --> I[OCR 所有图片获取 Y 坐标] + I --> J[合并文本行与图片数据] + J --> K[按 Y 坐标排序交叉排列] + K --> L[构建 Markdown 输出] + G --> L + D --> M{结果为空} + M -->|否| N[返回 Markdown] + M -->|是| O[pdfminer 降级提取] + L --> P{最终结果为空} + P -->|否| N + P -->|是| Q[整页 OCR 降级处理] + O --> N + Q --> N +``` + +#### 三种处理模式 + +| 模式 | 触发条件 | 处理方式 | +|------|----------|----------| +| 标准模式 | 无 OCR 服务 | 使用 pdfplumber 逐页提取文本 | +| OCR 交叉模式 | 有 OCR 服务且页面含图片 | 提取文本行和图片的 Y 坐标,按位置交叉排列 | +| 整页 OCR 降级 | 有 OCR 服务但文本提取为空 | 将整页渲染为图片后执行 OCR | + +#### 整页 OCR 降级 `_ocr_full_pages` + +当标准文本提取和 pdfplumber 处理均返回空结果时,系统将其视为扫描型 PDF: + +1. 使用 pdfplumber 以 300 DPI 渲染每页为 PNG +2. 若 pdfplumber 渲染失败,降级使用 PyMuPDF(fitz) +3. 对每页渲染结果调用 OCR 服务 + +#### 辅助函数 `_extract_images_from_page` + +独立的 PDF 页面图片提取函数,使用多种策略检测图片: + +- **方法 1**:`page.images` 标准接口 +- **方法 2**:`page.objects["image"]` 底层对象 +- **方法 3**:遍历所有对象查找 image/xobject 类型 + +图片获取也采用双重策略: +- **策略 A**:直接从 stream 对象获取图片数据 +- **策略 B**:通过 pdfplumber 的 `within_bbox` 裁剪页面区域并渲染 + +--- + +### 5. PptxConverterWithOCR — PPTX 增强转换器 + +继承自 `DocumentConverter`,在遍历幻灯片形状时增加图片 OCR 降级能力。 + +#### 形状处理流程 + +```mermaid +flowchart TD + A[遍历幻灯片形状] --> B{形状类型判断} + B -->|图片| C{LLM 描述可用} + C -->|是| D[使用 llm_caption 获取描述] + C -->|否| E{OCR 服务可用} + E -->|是| F[调用 OCR 提取文字] + E -->|否| G[跳过] + D --> H[输出 OCR 文本块] + F --> H + B -->|表格| I[转换为 Markdown 表格] + B -->|图表| J[转换为 Markdown 数据表] + B -->|文本框| K[提取文本内容] + B -->|分组形状| L[递归处理子形状] +``` + +#### 特殊处理 + +- **图片处理优先级**:先尝试 LLM 描述(`llm_caption`),失败后降级到 OCR +- **形状排序**:按 `(top, left)` 坐标排序,确保内容按视觉顺序输出 +- **表格转换**:通过 HTML 中间格式转换为 Markdown 表格 +- **图表转换**:提取分类名和系列数据,生成 Markdown 表格 + +--- + +### 6. XlsxConverterWithOCR — XLSX 增强转换器 + +继承自 `DocumentConverter`,在标准 Excel 表格转换基础上增加工作表图片 OCR。 + +#### 转换流程 + +```mermaid +flowchart TD + A[接收 XLSX 文件流] --> B{是否有 OCR 服务} + B -->|否| C[标准转换 pandas read_excel to HTML to MD] + B -->|是| D[遍历工作表] + D --> E[使用 pandas 转换表格数据为 Markdown] + E --> F[使用 openpyxl 提取工作表图片] + F --> G{是否有图片} + G -->|是| H[OCR 每张图片并追加结果] + G -->|否| I[继续下一个工作表] + H --> I +``` + +#### 图片位置追踪 + +XLSX 转换器通过 openpyxl 的 `_images` 属性访问工作表图片,并尝试从锚点信息(`anchor._from`)获取单元格引用位置,使用 `_column_number_to_letter` 将列号转换为 Excel 列字母。 + +--- + +### 7. register_converters — 插件注册入口 + +插件的核心入口函数,负责创建 OCR 服务并将四个增强转换器注册到 MarkItDown 实例。 + +#### 注册流程 + +```mermaid +flowchart TD + A[register_converters 调用] --> B[从 kwargs 提取 LLM 配置] + B --> C{llm_client 和 llm_model 均存在} + C -->|是| D[创建 LLMVisionOCRService] + C -->|否| E[ocr_service 设为 None] + D --> F[注册 PdfConverterWithOCR 优先级 -1.0] + E --> F + F --> G[注册 DocxConverterWithOCR 优先级 -1.0] + G --> H[注册 PptxConverterWithOCR 优先级 -1.0] + H --> I[注册 XlsxConverterWithOCR 优先级 -1.0] +``` + +#### 优先级机制 + +| 转换器 | 优先级 | 说明 | +|--------|--------|------| +| OCR 增强转换器 | `-1.0` | 优先于内置转换器执行 | +| 内置转换器 | `0.0` | 默认优先级 | + +通过设置 `-1.0` 优先级,OCR 增强转换器在内置转换器之前被匹配,从而在插件启用时自动替换默认行为。 + +#### 支持的 kwargs 参数 + +| 参数 | 说明 | +|------|------| +| `llm_client` | OpenAI 兼容的 API 客户端(必需) | +| `llm_model` | 模型名称,如 `gpt-4o`(必需) | +| `llm_prompt` | 自定义 OCR 提示词(可选) | + +--- + +## 统一 OCR 输出格式 + +所有转换器使用统一的 OCR 文本块标记格式: + +``` +*[Image OCR] +提取的文字内容 +[End OCR]* +``` + +该格式使用 Markdown 斜体标记(`*`)包裹,便于后续解析和样式区分。 + +--- + +## 依赖关系 + +```mermaid +graph LR + subgraph Internal["内部依赖"] + A[markitdown 核心框架] + B[HtmlConverter] + C[DocumentConverter] + D[StreamInfo] + E[DocumentConverterResult] + end + + subgraph External["外部依赖"] + F[mammoth - DOCX 转 HTML] + G[pdfplumber - PDF 解析] + H[pdfminer - PDF 降级提取] + I[PyMuPDF fitz - 扫描 PDF 渲染] + J[python-pptx - PPTX 解析] + K[openpyxl - XLSX 图片提取] + L[pandas - XLSX 表格读取] + M[PIL Pillow - 图片格式检测] + N[OpenAI SDK - LLM 调用] + end + + A --> B + A --> C + A --> D + A --> E +``` + +--- + +## 数据流全景 + +```mermaid +sequenceDiagram + participant User as 用户 + participant MID as MarkItDown + participant Plugin as OCR Plugin + participant Conv as 具体转换器 + participant OCR as LLMVisionOCRService + participant LLM as LLM 视觉模型 + + User->>MID: 转换文档请求 + MID->>Plugin: 匹配转换器 优先级 -1.0 + Plugin->>Conv: 调用 convert 方法 + Conv->>Conv: 提取文档中的嵌入图片 + Conv->>OCR: extract_text 图片流 + OCR->>OCR: Base64 编码图片 + OCR->>LLM: 多模态请求 文字+图片 + LLM-->>OCR: 返回提取的文字 + OCR-->>Conv: 返回 OCRResult + Conv->>Conv: 将 OCR 文本嵌入 Markdown + Conv-->>MID: 返回 DocumentConverterResult + MID-->>User: 返回最终 Markdown +``` + +--- + +## 与 Sample_Plugin 的关系 + +OCR_Plugin 与 [Sample_Plugin](Sample_Plugin.md) 同为 markitdown-CN 的插件模块,共享相同的插件注册接口(`register_converters` 函数签名)。两者的区别在于: + +- **OCR_Plugin**:替换内置转换器,增加 OCR 能力,处理 PDF/DOCX/PPTX/XLSX +- **Sample_Plugin**:扩展现有转换器,增加对新文件格式(如 RTF)的支持 + +--- + +## 错误处理策略 + +| 场景 | 处理方式 | +|------|----------| +| 缺少依赖包 | 抛出 `MissingDependencyException`,提示安装对应 feature | +| LLM 客户端未配置 | 返回空 OCRResult 并携带错误信息 | +| 单张图片 OCR 失败 | 捕获异常并跳过,继续处理其他图片 | +| PDF 文本提取为空 | 降级到 pdfminer,再降级到整页 OCR | +| pdfplumber 渲染失败 | 降级到 PyMuPDF 进行页面渲染 | +| PPTX 图表类型不支持 | 返回 `[unsupported chart]` 占位文本 | +| XLSX 表格读取失败 | 跳过该表格,继续处理图片 | diff --git a/repowiki/Sample_Plugin.md b/repowiki/Sample_Plugin.md new file mode 100644 index 000000000..c44191418 --- /dev/null +++ b/repowiki/Sample_Plugin.md @@ -0,0 +1,189 @@ +# Sample_Plugin 模块文档 + +## 概述 + +Sample_Plugin 是 markitdown-CN 项目中的示例插件模块,为 MarkItDown 文档转换框架提供 **RTF(富文本格式)文件转换能力**。该插件将 RTF 文件转换为纯文本 Markdown,是理解 MarkItDown 插件开发模式的最佳参考实现。 + +### 核心能力 + +- **RTF 格式支持**:识别并转换 RTF 文档为 Markdown 纯文本 +- **字符编码自适应**:优先使用 StreamInfo 提供的字符集,否则回退到系统默认编码 +- **最小化实现**:以最少代码展示完整的插件开发模式 +- **即插即用**:通过标准 `register_converters` 接口无缝集成 + +--- + +## 架构概览 + +```mermaid +graph TB + subgraph PluginModule["Sample_Plugin 插件"] + RC[register_converters] + RTC[RtfConverter] + end + + subgraph MarkItDownCore["MarkItDown 核心"] + MID[MarkItDown 实例] + DC[DocumentConverter 基类] + SI[StreamInfo] + DCR[DocumentConverterResult] + end + + subgraph ExternalLib["外部依赖"] + RTF[rtfde / striprtf] + end + + RC --> RTC + RC --> MID + RTC --> DC + RTC --> SI + RTC --> DCR + RTC --> RTF +``` + +--- + +## 组件详解 + +### 1. RtfConverter — RTF 转换器 + +`RtfConverter` 继承自 `DocumentConverter`,负责将 RTF 格式文档转换为纯文本 Markdown。 + +#### 文件识别逻辑 + +`accepts` 方法通过文件扩展名和 MIME 类型双重判断是否支持输入文件: + +```mermaid +flowchart TD + A[接收文件流和 StreamInfo] --> B{扩展名在 ACCEPTED_FILE_EXTENSIONS 中} + B -->|是| C[返回 True 接受文件] + B -->|否| D{MIME 类型匹配 ACCEPTED_MIME_TYPE_PREFIXES} + D -->|是| C + D -->|否| E[返回 False 拒绝文件] +``` + +| 判断依据 | 常量 | 说明 | +|----------|------|------| +| 文件扩展名 | `ACCEPTED_FILE_EXTENSIONS` | 接受的 RTF 文件扩展名列表 | +| MIME 类型前缀 | `ACCEPTED_MIME_TYPE_PREFIXES` | 接受的 RTF MIME 类型前缀列表 | + +#### 转换流程 + +```mermaid +flowchart TD + A[接收 RTF 文件流] --> B[确定字符编码] + B --> C{StreamInfo 提供 charset} + C -->|是| D[使用提供的编码] + C -->|否| E[使用系统默认编码] + D --> F[将字节流解码为字符串] + E --> F + F --> G[调用 rtf_to_text 提取纯文本] + G --> H[返回 DocumentConverterResult] +``` + +#### 编码策略 + +| 优先级 | 来源 | 说明 | +|--------|------|------| +| 1 | `stream_info.charset` | 来自文件元数据的字符集 | +| 2 | `locale.getpreferredencoding()` | 系统默认编码(如 UTF-8、GBK) | + +--- + +### 2. register_converters — 插件注册入口 + +插件入口函数,负责创建 `RtfConverter` 实例并注册到 MarkItDown。 + +```mermaid +flowchart TD + A[register_converters 调用] --> B[创建 RtfConverter 实例] + B --> C[调用 markitdown.register_converter] + C --> D[RtfConverter 注册完成] +``` + +#### 与 OCR_Plugin 注册机制的对比 + +| 特性 | Sample_Plugin | [OCR_Plugin](OCR_Plugin.md) | +|------|---------------|------------------| +| 注册转换器数量 | 1 个 | 4 个 | +| 默认优先级 | `0.0`(默认) | `-1.0`(优先) | +| 依赖外部服务 | 无 | 需要 LLM 客户端 | +| 构造函数参数 | 无 | OCR 服务实例 | + +--- + +## 数据流全景 + +```mermaid +sequenceDiagram + participant User as 用户 + participant MID as MarkItDown + participant RTC as RtfConverter + participant RTF as rtf_to_text + + User->>MID: 转换 RTF 文件请求 + MID->>RTC: accepts 检查文件类型 + RTC-->>MID: True + MID->>RTC: convert 方法调用 + RTC->>RTC: 解码字节流为字符串 + RTC->>RTF: rtf_to_text 提取纯文本 + RTF-->>RTC: 返回纯文本 + RTC-->>MID: 返回 DocumentConverterResult + MID-->>User: 返回 Markdown 文本 +``` + +--- + +## 插件开发模式参考 + +Sample_Plugin 作为示例插件,展示了 MarkItDown 插件开发的核心模式: + +### 基本步骤 + +1. **继承 DocumentConverter**:创建自定义转换器类 +2. **实现 `accepts` 方法**:定义支持的文件类型 +3. **实现 `convert` 方法**:定义转换逻辑 +4. **导出 `register_converters`**:注册转换器到 MarkItDown + +### 设计原则 + +| 原则 | 说明 | +|------|------| +| 单一职责 | 每个转换器只处理一种文件格式 | +| 防御性编程 | 使用 try-except 处理编码和解析错误 | +| 优雅降级 | 优先使用精确编码,失败时回退到系统默认 | +| 零配置 | 无需额外参数即可工作 | + +--- + +## 依赖关系 + +```mermaid +graph LR + subgraph Internal["内部依赖"] + A[markitdown 核心框架] + B[DocumentConverter 基类] + C[StreamInfo 文件元数据] + D[DocumentConverterResult 结果封装] + end + + subgraph External["外部依赖"] + E[rtf_to_text 函数] + F[locale 标准库] + end + + A --> B + A --> C + A --> D +``` + +--- + +## 与 OCR_Plugin 的关系 + +Sample_Plugin 与 [OCR_Plugin](OCR_Plugin.md) 同为 markitdown-CN 的插件模块,但定位不同: + +- **Sample_Plugin**:示例插件,扩展现有格式支持(RTF),实现简单,无外部服务依赖 +- **OCR_Plugin**:功能插件,替换内置转换器增加 OCR 能力,实现复杂,需要 LLM 服务 + +两者共享相同的插件接口规范,开发者可参考 Sample_Plugin 的实现模式开发自定义插件。 diff --git a/repowiki/Web_Content_Converters.md b/repowiki/Web_Content_Converters.md new file mode 100644 index 000000000..429417349 --- /dev/null +++ b/repowiki/Web_Content_Converters.md @@ -0,0 +1,288 @@ +# Web_Content_Converters 模块 + +## 模块简介 + +Web_Content_Converters 是 markitdown-CN 项目中负责 **网页类内容转换** 的核心模块。该模块将来自不同 Web 来源的 HTML 内容(普通网页、搜索引擎结果页、RSS/Atom 订阅源、Wikipedia 百科页面、YouTube 视频页面)统一转换为结构化的 Markdown 文档。 + +所有转换器均继承自基类 `DocumentConverter`,遵循统一的 `accepts()` / `convert()` 接口契约,使得上层调度器(参见 [MarkItDown](MarkItDown.md))可以通过一致的管线(pipeline)自动选择并调用合适的转换器。 + +## 核心功能一览 + +| 转换器 | 输入类型 | 核心能力 | +|---|---|---| +| HtmlConverter | 通用 HTML | HTML → Markdown,支持递归深度降级 | +| BingSerpConverter | Bing 搜索结果页 | 解析有机搜索结果,解码 Base64 重定向链接 | +| RssConverter | RSS / Atom 订阅 | 识别 Feed 类型,解析频道与条目 | +| WikipediaConverter | Wikipedia 页面 | 提取正文区域 `mw-content-text` | +| YouTubeConverter | YouTube 视频页 | 提取元数据 + 字幕(Transcript) | + +--- + +## 架构总览 + +```mermaid +graph TB + subgraph WebContentConverters + A[DocumentConverter 基类] + B[HtmlConverter] + C[BingSerpConverter] + D[RssConverter] + E[WikipediaConverter] + F[YouTubeConverter] + end + + subgraph SharedUtilities + G[CustomMarkdownify] + H[BeautifulSoup] + I[StreamInfo] + J[DocumentConverterResult] + end + + subgraph ExternalServices + K[YouTubeTranscriptApi] + L[minidom XML Parser] + end + + A --> B + A --> C + A --> D + A --> E + A --> F + + B --> G + B --> H + C --> G + C --> H + D --> G + D --> H + D --> L + E --> G + E --> H + F --> G + F --> H + F --> K + + B --> I + B --> J + C --> J + D --> J + E --> J + F --> J +``` + +--- + +## 转换流程 + +```mermaid +flowchart LR + A[输入流 BinaryIO] --> B[StreamInfo 判断] + B --> C{accepts 匹配?} + C -->|是| D[convert 转换] + C -->|否| E[跳过 尝试下一个转换器] + D --> F[BeautifulSoup 解析] + F --> G[清除 script/style] + G --> H[CustomMarkdownify 转 Markdown] + H --> I[DocumentConverterResult] +``` + +--- + +## 各组件详细说明 + +### 1. HtmlConverter — 通用 HTML 转换器 + +**源文件:** `_html_converter.py` + +**职责:** 处理任意 `text/html` 类型的内容,是最基础、最通用的 Web 内容转换器。其他专用转换器(如 BingSerpConverter、WikipediaConverter)在内部逻辑中也会间接依赖 HtmlConverter 的转换能力。 + +**工作流程:** + +1. **接受判断 (`accepts`):** 根据文件扩展名(`.html`、`.htm` 等)或 MIME 类型前缀(`text/html`)判断是否处理。 +2. **HTML 解析:** 使用 BeautifulSoup 的 `html.parser` 解析输入流,自动检测字符编码。 +3. **清理无关元素:** 移除所有 `