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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

### New

- **Math in your e-books is read aloud, not skipped.** When you open an **EPUB** that contains equations — MathML or LaTeX — QUILL now turns each one into a plain-language reading and places it inline in the text, so a formula is spoken as part of its sentence instead of vanishing or being read out as raw markup. It uses the same math speech as the editor: the **MathCAT** engine when it is installed, QUILL's built-in reader otherwise, and the bare formula as a last resort — it never fails a book over an equation it cannot parse, and a book with no math is untouched. (`core/epub.py`)
- **Ask a question out loud, and choose how Quill answers — including in an OpenAI or Gemini voice.** Ask Quill has always let you ask by voice (**Ctrl+F9**: record, and Quill transcribes it on your own machine). The answer, though, only ever came back one way — a short summary handed to your screen reader, cut off at 140 characters — even though Quill already had two full speech stacks that could have read it properly. **Tools ▸ AI ▸ Voice Reply Settings...** now lets you pick, and the choice applies to every reply: **an announcement** (what you have today: brief, offline, free), **text only** if you would rather read it yourself, **read aloud in Quill's own voice** (Kokoro, Piper, eSpeak, DECtalk or SAPI — offline and free), or **read aloud in an AI voice** from **OpenAI** (11 voices) or **Google Gemini** (30 voices). When a reply is spoken you hear all of it: the 140-character cut-off exists to keep an announcement brief, and stopping speech mid-sentence would be worse than not speaking at all, so it does not apply to the spoken modes. That length is now yours to set (0 for no limit) and it governs **everything** Ask Quill announces — answers, errors, and the summary of an edit it proposes — where before it was a fixed 140 written into the program with no way to ask for more or less. Errors and edit proposals stay announcements whichever mode you choose, since a long error read out in full is rarely what anyone wants; they simply follow your chosen length. The dialog also fixes a real trap in the old settings list, where the voice list mixed every provider's voices together and you could pick a Gemini voice while OpenAI was selected — only finding out when speech failed; the model and voice lists are now rebuilt from the provider you chose, so an impossible pairing cannot be set. A **Preview** button reads a sample line in the selected voice before you commit. The AI voices are strictly opt-in and never chosen for you: they are billed per character and the reply text is sent to that provider to be spoken, so the dialog states that plainly and shows the estimated cost of a typical reply. If an AI voice cannot be used — no API key, or a provider that can only export audio — Quill falls back towards the offline options and tells you why, rather than silently ignoring your setting or losing the answer. Nothing changes until you choose: the default is exactly the behaviour you have now. (`core/ai/voice_reply.py`, `ui/voice_reply_dialog.py`, `ui/assistant_reply_delivery.py`)
- **The Media Player listens: hands-free voice commands, spoken on your own device.** The Quill Media Player can now be driven by voice without touching the keyboard beyond one shortcut. Press **Ctrl+Shift+L** (Playback ▸ Listen for a Command) and the player starts listening — you hear a "Listening" cue and the audiobook quietens; say a command like "next chapter", "skip back thirty", "go to one twenty three", "bookmark this", "how much is left", or "sleep in twenty"; press Ctrl+Shift+L again and the player transcribes what you said **entirely offline** and carries it out, announcing the result. Recognition reuses QUILL's own speech stack (#617) on a small, fast speech model, and honours whichever engine you chose in QUILL — **Whisper** (the small Tiny/Base/Small models are ideal for short commands) or **Nemotron**. Accessibility is built in, not bolted on: every state — Listening, Working, the result, "not recognized" (which tells you what it heard so you can adjust), no speech, or microphone unavailable — has its own earcon and announcement, with successes spoken politely behind your screen reader and failures interrupting; the book is **ducked, never paused**, so you never lose your place; actual capture starts a beat after the spoken cue so your screen reader's own voice isn't recorded; a safety timer closes a forgotten-open microphone; and the menu item's checkmark mirrors the live listening state. A **Type a Voice Command** box (Ctrl+Shift+V) remains for when you'd rather not speak aloud. Every voice command also has its own key, so voice is purely additive. (`quill/ui/media/voice_capture.py`, `quill/ui/media/listen_mixin.py`, `quill/core/media/voice.py`, `quill/ui/media/voice_control.py`)
- **Teach dictation your words — a `dictation.md` profile.** Dictation can now learn *your* vocabulary. A small, human-editable file called **`dictation.md`** in your QUILL data folder has three optional sections: **Vocabulary** (names, jargon, and acronyms you use — QUILL feeds these to the speech engine as a hint so it spells "wxPython" and "GitHub" your way, using Whisper's initial-prompt bias, now actually passed through to whisper.cpp), **Replacements** (spoken-to-written fixes applied to the transcript, one per line as `spoken => written`, with `\n` for a line break — great for punctuation macros and names the engine keeps mishearing), and **Commands** (your own spoken phrases for existing commands, still bound by the safe-command allowlist). The profile applies everywhere dictation transcribes — Locked Dictation, Dictate (Offline), and the Media Player's hands-free voice — and is a no-op until you make one, so nothing changes for people who don't want it. The idea is adapted from VS Code's dictation file, made native to QUILL. (`quill/core/speech/dictation_profile.py`, `quill/core/speech/providers/whispercpp.py`, `quill/ui/voice_services.py`)
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -10665,6 +10665,17 @@ <h3 id="583-quillin-manager--install-update-and-remove-extensions">5.83
has real MathML rendering/speech support, so embedding raw `&lt;math&gt;` there
would be inert markup, not real accessibility.

**Math in imported e-books (EPUB), shipped (#1355).** EPUB text extraction
(`quill/core/epub.py`) detects `&lt;math&gt;` MathML blocks, LaTeX-classed
`&lt;span&gt;`/`&lt;div&gt;` elements, and inline/block LaTeX delimiters (`$…$`, `$$…$$`,
`\(…\)`, `\[…\]`) in each chapter and converts them to the same spoken reading
the editor uses — `math/speech.speak()` via MathCAT, falling back to the
`navigator` template renderer, and to the raw formula if neither can parse it —
wrapping the result inline as `[Math Equation: …]` so an equation reads as a
sentence in place rather than being dropped or read out as tag soup. Extraction
never raises: a malformed equation degrades to stripped text, and a chapter with
no math is byte-for-byte unchanged.

**Tutorial:** [07-type-math-like-a-pro.md](../tutorials/07-type-math-like-a-pro.md)
teaches the whole surface end to end — gallery first, then typed input, the
structure explorer, Browser Preview, and the Word round trip.
Expand Down
11 changes: 11 additions & 0 deletions docs/Product Requirement Documents and Specifications/QUILL-PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -5896,6 +5896,17 @@ DAISY 2.02 *text-only* writer (no OPF/NCX/EPUB3), and no DAISY 2.02 player
has real MathML rendering/speech support, so embedding raw `<math>` there
would be inert markup, not real accessibility.

**Math in imported e-books (EPUB), shipped (#1355).** EPUB text extraction
(`quill/core/epub.py`) detects `<math>` MathML blocks, LaTeX-classed
`<span>`/`<div>` elements, and inline/block LaTeX delimiters (`$…$`, `$$…$$`,
`\(…\)`, `\[…\]`) in each chapter and converts them to the same spoken reading
the editor uses — `math/speech.speak()` via MathCAT, falling back to the
`navigator` template renderer, and to the raw formula if neither can parse it —
wrapping the result inline as `[Math Equation: …]` so an equation reads as a
sentence in place rather than being dropped or read out as tag soup. Extraction
never raises: a malformed equation degrades to stripped text, and a chapter with
no math is byte-for-byte unchanged.

**Tutorial:** [07-type-math-like-a-pro.md](../tutorials/07-type-math-like-a-pro.md)
teaches the whole surface end to end — gallery first, then typed input, the
structure explorer, Browser Preview, and the Word round trip.
Expand Down
Binary file modified docs/release notes/release1.0.0.epub
Binary file not shown.
7 changes: 7 additions & 0 deletions docs/release notes/release1.0.0.html
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,13 @@ <h3 id="equations">Equations</h3>
Word export writes real, editable Word equation objects that round-trip
back to text when you reopen the file. With the optional MathCAT engine
installed, "read this part aloud" speaks math the way NVDA does.</p>
<p><strong>And now the math in the books you read, too.</strong> When
you open an <strong>EPUB</strong> that contains equations — MathML or
LaTeX — QUILL converts each one to that same plain-language reading and
drops it inline in the text as "Math Equation: …", so a formula is
spoken as part of its sentence instead of being skipped or read out as
raw markup. It uses MathCAT when installed and QUILL's built-in reader
otherwise, and never fails a book over an equation it cannot parse.</p>
<p>This feature was contributed by @salorajan.</p>
<hr />
<h2 id="spelling-language-and-words">Spelling, Language, and Words</h2>
Expand Down
7 changes: 7 additions & 0 deletions docs/release notes/release1.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,13 @@ render it through MathJax, and Word export writes real, editable Word equation o
that round-trip back to text when you reopen the file. With the optional MathCAT engine
installed, "read this part aloud" speaks math the way NVDA does.

**And now the math in the books you read, too.** When you open an **EPUB** that contains
equations — MathML or LaTeX — QUILL converts each one to that same plain-language reading
and drops it inline in the text as "Math Equation: …", so a formula is spoken as part of
its sentence instead of being skipped or read out as raw markup. It uses MathCAT when
installed and QUILL's built-in reader otherwise, and never fails a book over an equation
it cannot parse.

This feature was contributed by @salorajan.

---
Expand Down
Binary file modified docs/user guide/userguide.epub
Binary file not shown.
10 changes: 10 additions & 0 deletions docs/user guide/userguide.html
Original file line number Diff line number Diff line change
Expand Up @@ -29808,6 +29808,16 @@ <h3 id="epub-navigator">EPUB Navigator</h3>
book's structure — a paragraph styled as a title or chapter, or a short
standalone bold line — so heading navigation and the chapter outline
still work where they would otherwise be empty.</p>
<p><strong>Equations are spoken, not skipped.</strong> When a chapter
contains mathematics — MathML, or LaTeX written as a <code>math</code>
span or between <code>$$…$$</code> / <code>\(…\)</code> delimiters —
QUILL converts each equation to a plain-language reading and places it
inline as "Math Equation: …" so the formula is read as part of its
sentence rather than lost or spelled out as raw markup. It uses the same
math speech as the editor: the MathCAT engine when it is installed,
QUILL's built-in reader otherwise. A formula QUILL cannot parse is read
as its plain source rather than dropped, and a book with no math reads
exactly as before.</p>
<h3 id="ocr-image">OCR Image</h3>
<p>OCR is explicit and local. You choose the image, confirm the action,
and receive progress updates. This keeps OCR useful without making it
Expand Down
2 changes: 2 additions & 0 deletions docs/user guide/userguide.md
Original file line number Diff line number Diff line change
Expand Up @@ -7317,6 +7317,8 @@ When you open an EPUB, the navigator gives you chapter-aware movement rather tha

Chapter headings are also placed **inline in the text**, so single-key heading navigation (`H` and `Shift+H`) walks every heading in the book, not just chapter boundaries. Many older or hand-made EPUBs never used real heading tags at all; for those, QUILL **infers** headings from the book's structure — a paragraph styled as a title or chapter, or a short standalone bold line — so heading navigation and the chapter outline still work where they would otherwise be empty.

**Equations are spoken, not skipped.** When a chapter contains mathematics — MathML, or LaTeX written as a `math` span or between `$$…$$` / `\(…\)` delimiters — QUILL converts each equation to a plain-language reading and places it inline as "Math Equation: …" so the formula is read as part of its sentence rather than lost or spelled out as raw markup. It uses the same math speech as the editor: the MathCAT engine when it is installed, QUILL's built-in reader otherwise. A formula QUILL cannot parse is read as its plain source rather than dropped, and a book with no math reads exactly as before.

### OCR Image

OCR is explicit and local. You choose the image, confirm the action, and receive progress updates. This keeps OCR useful without making it invisible or surprising.
Expand Down
38 changes: 24 additions & 14 deletions tests/unit/core/test_epub.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
from __future__ import annotations

import re
import zipfile
from pathlib import Path

from quill.core.epub import load_epub_book, render_epub_book


def _math_equations(text: str) -> list[str]:
"""The spoken renderings inside each ``[Math Equation: ...]`` marker.

Assertions use this rather than exact phrasing: the words depend on which
math-speech backend is installed (MathCAT gives "1 half", the template
fallback gives "the fraction 1 over 2"), so tests check that a non-empty
equation was produced and wrapped, not how any one engine reads it.
"""
return re.findall(r"\[Math Equation: ([^\]]+)\]", text)


def test_load_epub_book_reads_ncx_order(tmp_path: Path) -> None:
target = tmp_path / "book.epub"
toc = (
Expand Down Expand Up @@ -128,7 +140,11 @@ def test_epub_math_parsing_extracts_mathml(tmp_path: Path) -> None:
with zipfile.ZipFile(target, "w") as archive:
archive.writestr("chapters/one.xhtml", chapter)
book = load_epub_book(target)
assert "[Math Equation: the fraction 1 over 2]" in book.chapters[0].text
text = book.chapters[0].text
equations = _math_equations(text)
assert len(equations) == 1
assert equations[0].strip() # a non-empty rendering, whatever backend produced it
assert "inside text." in text # surrounding prose is preserved


def test_epub_math_parsing_extracts_latex_classes(tmp_path: Path) -> None:
Expand All @@ -139,11 +155,9 @@ def test_epub_math_parsing_extracts_latex_classes(tmp_path: Path) -> None:
with zipfile.ZipFile(target, "w") as archive:
archive.writestr("chapters/one.xhtml", chapter)
book = load_epub_book(target)
# May convert to spoken text if latex2mathml is installed, or fall back to raw LaTeX
assert (
"[Math Equation: x squared plus y squared equals z squared]" in book.chapters[0].text
or "[Math Equation: x^2 + y^2 = z^2]" in book.chapters[0].text
)
equations = _math_equations(book.chapters[0].text)
assert len(equations) == 1
assert equations[0].strip()


def test_epub_math_parsing_extracts_latex_delimiters(tmp_path: Path) -> None:
Expand All @@ -154,11 +168,7 @@ def test_epub_math_parsing_extracts_latex_delimiters(tmp_path: Path) -> None:
with zipfile.ZipFile(target, "w") as archive:
archive.writestr("chapters/one.xhtml", chapter)
book = load_epub_book(target)
assert (
"[Math Equation: a squared plus b squared equals c squared]" in book.chapters[0].text
or "[Math Equation: a^2 + b^2 = c^2]" in book.chapters[0].text
)
assert (
"[Math Equation: x equals y]" in book.chapters[0].text
or "[Math Equation: x = y]" in book.chapters[0].text
)
# Both the inline \( \) and the block $$ $$ equation become spoken markers.
equations = _math_equations(book.chapters[0].text)
assert len(equations) == 2
assert all(equation.strip() for equation in equations)