Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ __pycache__/
.pytest_cache/
.venv/
reedy.egg-info/
build/
dist/
.DS_Store
*.wav
*.onnx
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ A CLI that reads text aloud using [piper-tts](https://github.com/rhasspy/piper).
## Features

- **Multiple input sources** — text argument, file (`-f`), clipboard (`-c`), or stdin
- **PDF support** — read full PDFs or selected pages with `--pages`
- **Pipe-friendly** — reads from stdin, works anywhere in a shell pipeline
- **Interactive mode** — conversational TTS with `/replay`, `/help`, `/clear`, tab completion, and history
- **Adjustable speech** — control speed (`-s`), volume (`-v`), and sentence silence (`--silence`)
Expand Down Expand Up @@ -72,6 +73,12 @@ reed 'Hello, I will read this for you'
# Read from a file
reed -f article.txt

# Read from a PDF
reed -f book.pdf

# Read selected pages from a PDF (1-based)
reed -f book.pdf --pages 1,3-5

# Read from clipboard
reed -c

Expand Down Expand Up @@ -176,6 +183,7 @@ All voice models are hosted on Hugging Face: [https://huggingface.co/rhasspy/pip
| Flag | Description | Default |
|------|-------------|---------|
| `-f`, `--file` | Read text from a file | — |
| `--pages` | PDF pages to read (1-based), e.g. `1,3-5` | — |
| `-c`, `--clipboard` | Read text from clipboard | — |
| `-m`, `--model` | Voice name or path to voice model | `en_US-kristin-medium` |
| `-s`, `--speed` | Speech speed (lower = slower) | `1.0` |
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ requires-python = ">=3.14"
dependencies = [
"piper-tts",
"pathvalidate",
"pypdf",
"prompt-toolkit",
"rich",
]
Expand Down
110 changes: 107 additions & 3 deletions reed.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,16 @@
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Callable, Optional, TextIO
from typing import TYPE_CHECKING, Callable, Iterator, Optional, TextIO

if TYPE_CHECKING:
from prompt_toolkit import PromptSession

try:
from pypdf import PdfReader
except ImportError: # pragma: no cover - validated in runtime error path
PdfReader = None

from rich.console import Console
from rich.markup import escape
from rich.panel import Panel
Expand Down Expand Up @@ -155,7 +160,10 @@ def get_text(
return result.stdout.strip()

if args.file:
return Path(args.file).read_text()
file_path = Path(args.file)
if args.pages:
raise ReedError("--pages can only be used with PDF files")
return file_path.read_text()

if not stdin.isatty():
return stdin.read().strip()
Expand All @@ -166,6 +174,83 @@ def get_text(
raise ReedError("No input provided. Use --help for usage.")


def _parse_pdf_pages(page_selection: str, total_pages: int) -> list[int]:
selection = page_selection.strip()
if not selection:
raise ReedError("Invalid page selection")

selected: list[int] = []
seen: set[int] = set()
for part in selection.split(","):
token = part.strip()
if not token:
raise ReedError("Invalid page selection")

if "-" in token:
bounds = token.split("-", 1)
if len(bounds) != 2 or not bounds[0].isdigit() or not bounds[1].isdigit():
raise ReedError("Invalid page selection")
start = int(bounds[0])
end = int(bounds[1])
if start < 1 or end < 1 or end < start:
raise ReedError("Invalid page selection")
pages = range(start, end + 1)
else:
if not token.isdigit():
raise ReedError("Invalid page selection")
page = int(token)
if page < 1:
raise ReedError("Invalid page selection")
pages = [page]

for page in pages:
if page > total_pages:
raise ReedError(
f"Page {page} is out of range (PDF has {total_pages} pages)"
)
index = page - 1
if index not in seen:
seen.add(index)
selected.append(index)

if not selected:
raise ReedError("Invalid page selection")
return selected


def _iter_pdf_pages(
path: Path, page_selection: Optional[str]
) -> Iterator[tuple[int, int, str]]:
"""Yield ``(page_number, total_pages, text)`` for each selected PDF page."""
if PdfReader is None:
raise ReedError("PDF support requires pypdf. Reinstall reed with dependencies.")

try:
reader = PdfReader(str(path))
except Exception as e: # pragma: no cover - depends on third-party parser internals
raise ReedError(f"Failed to read PDF: {e}")

total_pages = len(reader.pages)
if total_pages == 0:
raise ReedError("PDF has no pages")

if page_selection:
page_indices = _parse_pdf_pages(page_selection, total_pages)
else:
page_indices = list(range(total_pages))

found_any = False
for index in page_indices:
page_text = reader.pages[index].extract_text() or ""
page_text = page_text.strip()
if page_text:
found_any = True
yield (index + 1, total_pages, page_text)

if not found_any:
raise ReedError("No extractable text found in PDF")


def build_piper_cmd(
model: Path,
speed: float,
Expand Down Expand Up @@ -357,7 +442,7 @@ def interactive_loop(
def _should_enter_interactive(
args: argparse.Namespace, stdin: Optional[TextIO]
) -> bool:
if args.text or args.file or args.clipboard:
if args.text or args.file or args.clipboard or args.pages:
return False
if stdin is not None and hasattr(stdin, "isatty") and stdin.isatty():
return True
Expand All @@ -380,6 +465,11 @@ def main(
)
parser.add_argument("text", nargs="*", help="Text to read aloud")
parser.add_argument("-f", "--file", help="Read text from a file")
parser.add_argument(
"--pages",
default=None,
help="PDF pages to read (1-based), e.g. 1,3-5",
)
parser.add_argument(
"-c", "--clipboard", action="store_true", help="Read text from clipboard"
)
Expand Down Expand Up @@ -410,6 +500,9 @@ def main(
help="Seconds of silence between sentences",
)
args = parser.parse_args(argv)
if args.pages and not args.file:
print_error("--pages requires --file <PDF>", print_fn)
return 1

# Resolve model: None → default, short name → data dir path
if args.model is None:
Expand Down Expand Up @@ -489,6 +582,17 @@ def main(

try:
assert stdin is not None

# PDF: generate and play one page at a time
if args.file and Path(args.file).suffix.lower() == ".pdf":
ensure_model(config, print_fn)
for page_num, total, page_text in _iter_pdf_pages(
Path(args.file), args.pages
):
print_fn(f"\n[bold cyan]📄 Page {page_num}/{total}[/bold cyan]")
speak_text(page_text, config, run=run, print_fn=print_fn)
return 0

text = get_text(args, stdin, run=run)

if not text:
Expand Down
111 changes: 111 additions & 0 deletions test_reed.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ def _make_args(**overrides):
defaults = dict(
text=[],
file=None,
pages=None,
clipboard=False,
model=Path(__file__).parent / "en_US-kristin-medium.onnx",
speed=1.0,
Expand Down Expand Up @@ -382,6 +383,16 @@ def test_clipboard(self):
args = _make_args(clipboard=True)
assert _should_enter_interactive(args, io.StringIO()) is False

def test_pages_provided(self):
from reed import _should_enter_interactive

class FakeTty:
def isatty(self):
return True

args = _make_args(pages="1-2")
assert _should_enter_interactive(args, FakeTty()) is False

def test_tty_stdin_no_args(self):
from reed import _should_enter_interactive

Expand Down Expand Up @@ -620,6 +631,97 @@ def isatty(self):
assert result == "hello world"


class TestIterPdfPages:
def test_pdf_reads_all_pages_when_no_pages_flag(self, monkeypatch):
from reed import _iter_pdf_pages

class FakePage:
def __init__(self, text):
self._text = text

def extract_text(self):
return self._text

class FakeReader:
def __init__(self, path):
self.pages = [FakePage("page one"), FakePage("page two")]

monkeypatch.setattr("reed.PdfReader", FakeReader)

result = list(_iter_pdf_pages(Path("book.pdf"), None))
assert result == [(1, 2, "page one"), (2, 2, "page two")]

def test_pdf_reads_selected_pages(self, monkeypatch):
from reed import _iter_pdf_pages

class FakePage:
def __init__(self, text):
self._text = text

def extract_text(self):
return self._text

class FakeReader:
def __init__(self, path):
self.pages = [
FakePage("page one"),
FakePage("page two"),
FakePage("page three"),
FakePage("page four"),
]

monkeypatch.setattr("reed.PdfReader", FakeReader)

result = list(_iter_pdf_pages(Path("book.pdf"), "2,4"))
assert result == [(2, 4, "page two"), (4, 4, "page four")]

def test_pdf_page_out_of_bounds_raises(self, monkeypatch):
from reed import ReedError, _iter_pdf_pages

class FakePage:
def __init__(self, text):
self._text = text

def extract_text(self):
return self._text

class FakeReader:
def __init__(self, path):
self.pages = [FakePage("page one"), FakePage("page two")]

monkeypatch.setattr("reed.PdfReader", FakeReader)

with pytest.raises(ReedError, match="out of range"):
list(_iter_pdf_pages(Path("book.pdf"), "3"))

def test_pdf_invalid_pages_format_raises(self, monkeypatch):
from reed import ReedError, _iter_pdf_pages

class FakePage:
def __init__(self, text):
self._text = text

def extract_text(self):
return self._text

class FakeReader:
def __init__(self, path):
self.pages = [FakePage("page one"), FakePage("page two")]

monkeypatch.setattr("reed.PdfReader", FakeReader)

with pytest.raises(ReedError, match="Invalid page selection"):
list(_iter_pdf_pages(Path("book.pdf"), "1,a"))

def test_pages_flag_with_non_pdf_file_raises(self):
from reed import ReedError, get_text

txt = io.StringIO("file content")
args = _make_args(file="notes.txt", pages="1")
with pytest.raises(ReedError, match="only be used with PDF files"):
get_text(args, stdin=txt)


# ─── main error path tests ───────────────────────────────────────────


Expand Down Expand Up @@ -661,6 +763,15 @@ def failing_run(cmd, **kwargs):
assert code == 1
assert "piper exploded" in output

def test_pages_without_file_returns_1(self):
code, output = self._capture_main(
argv=["--pages", "1"],
run=lambda *a, **k: types.SimpleNamespace(returncode=0, stderr=""),
stdin=io.StringIO(""),
)
assert code == 1
assert "--pages requires --file <PDF>" in output


# ─── _data_dir tests ─────────────────────────────────────────────────

Expand Down
11 changes: 11 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading