#!/usr/bin/env python3 from __future__ import annotations import os import re import shutil import subprocess import sys from dataclasses import dataclass from pathlib import Path from typing import Any, Optional from _pdf_common import ( SkillArgumentParser, input_pdf, run_cli, selected_page_window, ) DEFAULT_MAX_PAGES = 8 DEFAULT_MAX_CHARS = 24000 DEFAULT_TIMEOUT_SECONDS = 120 MIN_MEANINGFUL_PAGE_CHARS = 20 IMAGE_PAGE_MIN_MEANINGFUL_CHARS = 100 IMAGE_PAGE_MIN_COVERAGE = 0.5 IMAGE_COVERAGE_GRID_SIZE = 32 CID_PATTERN = re.compile(r"\(cid:\d+\)", re.IGNORECASE) PAGE_NUMBER_PATTERN = re.compile( r"(?im)^\s*\d+\s*(?:of|/)\s*\d+\s*$" ) @dataclass class ExtractionCandidate: engine: str texts: list[str] quality: dict[str, Any] warnings: list[str] page_engines: list[str] def _parse_args(argv: list[str]): parser = SkillArgumentParser(description="按页分段提取 PDF 文本") parser.add_argument("--input", required=True, help="本地 PDF 路径") parser.add_argument("--start-page", type=int, default=1, help="起始页,1-based") parser.add_argument("--end-page", type=int, help="结束页,1-based,默认到末页") parser.add_argument( "--start-offset", type=int, default=0, help="起始页文本字符偏移量;大于 0 时须传入上次返回的 next_engine", ) parser.add_argument( "--max-pages", type=int, default=DEFAULT_MAX_PAGES, help=f"单次最多处理页数,默认 {DEFAULT_MAX_PAGES}", ) parser.add_argument( "--max-chars", type=int, default=DEFAULT_MAX_CHARS, help=f"单次最多返回文本字符数,默认 {DEFAULT_MAX_CHARS}", ) parser.add_argument( "--layout", action="store_true", help="尽量保留版面空格;普通总结通常不要启用", ) parser.add_argument( "--engine", choices=("auto", "poppler", "pdfplumber"), default="auto", help="提取引擎,默认 auto:优先 Poppler,失败或质量差时回退 pdfplumber", ) parser.add_argument( "--timeout", type=int, default=DEFAULT_TIMEOUT_SECONDS, help=f"Poppler 提取超时秒数,默认 {DEFAULT_TIMEOUT_SECONDS}", ) args = parser.parse_args(argv) if args.start_offset < 0: raise ValueError("start-offset 不能小于 0") if args.start_offset > 0 and args.engine == "auto": raise ValueError( "使用 start-offset 续读同一页时,必须把上次返回的 " "next_engine 传给 --engine" ) if args.max_chars < 1: raise ValueError("max-chars 必须大于 0") if args.timeout < 1: raise ValueError("timeout 必须大于 0") return args def _matrix(values) -> tuple[float, float, float, float, float, float]: try: converted = tuple(float(value) for value in values[:6]) if len(converted) != 6: raise ValueError return ( converted[0], converted[1], converted[2], converted[3], converted[4], converted[5], ) except (TypeError, ValueError): return (1.0, 0.0, 0.0, 1.0, 0.0, 0.0) def _concat_matrix( current: tuple[float, float, float, float, float, float], update: tuple[float, float, float, float, float, float], ) -> tuple[float, float, float, float, float, float]: ca, cb, cc, cd, ce, cf = current ua, ub, uc, ud, ue, uf = update return ( ca * ua + cc * ub, cb * ua + cd * ub, ca * uc + cc * ud, cb * uc + cd * ud, ca * ue + cc * uf + ce, cb * ue + cd * uf + cf, ) def _transform_point( matrix: tuple[float, float, float, float, float, float], point: tuple[float, float], ) -> tuple[float, float]: a, b, c, d, e, f = matrix x, y = point return (a * x + c * y + e, b * x + d * y + f) def _rectangle_polygon( x1: float, y1: float, x2: float, y2: float, matrix: tuple[float, float, float, float, float, float], ) -> list[tuple[float, float]]: return [ _transform_point(matrix, point) for point in ((x1, y1), (x2, y1), (x2, y2), (x1, y2)) ] def _point_in_polygon( point: tuple[float, float], polygon: list[tuple[float, float]], ) -> bool: x, y = point inside = False previous = polygon[-1] for current in polygon: x1, y1 = previous x2, y2 = current if (y1 > y) != (y2 > y): crossing_x = (x2 - x1) * (y - y1) / (y2 - y1) + x1 if x < crossing_x: inside = not inside previous = current return inside def _grid_cell_center( page_rect: tuple[float, float, float, float], grid_x: int, grid_y: int, ) -> tuple[float, float]: left, bottom, right, top = page_rect grid = IMAGE_COVERAGE_GRID_SIZE return ( left + (grid_x + 0.5) * (right - left) / grid, bottom + (grid_y + 0.5) * (top - bottom) / grid, ) def _polygon_cells( polygon: list[tuple[float, float]], page_rect: tuple[float, float, float, float], ) -> set[tuple[int, int]]: cells: set[tuple[int, int]] = set() if len(polygon) < 3: return cells left, bottom, right, top = page_rect width = right - left height = top - bottom if width <= 0 or height <= 0: return cells min_x = max(left, min(point[0] for point in polygon)) max_x = min(right, max(point[0] for point in polygon)) min_y = max(bottom, min(point[1] for point in polygon)) max_y = min(top, max(point[1] for point in polygon)) if min_x >= max_x or min_y >= max_y: return cells grid = IMAGE_COVERAGE_GRID_SIZE start_x = max(0, int((min_x - left) / width * grid)) end_x = min(grid - 1, int((max_x - left) / width * grid)) start_y = max(0, int((min_y - bottom) / height * grid)) end_y = min(grid - 1, int((max_y - bottom) / height * grid)) for grid_x in range(start_x, end_x + 1): for grid_y in range(start_y, end_y + 1): center = _grid_cell_center(page_rect, grid_x, grid_y) if _point_in_polygon(center, polygon): cells.add((grid_x, grid_y)) return cells def _winding_number( point: tuple[float, float], polygon: list[tuple[float, float]], ) -> int: x, y = point winding = 0 previous = polygon[-1] for current in polygon: x1, y1 = previous x2, y2 = current cross = (x2 - x1) * (y - y1) - (x - x1) * (y2 - y1) if y1 <= y < y2 and cross > 0: winding += 1 elif y2 <= y < y1 and cross < 0: winding -= 1 previous = current return winding def _clip_path_cells( polygons: list[list[tuple[float, float]]], page_rect: tuple[float, float, float, float], even_odd: bool, ) -> set[tuple[int, int]]: result: set[tuple[int, int]] = set() grid = IMAGE_COVERAGE_GRID_SIZE for grid_x in range(grid): for grid_y in range(grid): center = _grid_cell_center(page_rect, grid_x, grid_y) if even_odd: inside = sum( 1 for polygon in polygons if _point_in_polygon(center, polygon) ) % 2 == 1 else: inside = sum( _winding_number(center, polygon) for polygon in polygons ) != 0 if inside: result.add((grid_x, grid_y)) return result def _cubic_curve_points( start: tuple[float, float], control_1: tuple[float, float], control_2: tuple[float, float], end: tuple[float, float], steps: int = 8, ) -> list[tuple[float, float]]: points: list[tuple[float, float]] = [] for step in range(1, steps + 1): t = step / steps inverse = 1 - t points.append( ( inverse**3 * start[0] + 3 * inverse**2 * t * control_1[0] + 3 * inverse * t**2 * control_2[0] + t**3 * end[0], inverse**3 * start[1] + 3 * inverse**2 * t * control_1[1] + 3 * inverse * t**2 * control_2[1] + t**3 * end[1], ) ) return points def _resolve(value): try: return value.get_object() except Exception: return value def _form_marker(xobject) -> tuple[Any, ...]: reference = getattr(xobject, "indirect_reference", None) if reference is not None: try: return ("indirect", int(reference.idnum), int(reference.generation)) except Exception: pass return ("object", id(xobject)) def _walk_content_images( content, resources, reader, page_rect: tuple[float, float, float, float], covered_cells: set[tuple[int, int]], initial_matrix: tuple[ float, float, float, float, float, float, ] = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0), initial_clip_cells: Optional[set[tuple[int, int]]] = None, active_forms: Optional[set[tuple]] = None, ) -> bool: from pypdf.generic import ContentStream if content is None or resources is None: return False try: operations = ContentStream(content, reader).operations except Exception: return False resolved_resources = _resolve(resources) try: xobjects = _resolve(resolved_resources.get("/XObject") or {}) except Exception: xobjects = {} grid = IMAGE_COVERAGE_GRID_SIZE current_matrix = initial_matrix current_clip_cells = ( { (grid_x, grid_y) for grid_x in range(grid) for grid_y in range(grid) } if initial_clip_cells is None else initial_clip_cells ) stack: list[ tuple[ tuple[float, float, float, float, float, float], set[tuple[int, int]], ] ] = [] has_image = False forms = active_forms if active_forms is not None else set() path_polygons: list[list[tuple[float, float]]] = [] current_subpath: Optional[list[tuple[float, float]]] = None pending_clip_rule: Optional[bytes] = None path_end_operators = { b"n", b"S", b"s", b"f", b"F", b"f*", b"B", b"B*", b"b", b"b*", } for operands, operator in operations: if operator == b"q": stack.append((current_matrix, current_clip_cells)) elif operator == b"Q": if stack: current_matrix, current_clip_cells = stack.pop() elif operator == b"cm": current_matrix = _concat_matrix( current_matrix, _matrix(operands), ) elif operator == b"re": try: x, y, width, height = ( float(value) for value in operands[:4] ) rectangle = _rectangle_polygon( x, y, x + width, y + height, current_matrix, ) except (TypeError, ValueError): rectangle = None if rectangle is not None: path_polygons.append(rectangle) elif operator == b"m": if current_subpath and len(current_subpath) >= 3: path_polygons.append(current_subpath) try: current_subpath = [ _transform_point( current_matrix, (float(operands[0]), float(operands[1])), ) ] except (IndexError, TypeError, ValueError): current_subpath = None elif operator == b"l" and current_subpath is not None: try: current_subpath.append( _transform_point( current_matrix, (float(operands[0]), float(operands[1])), ) ) except (IndexError, TypeError, ValueError): pass elif operator == b"c" and current_subpath: try: control_1 = _transform_point( current_matrix, (float(operands[0]), float(operands[1])), ) control_2 = _transform_point( current_matrix, (float(operands[2]), float(operands[3])), ) end = _transform_point( current_matrix, (float(operands[4]), float(operands[5])), ) current_subpath.extend( _cubic_curve_points( current_subpath[-1], control_1, control_2, end, ) ) except (IndexError, TypeError, ValueError): pass elif operator == b"v" and current_subpath: try: control_2 = _transform_point( current_matrix, (float(operands[0]), float(operands[1])), ) end = _transform_point( current_matrix, (float(operands[2]), float(operands[3])), ) current_subpath.extend( _cubic_curve_points( current_subpath[-1], current_subpath[-1], control_2, end, ) ) except (IndexError, TypeError, ValueError): pass elif operator == b"y" and current_subpath: try: control_1 = _transform_point( current_matrix, (float(operands[0]), float(operands[1])), ) end = _transform_point( current_matrix, (float(operands[2]), float(operands[3])), ) current_subpath.extend( _cubic_curve_points( current_subpath[-1], control_1, end, end, ) ) except (IndexError, TypeError, ValueError): pass elif operator == b"h": if current_subpath and len(current_subpath) >= 3: path_polygons.append(current_subpath) current_subpath = None elif operator in {b"W", b"W*"}: pending_clip_rule = operator elif operator == b"INLINE IMAGE": image_polygon = _rectangle_polygon( 0, 0, 1, 1, current_matrix, ) has_image = True covered_cells.update( _polygon_cells( image_polygon, page_rect, ).intersection(current_clip_cells) ) elif operator == b"Do" and operands: try: xobject = _resolve(xobjects.get(operands[0])) subtype = str(xobject.get("/Subtype")) except Exception: continue if subtype == "/Image": image_polygon = _rectangle_polygon( 0, 0, 1, 1, current_matrix, ) has_image = True covered_cells.update( _polygon_cells( image_polygon, page_rect, ).intersection(current_clip_cells) ) elif subtype == "/Form": marker = _form_marker(xobject) if marker in forms: continue form_matrix = _concat_matrix( current_matrix, _matrix( xobject.get("/Matrix") or (1, 0, 0, 1, 0, 0) ), ) form_clip_cells = current_clip_cells try: x1, y1, x2, y2 = ( float(value) for value in xobject.get("/BBox") ) form_bbox = _rectangle_polygon( x1, y1, x2, y2, form_matrix, ) form_clip_cells = current_clip_cells.intersection( _polygon_cells(form_bbox, page_rect) ) except (TypeError, ValueError): pass forms.add(marker) try: nested_has_image = _walk_content_images( xobject, xobject.get("/Resources") or resolved_resources, reader, page_rect, covered_cells, initial_matrix=form_matrix, initial_clip_cells=form_clip_cells, active_forms=forms, ) finally: forms.remove(marker) has_image = has_image or nested_has_image if operator in path_end_operators: if current_subpath and len(current_subpath) >= 3: path_polygons.append(current_subpath) if pending_clip_rule is not None and path_polygons: path_cells = _clip_path_cells( path_polygons, page_rect, even_odd=pending_clip_rule == b"W*", ) current_clip_cells = current_clip_cells.intersection( path_cells ) path_polygons = [] current_subpath = None pending_clip_rule = None return has_image def _content_image_coverage( content, resources, reader, page_rect: tuple[float, float, float, float], ) -> tuple[bool, float]: covered_cells: set[tuple[int, int]] = set() has_image = _walk_content_images( content, resources, reader, page_rect, covered_cells, ) grid_cells = IMAGE_COVERAGE_GRID_SIZE**2 return has_image, len(covered_cells) / grid_cells def _page_count(path: Path) -> int: from pypdf import PdfReader with path.open("rb") as stream: reader = PdfReader(stream, strict=False) if reader.is_encrypted: raise ValueError("PDF 已加密,无法提取正文") return len(reader.pages) def _image_coverage_by_page( path: Path, start_page: int, end_page: int, ) -> dict[int, float]: from pypdf import PdfReader coverage: dict[int, float] = {} with path.open("rb") as stream: reader = PdfReader(stream, strict=False) for page_number in range(start_page, end_page + 1): page = reader.pages[page_number - 1] left = float(page.cropbox.left) bottom = float(page.cropbox.bottom) right = float(page.cropbox.right) top = float(page.cropbox.top) has_image, page_coverage = _content_image_coverage( page.get_contents(), page.get("/Resources"), reader, ( min(left, right), min(bottom, top), max(left, right), max(bottom, top), ), ) if has_image: coverage[page_number] = page_coverage return coverage def _is_executable(path: Path) -> bool: return path.is_file() and os.access(path, os.X_OK) def _find_pdftotext() -> Path: executable_names = ( ("pdftotext.exe", "pdftotext") if os.name == "nt" else ("pdftotext", "pdftotext.exe") ) for executable_name in executable_names: direct = shutil.which(executable_name) if direct: return Path(direct).resolve() probes: list[Path] = [Path(sys.executable).resolve()] pdftoppm = shutil.which("pdftoppm") if pdftoppm: probes.append(Path(pdftoppm).resolve()) checked: set[Path] = set() for probe in probes: for executable_name in executable_names: sibling = probe.with_name(executable_name) if sibling not in checked: checked.add(sibling) if _is_executable(sibling): return sibling for ancestor in probe.parents: for executable_name in executable_names: for relative in ( Path("native/poppler/bin") / executable_name, Path("native/poppler/poppler/bin") / executable_name, ): candidate = ancestor / relative if candidate in checked: continue checked.add(candidate) if _is_executable(candidate): return candidate raise RuntimeError("环境预置的 pdftotext 不可用") def _stderr_warnings(value: str) -> list[str]: warnings: list[str] = [] for raw_line in value.splitlines(): line = raw_line.strip() if line and line not in warnings: warnings.append(line[:500]) if len(warnings) >= 5: break return warnings def _split_poppler_pages(output: str, expected_pages: int) -> list[str]: normalized = output.replace("\r\n", "\n").replace("\r", "\n") pages = normalized.split("\f") while len(pages) > expected_pages and not pages[-1].strip(): pages.pop() if len(pages) != expected_pages: raise RuntimeError( f"pdftotext 返回页数不正确:预期 {expected_pages},实际 {len(pages)}" ) return [page.rstrip("\n").replace("\x00", "") for page in pages] def _extract_with_poppler( path: Path, start_page: int, end_page: int, layout: bool, timeout: int, ) -> tuple[list[str], list[str]]: executable = _find_pdftotext() command = [ str(executable), "-enc", "UTF-8", "-f", str(start_page), "-l", str(end_page), ] if layout: command.append("-layout") command.extend((str(path), "-")) try: completed = subprocess.run( command, text=True, encoding="utf-8", errors="replace", capture_output=True, timeout=timeout, check=False, ) except subprocess.TimeoutExpired as exc: raise RuntimeError(f"pdftotext 提取超过 {timeout} 秒") from exc if completed.returncode != 0: detail = (completed.stderr or completed.stdout or "").strip()[-2000:] raise RuntimeError(f"pdftotext 提取失败:{detail or '返回非零状态'}") expected_pages = end_page - start_page + 1 return ( _split_poppler_pages(completed.stdout, expected_pages), _stderr_warnings(completed.stderr), ) def _extract_with_pdfplumber( path: Path, canonical_page_count: int, start_page: int, end_page: int, layout: bool, ) -> tuple[list[str], list[str]]: import pdfplumber texts: list[str] = [] with pdfplumber.open(path) as pdf: observed_page_count = len(pdf.pages) if observed_page_count != canonical_page_count: raise RuntimeError( "pdfplumber 页数与 PDF 检查结果不一致:" f"{observed_page_count} != {canonical_page_count}" ) for page_number in range(start_page, end_page + 1): text = pdf.pages[page_number - 1].extract_text(layout=layout) or "" texts.append(text.replace("\x00", "")) return texts, [] def _is_radical(character: str) -> bool: codepoint = ord(character) return 0x2E80 <= codepoint <= 0x2FDF def _page_quality( text: str, page_number: int, image_coverage: float, ) -> dict[str, Any]: has_image = image_coverage > 0 visible_chars = sum(1 for character in text if not character.isspace()) meaningful_text = PAGE_NUMBER_PATTERN.sub("", text) meaningful_chars = sum( 1 for character in meaningful_text if character.isalnum() ) cid_tokens = len(CID_PATTERN.findall(text)) replacement_chars = text.count("\ufffd") radical_chars = sum(1 for character in text if _is_radical(character)) control_chars = sum( 1 for character in text if ord(character) < 32 and character not in "\n\r\t\f" ) reasons: list[str] = [] if visible_chars == 0: status = "empty" reasons.append("未提取到可见文字") if has_image: reasons.append("页面包含图像对象") else: cid_ratio = cid_tokens / visible_chars replacement_ratio = replacement_chars / visible_chars radical_ratio = radical_chars / visible_chars control_ratio = control_chars / visible_chars if cid_tokens >= 3 or cid_ratio >= 0.01: reasons.append("包含大量 (cid:...) 字体映射占位符") if replacement_chars >= 3 or replacement_ratio >= 0.01: reasons.append("包含大量 Unicode 替换字符") if radical_chars >= 3 and radical_ratio >= 0.005: reasons.append("包含疑似替代汉字的部首字符") if control_chars >= 3 or control_ratio >= 0.01: reasons.append("包含异常控制字符") if reasons: status = "poor" elif ( image_coverage >= IMAGE_PAGE_MIN_COVERAGE and meaningful_chars < IMAGE_PAGE_MIN_MEANINGFUL_CHARS ): status = "sparse" reasons.append( f"页面图像覆盖约 {round(image_coverage * 100)}%," f"且可提取文字少于 {IMAGE_PAGE_MIN_MEANINGFUL_CHARS} 个字符" ) elif meaningful_chars < MIN_MEANINGFUL_PAGE_CHARS: status = "sparse" reasons.append( f"有效文字少于 {MIN_MEANINGFUL_PAGE_CHARS} 个字符" ) else: status = "good" penalty = min( 100, cid_tokens * 8 + replacement_chars * 8 + radical_chars * 2 + control_chars * 4, ) if status == "empty": score = 0 elif status == "sparse": score = min(50, meaningful_chars * 2) else: score = max(0, 100 - penalty) return { "page": page_number, "has_image": has_image, "image_coverage": round(image_coverage, 4), "status": status, "score": score, "visible_chars": visible_chars, "meaningful_chars": meaningful_chars, "cid_tokens": cid_tokens, "replacement_chars": replacement_chars, "radical_chars": radical_chars, "control_chars": control_chars, "reasons": reasons, } def _text_quality( texts: list[str], start_page: int, image_coverage: dict[int, float], ) -> dict[str, Any]: pages = [ _page_quality( text, start_page + index, image_coverage.get(start_page + index, 0.0), ) for index, text in enumerate(texts) ] statuses = [page["status"] for page in pages] good_pages = [page["page"] for page in pages if page["status"] == "good"] suspect_pages = [ page["page"] for page in pages if page["status"] != "good" ] if statuses and all(status == "good" for status in statuses): status = "good" elif good_pages: status = "mixed" elif statuses and all(item in {"empty", "sparse"} for item in statuses): status = "empty" else: status = "poor" reasons: list[str] = [] for page in pages: for reason in page["reasons"]: message = f"第 {page['page']} 页:{reason}" if message not in reasons: reasons.append(message) score = ( round(sum(page["score"] for page in pages) / len(pages)) if pages else 0 ) return { "status": status, "score": score, "visible_chars": sum(page["visible_chars"] for page in pages), "meaningful_chars": sum(page["meaningful_chars"] for page in pages), "cid_tokens": sum(page["cid_tokens"] for page in pages), "replacement_chars": sum(page["replacement_chars"] for page in pages), "radical_chars": sum(page["radical_chars"] for page in pages), "control_chars": sum(page["control_chars"] for page in pages), "image_pages": sorted( page["page"] for page in pages if page["has_image"] ), "good_pages": good_pages, "suspect_pages": suspect_pages, "reasons": reasons, "pages": pages, } def _attempt_engine( engine: str, path: Path, page_count: int, image_coverage: dict[int, float], start_page: int, end_page: int, layout: bool, timeout: int, ) -> ExtractionCandidate: if engine == "poppler": texts, warnings = _extract_with_poppler( path, start_page, end_page, layout, timeout, ) elif engine == "pdfplumber": texts, warnings = _extract_with_pdfplumber( path, page_count, start_page, end_page, layout, ) else: raise ValueError(f"未知提取引擎:{engine}") return ExtractionCandidate( engine=engine, texts=texts, quality=_text_quality(texts, start_page, image_coverage), warnings=warnings, page_engines=[engine] * len(texts), ) def _merge_candidates( candidates: list[ExtractionCandidate], start_page: int, image_coverage: dict[int, float], ) -> ExtractionCandidate: page_rank = {"empty": 0, "poor": 1, "sparse": 2, "good": 3} merged_texts: list[str] = [] page_engines: list[str] = [] for page_index in range(len(candidates[0].texts)): chosen = max( candidates, key=lambda candidate: ( page_rank[ candidate.quality["pages"][page_index]["status"] ], candidate.quality["pages"][page_index]["score"], candidate.quality["pages"][page_index]["visible_chars"], ), ) merged_texts.append(chosen.texts[page_index]) page_engines.append(chosen.page_engines[page_index]) warnings: list[str] = [] for candidate in candidates: for warning in candidate.warnings: if warning not in warnings: warnings.append(warning) unique_engines = list(dict.fromkeys(page_engines)) return ExtractionCandidate( engine=( unique_engines[0] if len(unique_engines) == 1 else "hybrid" ), texts=merged_texts, quality=_text_quality( merged_texts, start_page, image_coverage, ), warnings=warnings, page_engines=page_engines, ) def _select_candidate( args, path: Path, page_count: int, image_coverage: dict[int, float], start_page: int, end_page: int, ) -> tuple[ExtractionCandidate, list[dict[str, Any]]]: engines = ( ("poppler", "pdfplumber") if args.engine == "auto" else (args.engine,) ) candidates: list[ExtractionCandidate] = [] attempts: list[dict[str, Any]] = [] for engine in engines: try: candidate = _attempt_engine( engine, path, page_count, image_coverage, start_page, end_page, args.layout, args.timeout, ) except Exception as exc: attempts.append( { "engine": engine, "ok": False, "error": str(exc), } ) continue candidates.append(candidate) attempts.append( { "engine": engine, "ok": True, "quality": candidate.quality["status"], "score": candidate.quality["score"], } ) if candidate.quality["status"] == "good": return candidate, attempts if not candidates: details = ";".join( f"{attempt['engine']}: {attempt.get('error', '失败')}" for attempt in attempts ) raise RuntimeError(f"所有文字提取引擎均失败:{details}") return ( _merge_candidates(candidates, start_page, image_coverage), attempts, ) def _paginate( texts: list[str], page_qualities: list[dict[str, Any]], page_engines: list[str], start_page: int, start_offset: int, max_chars: int, window_next: Optional[int], ) -> tuple[ list[dict[str, Any]], int, Optional[int], int, Optional[str], ]: pages_output: list[dict[str, Any]] = [] used_chars = 0 next_page: Optional[int] = None next_offset = 0 next_engine: Optional[str] = None quality_by_page = { item["page"]: item for item in page_qualities } for index, text in enumerate(texts): page_number = start_page + index page_engine = page_engines[index] page_quality = quality_by_page[page_number] text_usable = page_quality["status"] == "good" offset = start_offset if index == 0 else 0 if offset > len(text): raise ValueError( f"start-offset 超过第 {page_number} 页文本长度 {len(text)}" ) if not text_usable: if offset > 0: raise ValueError( f"第 {page_number} 页文本质量不可用,不能使用 start-offset" ) pages_output.append( { "page": page_number, "text": "", "char_count": len(text), "offset_start": 0, "offset_end": 0, "complete": True, "usable_for_summary": False, "text_quality_status": page_quality["status"], "extractor": page_engine, } ) continue remaining = text[offset:] budget = max_chars - used_chars if budget <= 0: next_page = page_number next_offset = offset break if len(remaining) > budget: if used_chars > 0 and budget < min(500, len(remaining)): next_page = page_number next_offset = offset break excerpt = remaining[:budget] pages_output.append( { "page": page_number, "text": excerpt, "char_count": len(text), "offset_start": offset, "offset_end": offset + len(excerpt), "complete": False, "usable_for_summary": True, "text_quality_status": page_quality["status"], "extractor": page_engine, } ) used_chars += len(excerpt) next_page = page_number next_offset = offset + len(excerpt) next_engine = page_engine break pages_output.append( { "page": page_number, "text": remaining, "char_count": len(text), "offset_start": offset, "offset_end": len(text), "complete": True, "usable_for_summary": True, "text_quality_status": page_quality["status"], "extractor": page_engine, } ) used_chars += len(remaining) if next_page is None: next_page = window_next return ( pages_output, used_chars, next_page, next_offset, next_engine, ) def _extract(args) -> dict[str, Any]: path = input_pdf(args.input) page_count = _page_count(path) effective_max_pages = 1 if args.start_offset > 0 else args.max_pages start_page, actual_end, window_next = selected_page_window( page_count, args.start_page, args.end_page, effective_max_pages, ) image_coverage = _image_coverage_by_page( path, start_page, actual_end, ) candidate, attempts = _select_candidate( args, path, page_count, image_coverage, start_page, actual_end, ) ( pages_output, used_chars, next_page, next_offset, next_engine, ) = _paginate( candidate.texts, candidate.quality["pages"], candidate.page_engines, start_page, args.start_offset, args.max_chars, window_next, ) usable = any( page["usable_for_summary"] for page in pages_output ) needs_ocr = bool(candidate.quality["suspect_pages"]) return { "path": str(path), "page_count": page_count, "extractor": candidate.engine, "engine_attempts": attempts, "text_quality": candidate.quality, "usable_for_summary": usable, "complete_text_coverage": not needs_ocr, "needs_ocr": needs_ocr, "warnings": candidate.warnings, "start_page": start_page, "end_page": pages_output[-1]["page"] if pages_output else None, "window_end_page": actual_end, "returned_chars": used_chars, "pages": pages_output, "has_more": next_page is not None, "next_page": next_page, "next_offset": next_offset, "next_engine": next_engine, } def main(argv: list[str] | None = None) -> int: arguments = sys.argv[1:] if argv is None else argv return run_cli(lambda: _extract(_parse_args(arguments))) if __name__ == "__main__": raise SystemExit(main())