From 5d0ed21e07b882cb5347dea4ef562f81bd4d2958 Mon Sep 17 00:00:00 2001 From: Zhengshou Lai Date: Tue, 9 Jun 2026 14:38:24 +0800 Subject: [PATCH] feat(pdf): add pdf extract command with pdftotext/pypdf fallback Add `mytoolkit pdf extract` to extract text from PDFs. Tries pdftotext (poppler) first for layout quality, falls back to pypdf. --- bin/commands/pdf.py | 72 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/bin/commands/pdf.py b/bin/commands/pdf.py index 0a2b18b..13416a7 100644 --- a/bin/commands/pdf.py +++ b/bin/commands/pdf.py @@ -1,6 +1,7 @@ """PDF utilities.""" import shutil +import subprocess from pathlib import Path import click @@ -165,6 +166,77 @@ def merge_pdfs(files, output, bookmark_depth, dry_run): click.echo(f"Merged {len(processed)} files into {output}") +@pdf.command("extract") +@click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path)) +@click.option( + "-o", + "--output", + type=click.Path(dir_okay=False, path_type=Path), + help="Output text file. Defaults to stdout.", +) +@click.option( + "-p", + "--pages", + help="Page range to extract (e.g. 1-5, 1,3,5). Defaults to all pages.", +) +@click.option( + "--layout/--no-layout", + default=False, + help="Preserve original layout using pdftotext -layout. Only applies when pdftotext is available.", +) +@handle_errors +def extract_text(input_file, output, pages, layout): + """Extract text from a PDF file. + + Tries pdftotext (poppler) first for best quality, falls back to pypdf. + """ + text = "" + use_pdftotext = shutil.which("pdftotext") is not None + + if use_pdftotext: + cmd = ["pdftotext"] + if layout: + cmd.append("-layout") + if pages: + cmd.extend(["-f", str(pages.split("-")[0]), "-l", str(pages.split("-")[-1])]) + cmd.extend([str(input_file), "-"]) + result = subprocess.run(cmd, capture_output=True, text=True) + text = result.stdout + else: + reader = PdfReader(str(input_file)) + page_indices = _parse_page_range(pages, len(reader.pages)) + text = "\n\n".join( + reader.pages[i].extract_text() or "" + for i in page_indices + ) + + if output: + output.write_text(text, encoding="utf-8") + click.echo(f"Extracted text to {output}") + else: + click.echo(text) + + +def _parse_page_range(pages: str | None, total: int) -> list[int]: + """Parse a page range string into 0-based page indices.""" + if not pages: + return list(range(total)) + + indices = set() + for part in pages.split(","): + part = part.strip() + if "-" in part: + start, end = part.split("-", 1) + start = int(start.strip()) - 1 if start.strip() else 0 + end = int(end.strip()) if end.strip() else total + indices.update(range(max(0, start), min(total, end))) + else: + idx = int(part) - 1 + if 0 <= idx < total: + indices.add(idx) + return sorted(indices) + + @pdf.command("bookmark") @click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path)) @click.option(