""" OCR 结果布局后处理(纯逻辑,无 IO)。 对 RapidOCR 检测到的文本框做阅读顺序修复和段落分组, 将扁平 blocks 转换为结构化文本(Markdown 兼容)。 能力范围(Phase 2,简单排版): 1. 阅读顺序排序:Y→X 排序 2. 行归组:Y 坐标容差内归为同一行 3. 段落分组:行间距 > 阈值时分段 4. 基础格式推断:全数字行、短标题行标记 5. 多栏检测:X 范围不重叠的 blocks 按栏分组排序 6. 标题层级:基于 block 高度相对中位高度的倍数推断 依赖: - 无 IO,无第三方库(仅标准库) - 可独立测试,不依赖引擎实例 """ from __future__ import annotations import re import statistics from typing import Any from src.schemas.ocr import BBox, TextBlock def _detect_columns(blocks: list[TextBlock], page_width: float = 800) -> list[list[TextBlock]]: """多栏检测:将 blocks 按水平范围分成左右栏。 检测策略: 1. 计算所有 block 的 X 范围(左边界、右边界) 2. 如果存在明显的水平间隙(gap > page_width * 0.1),则按间隙分栏 3. 每栏内部按 Y 排序 4. 按左 → 右顺序返回 Args: blocks: 原始 OCR blocks。 page_width: 页面参考宽度。 Returns: 分栏后的 blocks 列表(左栏 → 右栏,每栏内部 Y 排序)。 如果未检测到多栏,返回 [blocks](单栏)。 """ if len(blocks) < 4: return [blocks] # 收集所有 block 的 X 范围 x_ranges: list[tuple[float, float, float]] = [] # (left, right, center) for b in blocks: pts = b.bbox.points if len(pts) >= 2: xs = [p[0] for p in pts] x_ranges.append((min(xs), max(xs), statistics.mean(xs))) else: x_ranges.append((0, page_width, page_width / 2)) # 计算中位宽度,用于检测间隙 widths = [r[1] - r[0] for r in x_ranges] median_width = statistics.median(widths) if widths else page_width # 对所有右边界排序,找间隙 right_edges = sorted(set(r[1] for r in x_ranges)) left_edges = sorted(set(r[0] for r in x_ranges)) # 找大间隙:如果某列右边界与下一列左边界的间隙 > median_width split_x: list[float] = [] for re in right_edges: candidates = [le for le in left_edges if le > re + median_width * 1.5] if candidates: gap = candidates[0] - re if gap > page_width * 0.08: # 间隙 > 页面 8% 宽度 split_x.append(re + gap / 2) if not split_x: return [blocks] # 按间隙分栏(取最大间隙位置) split_pos = max(split_x) col_left = [b for b, r in zip(blocks, x_ranges) if r[0] < split_pos] col_right = [b for b, r in zip(blocks, x_ranges) if r[0] >= split_pos] # 每栏内按 Y 排序 col_left.sort(key=_block_center_y) col_right.sort(key=_block_center_y) result = [] if col_left: result.append(col_left) if col_right: result.append(col_right) return result def sort_and_group(blocks: list[TextBlock], page_width: float = 800) -> list[list[TextBlock]]: """对 OCR blocks 做阅读顺序排序和行归组。 支持多栏检测:如果 blocks 可以按水平范围分成左右栏, 则先按栏分组,每栏内部 Y→X 排序,再左→右合并。 Args: blocks: 原始 OCR 结果块。 page_width: 页面参考宽度,用于多栏检测和居中判断。 Returns: 按阅读顺序排列的行列表,每行为该行内已按 X 排序的 blocks。 """ if not blocks: return [] # 多栏检测(有间隙时分栏,每栏独立 Y 排序) columns = _detect_columns(blocks, page_width) if len(columns) > 1: # 多栏:逐栏处理行归组,再左→右合并行 all_lines: list[list[TextBlock]] = [] for col in columns: col_lines = _sort_and_group_single(col) all_lines.extend(col_lines) return all_lines return _sort_and_group_single(blocks) def _sort_and_group_single(blocks: list[TextBlock]) -> list[list[TextBlock]]: """单栏场景的 Y→X 排序和行归组。""" if not blocks: return [] # 1. 计算 Y 容差(基于所有 bbox 的中位高度) heights = [_block_height(b) for b in blocks] if not heights: return [[b] for b in blocks] median_height = statistics.median(heights) if len(heights) > 1 else heights[0] y_tolerance = median_height * 0.5 # 半行高以内视为同一行 # 2. 按 Y 中心点排序(全局从上到下) sorted_blocks = sorted(blocks, key=_block_center_y) # 3. 行归组 lines: list[list[TextBlock]] = [] current_line: list[TextBlock] = [sorted_blocks[0]] current_y = _block_center_y(sorted_blocks[0]) for block in sorted_blocks[1:]: y_center = _block_center_y(block) if abs(y_center - current_y) <= y_tolerance: current_line.append(block) else: lines.append(current_line) current_line = [block] current_y = y_center if current_line: lines.append(current_line) # 4. 每行内按 X 从左到右排序 for line in lines: line.sort(key=_block_center_x) return lines def group_paragraphs(lines: list[list[TextBlock]], page_width: float = 800) -> list[dict[str, Any]]: """将行归组为段落。 基于行间距判断段落边界。行间距明显大于中位行距时视为新段落。 Args: lines: 已按阅读顺序排列的行列表。 page_width: 页面参考宽度(px),用于居中检测,默认 800。 Returns: 段落列表,每段包含文本和类型。 """ if not lines: return [] # 计算中位行间距 if len(lines) > 1: spacings: list[float] = [] for i in range(len(lines) - 1): bottom = max(_block_bottom(b) for b in lines[i]) top = min(_block_top(b) for b in lines[i + 1]) spacings.append(top - bottom) median_spacing = statistics.median(spacings) if spacings else 8.0 paragraph_threshold = median_spacing * 1.8 # 1.8 倍以上视为段落分隔 else: paragraph_threshold = 20.0 # 默认值 paragraphs: list[dict[str, Any]] = [] current_para: list[str] = [] current_type = "text" current_level: int = 3 # 当前标题级别(用于连续标题的场景) # 所有行 block 的高度(用于计算中位高度比例,辅助标题检测) all_heights = [max(_block_height(b) for b in line) for line in lines if line] median_line_height = statistics.median(all_heights) if len(all_heights) > 1 else (all_heights[0] if all_heights else 16.0) for i, line_blocks in enumerate(lines): line_text = "".join(b.text for b in line_blocks) # 计算行信息(居中 + 相对高度) avg_center_x = statistics.mean( _block_center_x(b) for b in line_blocks ) if line_blocks else 0 center_x_ratio = avg_center_x / page_width if page_width > 0 and avg_center_x > 0 else None line_height = max(_block_height(b) for b in line_blocks) if line_blocks else 0 rel_height = line_height / median_line_height if median_line_height > 0 and line_height > 0 else None is_centered = center_x_ratio is not None and 0.25 <= center_x_ratio <= 0.75 line_type = _detect_line_type( line_text, center_x_ratio=center_x_ratio, rel_height=rel_height, ) # 标题级别推断 level = _detect_heading_level( rel_height=rel_height, is_centered=is_centered, text=line_text, ) if line_type == "heading" else 3 # 判断是否分段 is_new_paragraph = False # 空行强制分段 if not line_text.strip(): is_new_paragraph = True else: # 行间距大于阈值(仅正间距,防止重叠/同 Y 误分段) if i > 0: prev_bottom = max(_block_bottom(b) for b in lines[i - 1]) curr_top = min(_block_top(b) for b in line_blocks) gap = curr_top - prev_bottom if gap > 0 and gap > paragraph_threshold: is_new_paragraph = True # 行类型变化(如文字→标题,独立于间距判断) if current_para and line_type != current_type: is_new_paragraph = True if is_new_paragraph and current_para: paragraphs.append({ "type": current_type, "text": "\n".join(current_para), "level": current_level, }) current_para = [] current_type = "text" current_level = 3 if line_text.strip(): current_para.append(line_text) current_type = line_type current_level = level if current_para: paragraphs.append({ "type": current_type, "text": "\n".join(current_para), "level": current_level, }) return paragraphs def build_markdown_text( blocks: list[TextBlock], page_width: float = 800, ) -> tuple[str, list[list[TextBlock]], list[dict[str, Any]]]: """将 OCR blocks 转换为 markdown 格式文本。 支持段落类型: - heading:标题(## 前缀) - list_item:无序列表(- 前缀) - numbered:有序列表 - code:代码块(``` 包裹) - separator:分隔线(---) - text:普通段落 Args: blocks: 原始 OCR 识别结果块。 page_width: 页面参考宽度(px),用于居中检测。 Returns: (markdown 文本, 行列表, 段落列表) """ lines = sort_and_group(blocks) paragraphs = group_paragraphs(lines, page_width=page_width) # 构建 markdown md_parts: list[str] = [] code_block_open = False for para in paragraphs: para_type = para["type"] para_text = para["text"] if para_type == "heading": level = para.get("level", 2) level = max(1, min(level, 3)) # 只输出 1~3 级 prefix = "#" * level md_parts.append(f"\n{prefix} {para_text.strip()}\n") elif para_type == "list_item": # 每行加 - 前缀 items = para_text.strip().split("\n") md_parts.append("\n".join(f"- {line.strip()}" for line in items)) elif para_type == "numbered": md_parts.append(f"\n{para_text.strip()}\n") elif para_type == "code": md_parts.append(f"\n```\n{para_text.strip()}\n```\n") elif para_type == "separator": md_parts.append("\n---\n") else: md_parts.append(para_text) markdown = "\n\n".join(p for p in md_parts if p.strip()) return markdown, lines, paragraphs # ── 辅助函数 ────────────────────────────────────────────── def _block_height(block: TextBlock) -> float: """计算文本框高度。""" try: pts = block.bbox.points return abs(pts[2][1] - pts[0][1]) if len(pts) >= 4 else 0.0 except (IndexError, TypeError, AttributeError): return 0.0 def _block_center_y(block: TextBlock) -> float: """文本框 Y 中心点。""" try: pts = block.bbox.points ys = [p[1] for p in pts] return statistics.mean(ys) except (IndexError, TypeError, AttributeError, statistics.StatisticsError): return 0.0 def _block_center_x(block: TextBlock) -> float: """文本框 X 中心点。""" try: pts = block.bbox.points xs = [p[0] for p in pts] return statistics.mean(xs) except (IndexError, TypeError, AttributeError, statistics.StatisticsError): return 0.0 def _block_bottom(block: TextBlock) -> float: """文本框底部 Y 坐标。""" try: pts = block.bbox.points return max(p[1] for p in pts) except (IndexError, TypeError, AttributeError): return 0.0 def _block_top(block: TextBlock) -> float: """文本框顶部 Y 坐标。""" try: pts = block.bbox.points return min(p[1] for p in pts) except (IndexError, TypeError, AttributeError): return 0.0 def _detect_line_type( text: str, center_x_ratio: float | None = None, rel_height: float | None = None, ) -> str: """检测行类型。 Args: text: 行文本。 center_x_ratio: 文本框中心 X 相对页面宽度的比例(0~1), 用于检测居中对齐(标题候选)。 rel_height: 文本框高度相对中位高度的比例, 用于检测标题级别(更大的字 → 更高级别标题)。 Returns: 类型:heading / list_item / numbered / code / separator / text """ stripped = text.strip() if not stripped: return "text" # 分隔线(纯符号) if re.match(r'^[-=*_]{3,}$', stripped): return "separator" # 代码行(检查原文以保留前导空格,stripped 会去除它们) if text.startswith((" ", "\t")): return "code" # 列表项(以 - * • ▪ ▸ → · 开头) if stripped.startswith(("- ", "* ", "• ", "▪ ", "▸ ", "→ ", "· ")): return "list_item" # 独立 bullet 字符(单个符号作为独立行) if stripped in ("•", "▪", "▸", "→", "·", "-", "*"): return "list_item" # 编号项(支持 1.内容 / 1)内容 / (1) 等,点后可不带空格) if re.match(r'^\d+[\.\)]\s*', stripped): return "numbered" if re.match(r'^\(\d+\)\s*', stripped): return "numbered" if re.match(r'^[①②③④⑤⑥⑦⑧⑨⑩]', stripped): return "numbered" # TOC 目录行:"第一章 .............. 1" — 含连续点号的行 # 特征:短行(10-50 char) + 含连续 3+ 点号 + 非标题 if 10 <= len(stripped) < 60 and "..." in stripped and "。" not in stripped: return "numbered" # 标题候选:短行 + 居中或短文本特征 # 标题通常满足:较短 + 不以句号/逗号/分号/冒号结尾 is_short = len(stripped) < 40 no_punct_end = not stripped.endswith(("。", ",", ";", ":", "、", "!", "?", "…")) # 居中检测:如果 center_x_ratio 在 0.25~0.75 之间,视为居中 is_centered = center_x_ratio is not None and 0.25 <= center_x_ratio <= 0.75 # 字体较大:rel_height > 1.3 说明字号明显大于中位文本 is_larger_font = rel_height is not None and rel_height > 1.3 if is_short and no_punct_end: # 全大写英文短行 → 标题 if stripped.isupper() and stripped.replace(" ", "").isalpha(): return "heading" # 居中短行 → 标题 if is_centered: return "heading" # 大字短行 → 标题 if is_larger_font: return "heading" # 极短的中文行(≤ 8 字符)→ 标题(如"第一章"、"摘要") cn_chars = sum(1 for c in stripped if '一' <= c <= '鿿') cn_ratio = cn_chars / len(stripped) if len(stripped) > 0 else 0 if cn_ratio > 0.6 and len(stripped) <= 8: return "heading" # 纯数字短行(页码、编号) if stripped.isdigit(): return "numbered" return "text" def _detect_heading_level( rel_height: float | None = None, is_centered: bool = False, text: str = "", ) -> int: """推断标题级别。 级别规则: - h1(1):字体很大(rel_height ≥ 1.8) 或 居中 + 大字 - h2(2):字体中等(rel_height ≥ 1.3) 或 居中 - h3(3):其他标题 Args: rel_height: 相对中位字体高度的比例。 is_centered: 是否居中。 text: 标题文本(用于额外判断)。 Returns: 1 / 2 / 3——对应 markdown 的 # / ## / ###。 """ # 居中的大字 → h1 if is_centered and rel_height is not None and rel_height >= 1.5: return 1 # 大字 → h2 if rel_height is not None and rel_height >= 1.8: return 1 if rel_height is not None and rel_height >= 1.3: return 2 # 居中 → h2 if is_centered: return 2 return 3