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.
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user