From 922184f3b27e23f909ac2161cc6632942c3f9efa Mon Sep 17 00:00:00 2001 From: myhloli Date: Thu, 11 Jun 2026 21:06:20 +0800 Subject: [PATCH 1/6] feat: enhance hybrid OCR detection by introducing candidate types for improved accuracy --- mineru/backend/hybrid/hybrid_analyze.py | 18 ++++++++++++++---- mineru/backend/hybrid/hybrid_magic_model.py | 14 +++++--------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/mineru/backend/hybrid/hybrid_analyze.py b/mineru/backend/hybrid/hybrid_analyze.py index 34cb066f..db595988 100644 --- a/mineru/backend/hybrid/hybrid_analyze.py +++ b/mineru/backend/hybrid/hybrid_analyze.py @@ -72,6 +72,12 @@ LAYOUT_TITLE_SPLIT_OVERLAP_THRESHOLD = 0.8 not_extract_list = [item.value for item in NotExtractType] HYBRID_OCR_DET_TEXT_TYPES = set(not_extract_list) +HYBRID_VLM_OCR_DET_TEXT_TYPES = { + MineruBlockType.TEXT, + MineruBlockType.TITLE, + MineruBlockType.DOC_TITLE, + MineruBlockType.PARAGRAPH_TITLE, +} HYBRID_ANALYZE_EFFORTS = {"medium", "high"} INLINE_FORMULA_CONTAINER_LABELS = {"table", "image", "chart", "display_formula"} MEDIUM_EFFORT_LAYOUT_LABEL_TO_VLM_TYPE = { @@ -126,9 +132,10 @@ def _apply_medium_visual_sub_type(block, label: str | None): block["sub_type"] = "seal" -def _is_hybrid_ocr_det_candidate(block): +def _is_hybrid_ocr_det_candidate(block, candidate_types=None): """判断 Hybrid 文本类块是否需要 OCR det 生成行级视觉信息。""" - return (block.get("type") or block.get("label")) in HYBRID_OCR_DET_TEXT_TYPES + candidate_types = candidate_types or HYBRID_OCR_DET_TEXT_TYPES + return (block.get("type") or block.get("label")) in candidate_types def ocr_classify(pdf_bytes, parse_method: str = 'auto',) -> bool: # 确定OCR设置 @@ -146,6 +153,8 @@ def ocr_det( model_list, mfd_res, batch_ratio: int = 1, + *, + candidate_types=None, ): mask_formula_for_ocr_det = get_ocr_det_mask_inline_formula_enable(True) @@ -169,7 +178,7 @@ def ocr_det( ocr_res_list.append([]) img_height, img_width = np_image.shape[:2] for res in page_results: - if not _is_hybrid_ocr_det_candidate(res): + if not _is_hybrid_ocr_det_candidate(res, candidate_types): continue x0 = max(0, int(res['bbox'][0] * img_width)) y0 = max(0, int(res['bbox'][1] * img_height)) @@ -220,7 +229,7 @@ def ocr_det( ocr_res_list.append([]) img_height, img_width = np_image.shape[:2] for res in page_results: - if not _is_hybrid_ocr_det_candidate(res): + if not _is_hybrid_ocr_det_candidate(res, candidate_types): continue x0 = max(0, int(res['bbox'][0] * img_width)) y0 = max(0, int(res['bbox'][1] * img_height)) @@ -773,6 +782,7 @@ def _apply_vlm_ocr_det_sidecars_for_window( model_list, formula_mask_inputs, batch_ratio=batch_ratio, + candidate_types=HYBRID_VLM_OCR_DET_TEXT_TYPES, ) _normalize_bbox(inline_formula_list, ocr_res_list, images_pil_list) model_list[:] = _merge_page_sidecar_items( diff --git a/mineru/backend/hybrid/hybrid_magic_model.py b/mineru/backend/hybrid/hybrid_magic_model.py index 1d963f74..8e41850c 100644 --- a/mineru/backend/hybrid/hybrid_magic_model.py +++ b/mineru/backend/hybrid/hybrid_magic_model.py @@ -27,15 +27,11 @@ not_extract_list = [item.value for item in NotExtractType] + [ BlockType.PARAGRAPH_TITLE, ] OCR_DET_LINES_KEY = "_ocr_det_lines" -OCR_DET_LINE_BLOCK_TYPES = set(not_extract_list) | { - BlockType.LIST, - BlockType.INDEX, - BlockType.ABSTRACT, - BlockType.ASIDE_TEXT, - BlockType.PHONETIC, - BlockType.CHART_CAPTION, - BlockType.CHART_FOOTNOTE, - BlockType.CODE_FOOTNOTE, +OCR_DET_LINE_BLOCK_TYPES = { + BlockType.TEXT, + BlockType.TITLE, + BlockType.DOC_TITLE, + BlockType.PARAGRAPH_TITLE, } From ea0905f46465b654dfe5c976518e16386e1fb45b Mon Sep 17 00:00:00 2001 From: myhloli Date: Thu, 11 Jun 2026 21:23:49 +0800 Subject: [PATCH 2/6] feat: add cross-script detection for CJK text to enhance OCR classification --- mineru/utils/pdf_classify.py | 117 +++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/mineru/utils/pdf_classify.py b/mineru/utils/pdf_classify.py index d56f567f..8e23c915 100644 --- a/mineru/utils/pdf_classify.py +++ b/mineru/utils/pdf_classify.py @@ -35,6 +35,41 @@ ASCII_PUNCT_RUN_MIN_LENGTH = 4 SUSPICIOUS_ASCII_PUNCT_MIN_TEXT_CHARS = 100 SUSPICIOUS_ASCII_PUNCT_RATIO_THRESHOLD = 0.25 SUSPICIOUS_ASCII_PUNCT_RUN_RATIO_THRESHOLD = 0.10 +SUSPICIOUS_CROSS_SCRIPT_MIN_TEXT_CHARS = 300 +SUSPICIOUS_CROSS_SCRIPT_MIN_CJK_CHARS = 100 +SUSPICIOUS_CROSS_SCRIPT_COUNT_THRESHOLD = 120 +SUSPICIOUS_CROSS_SCRIPT_RATIO_THRESHOLD = 0.18 +SUSPICIOUS_CROSS_SCRIPT_MIN_SCRIPT_COUNT = 3 +SUSPICIOUS_CROSS_SCRIPT_SCRIPT_MIN_CHARS = 5 +SUSPICIOUS_CROSS_SCRIPT_RANGES = ( + (0x0400, 0x052F, "Cyrillic"), + (0x0600, 0x06FF, "Arabic"), + (0x0700, 0x074F, "Syriac"), + (0x0750, 0x077F, "Arabic Supplement"), + (0x0780, 0x07BF, "Thaana"), + (0x07C0, 0x07FF, "NKo"), + (0x0800, 0x083F, "Samaritan"), + (0x0840, 0x085F, "Mandaic"), + (0x0860, 0x086F, "Syriac Supplement"), + (0x0870, 0x089F, "Arabic Extended-B"), + (0x0900, 0x097F, "Devanagari"), + (0x0C80, 0x0CFF, "Kannada"), + (0x1000, 0x109F, "Myanmar"), + (0x1100, 0x11FF, "Hangul Jamo"), + (0x1200, 0x137F, "Ethiopic"), + (0x13A0, 0x13FF, "Cherokee"), + (0x1400, 0x167F, "Canadian Syllabics"), + (0x1800, 0x18AF, "Mongolian"), + (0x1A20, 0x1AAF, "Tai Tham"), + (0x2C00, 0x2C5F, "Glagolitic"), + (0xA000, 0xA48F, "Yi"), +) +CJK_TEXT_RANGES = ( + (0x3400, 0x4DBF), + (0x4E00, 0x9FFF), + (0xF900, 0xFAFF), + (0x20000, 0x2EBEF), +) _ALLOWED_CONTROL_CODES = {9, 10, 13} _PRIVATE_USE_AREA_START = 0xE000 @@ -133,6 +168,20 @@ def classify(pdf_bytes): ): return "ocr" + cross_script_signal = _get_cross_script_text_signal_from_samples( + text_samples + ) + if cross_script_signal["triggered"]: + logger.debug( + "Classify PDF as OCR due to suspicious cross-script text: " + f"chars={cross_script_signal['total_chars']}, " + f"cjk={cross_script_signal['cjk_chars']}, " + f"suspicious={cross_script_signal['suspicious_chars']}, " + f"ratio={cross_script_signal['suspicious_ratio']:.4f}, " + f"scripts={cross_script_signal['top_scripts']}" + ) + return "ocr" + u72xx_signal = _get_u72xx_text_signal_from_samples(text_samples) if ( u72xx_signal["u72xx_count"] @@ -473,6 +522,74 @@ def _get_cid_font_usage_signal_from_samples(text_samples, cid_font_signal): return best_signal +def _is_cjk_text_char(char: str) -> bool: + """判断字符是否属于中文文档中可接受的 CJK 文字范围。""" + unicode_code = ord(char) + return any(start <= unicode_code <= end for start, end in CJK_TEXT_RANGES) + + +def _get_cross_script_name(char: str) -> str | None: + """识别中文文档乱码中常见的跨脚本字符块名称。""" + unicode_code = ord(char) + for start, end, script_name in SUSPICIOUS_CROSS_SCRIPT_RANGES: + if start <= unicode_code <= end: + return script_name + return None + + +def _get_cross_script_text_signal_from_samples(text_samples): + """统计中文文档文本层中大比例跨脚本混入信号,用于识别合法 Unicode 错码。""" + total_chars = 0 + cjk_chars = 0 + suspicious_chars = 0 + script_counts = {} + + for text_sample in text_samples: + for char in text_sample["cleaned_text"]: + total_chars += 1 + if _is_cjk_text_char(char): + cjk_chars += 1 + + script_name = _get_cross_script_name(char) + if script_name is None: + continue + + suspicious_chars += 1 + script_counts[script_name] = script_counts.get(script_name, 0) + 1 + + suspicious_ratio = 0.0 + if total_chars > 0: + suspicious_ratio = suspicious_chars / total_chars + + dense_script_count = sum( + 1 + for count in script_counts.values() + if count >= SUSPICIOUS_CROSS_SCRIPT_SCRIPT_MIN_CHARS + ) + top_scripts = sorted( + script_counts.items(), + key=lambda item: (-item[1], item[0]), + )[:5] + triggered = ( + total_chars >= SUSPICIOUS_CROSS_SCRIPT_MIN_TEXT_CHARS + and cjk_chars >= SUSPICIOUS_CROSS_SCRIPT_MIN_CJK_CHARS + and suspicious_chars >= SUSPICIOUS_CROSS_SCRIPT_COUNT_THRESHOLD + and suspicious_ratio >= SUSPICIOUS_CROSS_SCRIPT_RATIO_THRESHOLD + and dense_script_count >= SUSPICIOUS_CROSS_SCRIPT_MIN_SCRIPT_COUNT + ) + + return { + "triggered": triggered, + "total_chars": total_chars, + "cjk_chars": cjk_chars, + "suspicious_chars": suspicious_chars, + "suspicious_ratio": suspicious_ratio, + "script_counts": script_counts, + "top_scripts": top_scripts, + "dense_script_count": dense_script_count, + } + + def _get_u72xx_text_signal_from_samples(text_samples): """基于已缓存的抽样页文本统计扣除常用字后的 U+7280-U+72DF 字符占比。""" cjk_chars = 0 From 28ddd783130feebb4b8d184c17c891baba966907 Mon Sep 17 00:00:00 2001 From: myhloli Date: Thu, 11 Jun 2026 21:32:03 +0800 Subject: [PATCH 3/6] feat: enhance markdown text escaping to protect bare URLs and improve link handling --- .../office/mkcontent/inline_renderer.py | 5 +- mineru/backend/utils/markdown_utils.py | 83 ++++++++++++++++++- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/mineru/backend/office/mkcontent/inline_renderer.py b/mineru/backend/office/mkcontent/inline_renderer.py index f43685cc..b3be4e3a 100644 --- a/mineru/backend/office/mkcontent/inline_renderer.py +++ b/mineru/backend/office/mkcontent/inline_renderer.py @@ -138,7 +138,10 @@ def _escape_office_inline_text(content: str, inline_syntax: str) -> str: return content if inline_syntax == OFFICE_INLINE_SYNTAX_MARKDOWN: return escape_conservative_markdown_text(content) - return escape_conservative_markdown_text(escape(content, quote=False)) + return escape_conservative_markdown_text( + escape(content, quote=False), + protect_bare_urls=False, + ) def get_title_level(para_block): diff --git a/mineru/backend/utils/markdown_utils.py b/mineru/backend/utils/markdown_utils.py index dd8bc7d1..cc978af0 100644 --- a/mineru/backend/utils/markdown_utils.py +++ b/mineru/backend/utils/markdown_utils.py @@ -7,9 +7,21 @@ CONSERVATIVE_MARKDOWN_SPECIAL_CHARS = ("*", "_", "`", "~", "$") TEXT_BLOCK_MARKDOWN_PREFIX_RE = re.compile( r"^(?P[ \t]{0,3})(?P#{1,6}|[+-])(?=[ \t])" ) +BARE_HTTP_URL_RE = re.compile( + r"https?://[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+" +) +TRAILING_URL_PUNCTUATION = ".,;:!?" +TRAILING_URL_BRACKETS = { + ")": "(", + "]": "[", + "}": "{", +} -def escape_conservative_markdown_text(content: str) -> str: +def escape_conservative_markdown_text( + content: str, + protect_bare_urls: bool = True, +) -> str: """Escape plain-text characters that carry inline Markdown semantics.""" if not content: return content @@ -32,7 +44,74 @@ def escape_conservative_markdown_text(content: str) -> str: escaped_chars.append(char) preceding_backslashes = 0 - return "".join(escaped_chars) + escaped_content = "".join(escaped_chars) + if protect_bare_urls: + return protect_bare_urls_in_markdown_text(escaped_content) + return escaped_content + + +def protect_bare_urls_in_markdown_text(content: str) -> str: + """将普通文本中的裸 URL 包成显式 autolink,避免预览器误吞后续正文。""" + if not content: + return content + + return BARE_HTTP_URL_RE.sub( + lambda match: _render_protected_bare_url(content, match), + content, + ) + + +def _render_protected_bare_url(content: str, match: re.Match) -> str: + """渲染一个裸 URL 命中;已有 Markdown/HTML 链接上下文保持原样。""" + if _is_existing_link_url_context(content, match.start()): + return match.group(0) + + url, trailing = _split_trailing_url_punctuation(match.group(0)) + if not url: + return match.group(0) + + return f"<{url}>{trailing}" + + +def _is_existing_link_url_context(content: str, url_start: int) -> bool: + """判断 URL 是否已经处在 autolink、Markdown 链接或 HTML 属性中。""" + if url_start <= 0: + return False + + previous_char = content[url_start - 1] + prefix = content[max(0, url_start - 10):url_start].lower() + + if previous_char == "<": + return True + if previous_char == "(" and url_start >= 2 and content[url_start - 2] == "]": + return True + return prefix.endswith(('href="', "href='", 'src="', "src='", "href=", "src=")) + + +def _split_trailing_url_punctuation(url: str) -> tuple[str, str]: + """拆出 URL 末尾不属于地址本体的标点,尤其是正文括号和句读符号。""" + trailing = [] + while url: + last_char = url[-1] + if last_char in TRAILING_URL_PUNCTUATION: + trailing.append(last_char) + url = url[:-1] + continue + if _is_unbalanced_trailing_bracket(url, last_char): + trailing.append(last_char) + url = url[:-1] + continue + break + + return url, "".join(reversed(trailing)) + + +def _is_unbalanced_trailing_bracket(url: str, last_char: str) -> bool: + """判断 URL 末尾闭合括号是否更像正文标点,而不是 URL path 的一部分。""" + open_char = TRAILING_URL_BRACKETS.get(last_char) + if open_char is None: + return False + return url.count(last_char) > url.count(open_char) def escape_text_block_markdown_prefix(content: str) -> str: From c376910f35a6b683507c6f8ad161b376078a44ec Mon Sep 17 00:00:00 2001 From: myhloli Date: Thu, 11 Jun 2026 21:45:20 +0800 Subject: [PATCH 4/6] feat: update block type handling to introduce INDEX type for hybrid content processing --- mineru/backend/hybrid/hybrid_analyze.py | 2 +- mineru/backend/hybrid/hybrid_magic_model.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/mineru/backend/hybrid/hybrid_analyze.py b/mineru/backend/hybrid/hybrid_analyze.py index db595988..96434e7a 100644 --- a/mineru/backend/hybrid/hybrid_analyze.py +++ b/mineru/backend/hybrid/hybrid_analyze.py @@ -84,7 +84,7 @@ MEDIUM_EFFORT_LAYOUT_LABEL_TO_VLM_TYPE = { "abstract": BlockType.TEXT, "algorithm": BlockType.CODE, "aside_text": BlockType.ASIDE_TEXT, - "content": BlockType.TEXT, + "content": BlockType.INDEX, "doc_title": BlockType.TITLE, "footer": BlockType.FOOTER, "footer_image": BlockType.FOOTER, diff --git a/mineru/backend/hybrid/hybrid_magic_model.py b/mineru/backend/hybrid/hybrid_magic_model.py index 8e41850c..3b786db5 100644 --- a/mineru/backend/hybrid/hybrid_magic_model.py +++ b/mineru/backend/hybrid/hybrid_magic_model.py @@ -135,6 +135,7 @@ class MagicModel: "aside_text", "page_footnote", "list", + "index", ]: span_type = ContentType.TEXT elif block_type in ["image_caption", "table_caption", "code_caption"]: @@ -318,6 +319,10 @@ class MagicModel: block = fix_text_block(block) _copy_raw_text_block_metadata(raw_block_type, block_info, block) + if block["type"] == BlockType.INDEX: + # index 仅用于 Hybrid medium content 强制走 VLM-OCR,输出前统一还原为正文。 + block["type"] = BlockType.TEXT + blocks.append(block) fallback_inline_caption_fragments(blocks, VISUAL_MAIN_TYPES) From 62e3c3ea1a1c840ca0a15d91fccc6760c86ba75d Mon Sep 17 00:00:00 2001 From: myhloli Date: Thu, 11 Jun 2026 21:48:27 +0800 Subject: [PATCH 5/6] feat: update mineru-vl-utils dependency version to 1.0.5 for compatibility --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 79cc4dd7..ab0fe9e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ dependencies = [ "openai>=1.70.0,<3", "beautifulsoup4>=4.13.5,<5", "magika>=0.6.2,<1.1.0", - "mineru-vl-utils>=1.0.4,<2", + "mineru-vl-utils>=1.0.5,<2", "python-docx>=1.2.0,<2", 'pypptx-with-oxml>=1.0.3,<2', "mammoth>=1.11.0,<2", From cf72ca6fe454051c9da6ca92641f284e35949a28 Mon Sep 17 00:00:00 2001 From: myhloli Date: Thu, 11 Jun 2026 22:09:23 +0800 Subject: [PATCH 6/6] feat: simplify markdown text escaping by removing bare URL protection logic --- .../office/mkcontent/inline_renderer.py | 5 +- mineru/backend/utils/markdown_utils.py | 83 +------------------ 2 files changed, 3 insertions(+), 85 deletions(-) diff --git a/mineru/backend/office/mkcontent/inline_renderer.py b/mineru/backend/office/mkcontent/inline_renderer.py index b3be4e3a..f43685cc 100644 --- a/mineru/backend/office/mkcontent/inline_renderer.py +++ b/mineru/backend/office/mkcontent/inline_renderer.py @@ -138,10 +138,7 @@ def _escape_office_inline_text(content: str, inline_syntax: str) -> str: return content if inline_syntax == OFFICE_INLINE_SYNTAX_MARKDOWN: return escape_conservative_markdown_text(content) - return escape_conservative_markdown_text( - escape(content, quote=False), - protect_bare_urls=False, - ) + return escape_conservative_markdown_text(escape(content, quote=False)) def get_title_level(para_block): diff --git a/mineru/backend/utils/markdown_utils.py b/mineru/backend/utils/markdown_utils.py index cc978af0..dd8bc7d1 100644 --- a/mineru/backend/utils/markdown_utils.py +++ b/mineru/backend/utils/markdown_utils.py @@ -7,21 +7,9 @@ CONSERVATIVE_MARKDOWN_SPECIAL_CHARS = ("*", "_", "`", "~", "$") TEXT_BLOCK_MARKDOWN_PREFIX_RE = re.compile( r"^(?P[ \t]{0,3})(?P#{1,6}|[+-])(?=[ \t])" ) -BARE_HTTP_URL_RE = re.compile( - r"https?://[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+" -) -TRAILING_URL_PUNCTUATION = ".,;:!?" -TRAILING_URL_BRACKETS = { - ")": "(", - "]": "[", - "}": "{", -} -def escape_conservative_markdown_text( - content: str, - protect_bare_urls: bool = True, -) -> str: +def escape_conservative_markdown_text(content: str) -> str: """Escape plain-text characters that carry inline Markdown semantics.""" if not content: return content @@ -44,74 +32,7 @@ def escape_conservative_markdown_text( escaped_chars.append(char) preceding_backslashes = 0 - escaped_content = "".join(escaped_chars) - if protect_bare_urls: - return protect_bare_urls_in_markdown_text(escaped_content) - return escaped_content - - -def protect_bare_urls_in_markdown_text(content: str) -> str: - """将普通文本中的裸 URL 包成显式 autolink,避免预览器误吞后续正文。""" - if not content: - return content - - return BARE_HTTP_URL_RE.sub( - lambda match: _render_protected_bare_url(content, match), - content, - ) - - -def _render_protected_bare_url(content: str, match: re.Match) -> str: - """渲染一个裸 URL 命中;已有 Markdown/HTML 链接上下文保持原样。""" - if _is_existing_link_url_context(content, match.start()): - return match.group(0) - - url, trailing = _split_trailing_url_punctuation(match.group(0)) - if not url: - return match.group(0) - - return f"<{url}>{trailing}" - - -def _is_existing_link_url_context(content: str, url_start: int) -> bool: - """判断 URL 是否已经处在 autolink、Markdown 链接或 HTML 属性中。""" - if url_start <= 0: - return False - - previous_char = content[url_start - 1] - prefix = content[max(0, url_start - 10):url_start].lower() - - if previous_char == "<": - return True - if previous_char == "(" and url_start >= 2 and content[url_start - 2] == "]": - return True - return prefix.endswith(('href="', "href='", 'src="', "src='", "href=", "src=")) - - -def _split_trailing_url_punctuation(url: str) -> tuple[str, str]: - """拆出 URL 末尾不属于地址本体的标点,尤其是正文括号和句读符号。""" - trailing = [] - while url: - last_char = url[-1] - if last_char in TRAILING_URL_PUNCTUATION: - trailing.append(last_char) - url = url[:-1] - continue - if _is_unbalanced_trailing_bracket(url, last_char): - trailing.append(last_char) - url = url[:-1] - continue - break - - return url, "".join(reversed(trailing)) - - -def _is_unbalanced_trailing_bracket(url: str, last_char: str) -> bool: - """判断 URL 末尾闭合括号是否更像正文标点,而不是 URL path 的一部分。""" - open_char = TRAILING_URL_BRACKETS.get(last_char) - if open_char is None: - return False - return url.count(last_char) > url.count(open_char) + return "".join(escaped_chars) def escape_text_block_markdown_prefix(content: str) -> str: