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."""
|
"""PDF utilities."""
|
||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import click
|
import click
|
||||||
@@ -165,6 +166,77 @@ def merge_pdfs(files, output, bookmark_depth, dry_run):
|
|||||||
click.echo(f"Merged {len(processed)} files into {output}")
|
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")
|
@pdf.command("bookmark")
|
||||||
@click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
@click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||||
@click.option(
|
@click.option(
|
||||||
|
|||||||
Reference in New Issue
Block a user