From f83e9a1817ca19a51876eadcc27a0de6c53a88c8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:27:23 +0000 Subject: [PATCH 1/2] Initial plan From f9c623f6e6f87e85b23b161728b37c0c37899e0b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:33:32 +0000 Subject: [PATCH 2/2] Add complete Gemini PDF Reader application with all modules, tests, and documentation Co-authored-by: ashutoshjangle <46676790+ashutoshjangle@users.noreply.github.com> --- .gitignore | 13 + gemini-pdf-reader/.env.example | 1 + gemini-pdf-reader/README.md | 95 +++++ gemini-pdf-reader/core/__init__.py | 0 gemini-pdf-reader/core/gemini_client.py | 117 ++++++ gemini-pdf-reader/core/highlighter.py | 76 ++++ gemini-pdf-reader/core/pdf_processor.py | 155 ++++++++ gemini-pdf-reader/main.py | 28 ++ gemini-pdf-reader/requirements.txt | 5 + gemini-pdf-reader/tests/__init__.py | 0 gemini-pdf-reader/tests/test_core.py | 187 ++++++++++ gemini-pdf-reader/ui/__init__.py | 0 gemini-pdf-reader/ui/explanation_panel.py | 186 ++++++++++ gemini-pdf-reader/ui/main_window.py | 309 ++++++++++++++++ gemini-pdf-reader/ui/pdf_viewer.py | 411 ++++++++++++++++++++++ gemini-pdf-reader/utils/__init__.py | 0 gemini-pdf-reader/utils/config.py | 52 +++ gemini-pdf-reader/utils/themes.py | 131 +++++++ 18 files changed, 1766 insertions(+) create mode 100644 .gitignore create mode 100644 gemini-pdf-reader/.env.example create mode 100644 gemini-pdf-reader/README.md create mode 100644 gemini-pdf-reader/core/__init__.py create mode 100644 gemini-pdf-reader/core/gemini_client.py create mode 100644 gemini-pdf-reader/core/highlighter.py create mode 100644 gemini-pdf-reader/core/pdf_processor.py create mode 100644 gemini-pdf-reader/main.py create mode 100644 gemini-pdf-reader/requirements.txt create mode 100644 gemini-pdf-reader/tests/__init__.py create mode 100644 gemini-pdf-reader/tests/test_core.py create mode 100644 gemini-pdf-reader/ui/__init__.py create mode 100644 gemini-pdf-reader/ui/explanation_panel.py create mode 100644 gemini-pdf-reader/ui/main_window.py create mode 100644 gemini-pdf-reader/ui/pdf_viewer.py create mode 100644 gemini-pdf-reader/utils/__init__.py create mode 100644 gemini-pdf-reader/utils/config.py create mode 100644 gemini-pdf-reader/utils/themes.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..671e7a5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.env +*.egg-info/ +dist/ +build/ +.eggs/ +venv/ +.venv/ +node_modules/ +.pytest_cache/ diff --git a/gemini-pdf-reader/.env.example b/gemini-pdf-reader/.env.example new file mode 100644 index 0000000..8b79f01 --- /dev/null +++ b/gemini-pdf-reader/.env.example @@ -0,0 +1 @@ +GEMINI_API_KEY=your_key_here diff --git a/gemini-pdf-reader/README.md b/gemini-pdf-reader/README.md new file mode 100644 index 0000000..e8f279a --- /dev/null +++ b/gemini-pdf-reader/README.md @@ -0,0 +1,95 @@ +# Gemini PDF Reader + +A dual-pane desktop PDF reader with integrated Google Gemini Pro contextual explanations. Built with PyQt6, PyMuPDF, and the Google Generative AI SDK. + +## Features + +- **Dual-Pane Layout**: PDF viewer on the left, explanation panel on the right +- **Gemini Integration**: Right-click selected text to get AI-powered explanations, summaries, or definitions +- **Smart Context**: Automatically chunks large PDFs to fit within token limits, prioritizing pages near your selection +- **Cross-Reference Highlighting**: Gemini's references to the document are highlighted in both the explanation panel and the PDF viewer +- **Conversation History**: All explanations are preserved in a scrollable history for the current session +- **Dark Mode**: Toggle between light and dark themes with `Ctrl+D` +- **Export**: Save all explanations as a Markdown file +- **Keyboard Shortcuts**: `Ctrl+E` to explain selected text, `Ctrl+O` to open a PDF + +## Installation + +```bash +cd gemini-pdf-reader +pip install -r requirements.txt +``` + +## Configuration + +1. Copy `.env.example` to `.env`: + ```bash + cp .env.example .env + ``` +2. Edit `.env` and add your Google Gemini API key: + ``` + GEMINI_API_KEY=your_actual_key_here + ``` + +Alternatively, launch the app and use **Help → Set API Key** to enter your key. It will be saved to `~/.gemini-pdf-reader/config.env`. + +## Usage + +```bash +python main.py +``` + +1. **Open a PDF**: `File → Open PDF...` or `Ctrl+O` +2. **Select text**: Click and drag on the PDF to select a passage +3. **Get explanation**: Right-click and choose "Explain with Gemini", "Summarize Selection", or "Define Term" — or press `Ctrl+E` +4. **View highlights**: Referenced text from the document is highlighted in yellow in both the explanation panel and the PDF viewer +5. **Export**: Click "Export Explanations" to save all session explanations as Markdown + +## Keyboard Shortcuts + +| Shortcut | Action | +|----------|--------| +| `Ctrl+O` | Open PDF | +| `Ctrl+E` | Explain selected text | +| `Ctrl+D` | Toggle dark mode | +| `Ctrl+Q` | Quit | + +## Project Structure + +``` +gemini-pdf-reader/ +├── main.py # Entry point +├── ui/ +│ ├── main_window.py # Main window layout +│ ├── pdf_viewer.py # PDF rendering + selection + highlighting +│ └── explanation_panel.py # Right-side explanation display +├── core/ +│ ├── gemini_client.py # Gemini API wrapper +│ ├── pdf_processor.py # PDF text extraction + chunking +│ └── highlighter.py # Cross-reference highlighting logic +├── utils/ +│ ├── config.py # API key management +│ └── themes.py # Dark/light theme definitions +├── tests/ +│ └── test_core.py # Unit tests for core modules +├── requirements.txt +├── .env.example +└── README.md +``` + +## Error Handling + +- **No API key**: Shows a clear message in the explanation panel with instructions +- **API rate limits**: Displays the error message from the API +- **PDF load failures**: Shows an error dialog +- **Empty selections**: Warns the user to select text first +- **Image-only PDFs**: Warns that no extractable text was found and suggests OCR + +## Requirements + +- Python 3.9+ +- PyQt6 +- PyMuPDF (fitz) +- google-generativeai +- python-dotenv +- markdown diff --git a/gemini-pdf-reader/core/__init__.py b/gemini-pdf-reader/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gemini-pdf-reader/core/gemini_client.py b/gemini-pdf-reader/core/gemini_client.py new file mode 100644 index 0000000..8c311ae --- /dev/null +++ b/gemini-pdf-reader/core/gemini_client.py @@ -0,0 +1,117 @@ +"""Google Gemini API client wrapper.""" + +from typing import Optional + +import google.generativeai as genai + +from utils.config import load_api_key + + +EXPLAIN_PROMPT = ( + "You are a document reading assistant. The user is reading the following " + "document:\n\n{context}\n\n" + "They have selected the following passage and want it explained in the " + "context of this document:\n\n\"{selected}\"\n\n" + "Explain this passage clearly. Reference specific parts of the document " + "to support your explanation. When you reference text from the document, " + "wrap it in ... tags so the app can identify and " + "highlight those sections in the PDF viewer." +) + +SUMMARIZE_PROMPT = ( + "You are a document reading assistant. The user is reading the following " + "document:\n\n{context}\n\n" + "They have selected the following passage and want a concise summary:\n\n" + "\"{selected}\"\n\n" + "Provide a clear, concise summary. When you reference text from the " + "document, wrap it in ... tags." +) + +DEFINE_PROMPT = ( + "You are a document reading assistant. The user is reading the following " + "document:\n\n{context}\n\n" + "They want you to define the following term/phrase in the context of this " + "document:\n\n\"{selected}\"\n\n" + "Provide a clear definition. When you reference text from the document, " + "wrap it in ... tags." +) + + +class GeminiClient: + """Wrapper around the Google Generative AI SDK for Gemini Pro.""" + + def __init__(self, api_key: Optional[str] = None) -> None: + """Initialize the Gemini client. + + Args: + api_key: Optional API key. If not provided, attempts to load + from environment/config. + + Raises: + ValueError: If no API key is available. + """ + self._api_key = api_key or load_api_key() + if not self._api_key: + raise ValueError( + "No Gemini API key found. Please set GEMINI_API_KEY in your " + ".env file or enter it in the application settings." + ) + genai.configure(api_key=self._api_key) + self._model = genai.GenerativeModel("gemini-1.5-pro") + + def explain(self, context: str, selected_text: str) -> str: + """Ask Gemini to explain selected text in context. + + Args: + context: The full (or chunked) document text. + selected_text: The user-selected passage. + + Returns: + Gemini's explanation as a string. + """ + return self._query(EXPLAIN_PROMPT, context, selected_text) + + def summarize(self, context: str, selected_text: str) -> str: + """Ask Gemini to summarize selected text. + + Args: + context: The full (or chunked) document text. + selected_text: The user-selected passage. + + Returns: + Gemini's summary as a string. + """ + return self._query(SUMMARIZE_PROMPT, context, selected_text) + + def define(self, context: str, selected_text: str) -> str: + """Ask Gemini to define a term from the document. + + Args: + context: The full (or chunked) document text. + selected_text: The term or phrase to define. + + Returns: + Gemini's definition as a string. + """ + return self._query(DEFINE_PROMPT, context, selected_text) + + def _query(self, prompt_template: str, context: str, selected: str) -> str: + """Send a prompt to Gemini and return the response text. + + Args: + prompt_template: Prompt template with {context} and {selected}. + context: Document context. + selected: Selected text. + + Returns: + The model's response text. + + Raises: + RuntimeError: On API errors. + """ + prompt = prompt_template.format(context=context, selected=selected) + try: + response = self._model.generate_content(prompt) + return response.text + except Exception as exc: + raise RuntimeError(f"Gemini API error: {exc}") from exc diff --git a/gemini-pdf-reader/core/highlighter.py b/gemini-pdf-reader/core/highlighter.py new file mode 100644 index 0000000..9c9bbd9 --- /dev/null +++ b/gemini-pdf-reader/core/highlighter.py @@ -0,0 +1,76 @@ +"""Cross-reference highlighting logic. + +Parses ... tags from Gemini responses and provides +utilities for rendering highlighted text in both the explanation panel +and the PDF viewer. +""" + +import re +from dataclasses import dataclass +from typing import List + + +@dataclass +class HighlightSegment: + """A segment of text extracted from tags.""" + text: str + + +def extract_highlights(response_text: str) -> List[HighlightSegment]: + """Extract all -tagged text from a Gemini response. + + Args: + response_text: The raw response from Gemini. + + Returns: + List of HighlightSegment objects. + """ + pattern = re.compile(r"(.*?)", re.DOTALL) + return [HighlightSegment(text=m.group(1).strip()) for m in pattern.finditer(response_text)] + + +def response_to_html(response_text: str) -> str: + """Convert a Gemini response to HTML with highlighted segments styled. + + Replaces ... tags with styled elements + and converts basic Markdown formatting to HTML. + + Args: + response_text: The raw response from Gemini. + + Returns: + HTML string suitable for rendering in a QTextBrowser. + """ + import markdown + + # Temporarily replace highlight tags with placeholders + placeholder_map: dict[str, str] = {} + counter = 0 + + def _replace_highlight(m: re.Match) -> str: + nonlocal counter + key = f"GEMINIHIGHLIGHT{counter}ENDHIGHLIGHT" + text = m.group(1).strip() + placeholder_map[key] = ( + f'{text}' + ) + counter += 1 + return key + + text = re.sub( + r"(.*?)", + _replace_highlight, + response_text, + flags=re.DOTALL, + ) + + # Convert Markdown to HTML + html = markdown.markdown(text, extensions=["extra", "nl2br"]) + + # Restore highlights + for key, replacement in placeholder_map.items(): + html = html.replace(key, replacement) + + return html diff --git a/gemini-pdf-reader/core/pdf_processor.py b/gemini-pdf-reader/core/pdf_processor.py new file mode 100644 index 0000000..107b0cf --- /dev/null +++ b/gemini-pdf-reader/core/pdf_processor.py @@ -0,0 +1,155 @@ +"""PDF text extraction, rendering, and chunking utilities.""" + +from dataclasses import dataclass, field +from typing import List, Optional, Tuple + +import fitz # PyMuPDF + + +@dataclass +class PageText: + """Extracted text and metadata for a single PDF page.""" + page_number: int + text: str + + +@dataclass +class PDFDocument: + """Represents a loaded PDF document with extracted text.""" + path: str + pages: List[PageText] = field(default_factory=list) + total_pages: int = 0 + + @property + def full_text(self) -> str: + """Return the full concatenated text of all pages.""" + return "\n\n".join( + f"[Page {p.page_number + 1}]\n{p.text}" for p in self.pages + ) + + @property + def has_text(self) -> bool: + """Check if the PDF has any extractable text.""" + return any(p.text.strip() for p in self.pages) + + +def extract_text(pdf_path: str) -> PDFDocument: + """Extract text from all pages of a PDF. + + Args: + pdf_path: Path to the PDF file. + + Returns: + A PDFDocument containing extracted text per page. + + Raises: + FileNotFoundError: If the PDF file does not exist. + fitz.FileDataError: If the file is not a valid PDF. + """ + doc = fitz.open(pdf_path) + pages: List[PageText] = [] + for i in range(len(doc)): + page = doc[i] + text = page.get_text("text") + pages.append(PageText(page_number=i, text=text)) + pdf_doc = PDFDocument(path=pdf_path, pages=pages, total_pages=len(doc)) + doc.close() + return pdf_doc + + +def chunk_text( + pdf_doc: PDFDocument, + selected_page: int, + max_chars: int = 80000, +) -> str: + """Build a context string that fits within token limits. + + Prioritizes pages around the selection and includes a summary of + distant pages if the full text exceeds ``max_chars``. + + Args: + pdf_doc: The loaded PDF document. + selected_page: The page index where text was selected. + max_chars: Maximum character count for the context. + + Returns: + A context string suitable for sending to the LLM. + """ + full = pdf_doc.full_text + if len(full) <= max_chars: + return full + + # Prioritise nearby pages + radius = 3 + nearby_pages = [ + p for p in pdf_doc.pages + if abs(p.page_number - selected_page) <= radius + ] + nearby_text = "\n\n".join( + f"[Page {p.page_number + 1}]\n{p.text}" for p in nearby_pages + ) + + # Summarise the rest + other_pages = [ + p for p in pdf_doc.pages + if abs(p.page_number - selected_page) > radius + ] + summary_parts: List[str] = [] + for p in other_pages: + snippet = p.text[:200].replace("\n", " ").strip() + if snippet: + summary_parts.append(f"Page {p.page_number + 1}: {snippet}...") + + summary = "\n".join(summary_parts) + remaining = max_chars - len(nearby_text) - 200 + if remaining > 0: + summary = summary[:remaining] + + return ( + f"=== Nearby pages (around page {selected_page + 1}) ===\n" + f"{nearby_text}\n\n" + f"=== Summary of other pages ===\n{summary}" + ) + + +def render_page(pdf_path: str, page_num: int, zoom: float = 2.0) -> bytes: + """Render a single PDF page as PNG bytes. + + Args: + pdf_path: Path to the PDF file. + page_num: Zero-based page index. + zoom: Zoom factor for rendering (default 2.0 for high DPI). + + Returns: + PNG image data as bytes. + """ + doc = fitz.open(pdf_path) + page = doc[page_num] + mat = fitz.Matrix(zoom, zoom) + pix = page.get_pixmap(matrix=mat) + png_data = pix.tobytes("png") + doc.close() + return png_data + + +def search_text_rects( + pdf_path: str, + page_num: int, + search_text: str, +) -> List[Tuple[float, float, float, float]]: + """Find bounding rectangles for text on a PDF page. + + Args: + pdf_path: Path to the PDF file. + page_num: Zero-based page index. + search_text: Text to search for on the page. + + Returns: + List of (x0, y0, x1, y1) rectangles in PDF coordinates. + """ + doc = fitz.open(pdf_path) + page = doc[page_num] + rects = page.search_for(search_text) + result = [(r.x0, r.y0, r.x1, r.y1) for r in rects] + doc.close() + return result diff --git a/gemini-pdf-reader/main.py b/gemini-pdf-reader/main.py new file mode 100644 index 0000000..f0da3c2 --- /dev/null +++ b/gemini-pdf-reader/main.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Gemini PDF Reader — Entry point. + +A dual-pane desktop PDF reader with Google Gemini Pro +contextual explanations. +""" + +import sys + +from PyQt6.QtWidgets import QApplication + +from ui.main_window import MainWindow + + +def main() -> None: + """Launch the application.""" + app = QApplication(sys.argv) + app.setApplicationName("Gemini PDF Reader") + app.setApplicationVersion("1.0.0") + + window = MainWindow() + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/gemini-pdf-reader/requirements.txt b/gemini-pdf-reader/requirements.txt new file mode 100644 index 0000000..5fbcf5c --- /dev/null +++ b/gemini-pdf-reader/requirements.txt @@ -0,0 +1,5 @@ +PyQt6>=6.5.0 +PyMuPDF>=1.23.0 +google-generativeai>=0.3.0 +python-dotenv>=1.0.0 +markdown>=3.5.0 diff --git a/gemini-pdf-reader/tests/__init__.py b/gemini-pdf-reader/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gemini-pdf-reader/tests/test_core.py b/gemini-pdf-reader/tests/test_core.py new file mode 100644 index 0000000..dfaca4b --- /dev/null +++ b/gemini-pdf-reader/tests/test_core.py @@ -0,0 +1,187 @@ +"""Unit tests for core modules (highlighter, pdf_processor, config).""" + +import os +import tempfile +import unittest + +import fitz # PyMuPDF + +# Adjust path so imports work when running from gemini-pdf-reader directory +import sys +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from core.highlighter import extract_highlights, response_to_html +from core.pdf_processor import ( + PDFDocument, + PageText, + extract_text, + chunk_text, + render_page, + search_text_rects, +) +from utils.config import load_api_key + + +class TestHighlighter(unittest.TestCase): + """Tests for the highlighter module.""" + + def test_extract_highlights_single(self) -> None: + text = "Here is important text in the response." + segments = extract_highlights(text) + self.assertEqual(len(segments), 1) + self.assertEqual(segments[0].text, "important text") + + def test_extract_highlights_multiple(self) -> None: + text = ( + "first and " + "second highlights." + ) + segments = extract_highlights(text) + self.assertEqual(len(segments), 2) + self.assertEqual(segments[0].text, "first") + self.assertEqual(segments[1].text, "second") + + def test_extract_highlights_none(self) -> None: + text = "No highlights here." + segments = extract_highlights(text) + self.assertEqual(len(segments), 0) + + def test_extract_highlights_multiline(self) -> None: + text = "multi\nline\ntext" + segments = extract_highlights(text) + self.assertEqual(len(segments), 1) + self.assertEqual(segments[0].text, "multi\nline\ntext") + + def test_response_to_html_contains_styled_highlight(self) -> None: + text = "Some highlighted text here." + html = response_to_html(text) + self.assertIn("highlighted text", html) + self.assertIn("background-color", html) + self.assertIn("highlight-ref", html) + + def test_response_to_html_no_highlights(self) -> None: + text = "Plain **bold** text." + html = response_to_html(text) + self.assertIn("bold", html) + self.assertNotIn("highlight-ref", html) + + +class TestPDFDocument(unittest.TestCase): + """Tests for the PDFDocument dataclass.""" + + def test_full_text(self) -> None: + doc = PDFDocument( + path="test.pdf", + pages=[ + PageText(page_number=0, text="Page one text."), + PageText(page_number=1, text="Page two text."), + ], + total_pages=2, + ) + full = doc.full_text + self.assertIn("[Page 1]", full) + self.assertIn("Page one text.", full) + self.assertIn("[Page 2]", full) + self.assertIn("Page two text.", full) + + def test_has_text_true(self) -> None: + doc = PDFDocument( + path="test.pdf", + pages=[PageText(page_number=0, text="Some text")], + total_pages=1, + ) + self.assertTrue(doc.has_text) + + def test_has_text_false(self) -> None: + doc = PDFDocument( + path="test.pdf", + pages=[PageText(page_number=0, text=" ")], + total_pages=1, + ) + self.assertFalse(doc.has_text) + + +class TestChunkText(unittest.TestCase): + """Tests for the chunk_text function.""" + + def test_small_document_returns_full_text(self) -> None: + doc = PDFDocument( + path="test.pdf", + pages=[PageText(page_number=0, text="Short text.")], + total_pages=1, + ) + result = chunk_text(doc, selected_page=0, max_chars=1000) + self.assertIn("Short text.", result) + + def test_large_document_is_chunked(self) -> None: + pages = [ + PageText(page_number=i, text=f"Content for page {i}. " * 100) + for i in range(50) + ] + doc = PDFDocument(path="test.pdf", pages=pages, total_pages=50) + result = chunk_text(doc, selected_page=25, max_chars=5000) + # Should contain nearby pages + self.assertIn("Page 26", result) # page 25 (0-indexed) = "Page 26" + # Should not contain the entire document + self.assertLess(len(result), len(doc.full_text)) + + +class TestPDFProcessorWithFile(unittest.TestCase): + """Tests that require creating a temporary PDF file.""" + + def setUp(self) -> None: + """Create a temporary PDF with known text.""" + self._tmp = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) + doc = fitz.open() + page = doc.new_page() + page.insert_text((72, 72), "Hello World from test PDF.", fontsize=12) + page.insert_text((72, 100), "This is a second line.", fontsize=12) + doc.save(self._tmp.name) + doc.close() + self._tmp.close() + + def tearDown(self) -> None: + os.unlink(self._tmp.name) + + def test_extract_text(self) -> None: + pdf_doc = extract_text(self._tmp.name) + self.assertEqual(pdf_doc.total_pages, 1) + self.assertIn("Hello World", pdf_doc.pages[0].text) + + def test_render_page(self) -> None: + png_data = render_page(self._tmp.name, 0) + self.assertIsInstance(png_data, bytes) + self.assertTrue(len(png_data) > 100) + # PNG signature + self.assertTrue(png_data[:4] == b"\x89PNG") + + def test_search_text_rects(self) -> None: + rects = search_text_rects(self._tmp.name, 0, "Hello World") + self.assertGreater(len(rects), 0) + x0, y0, x1, y1 = rects[0] + self.assertLess(x0, x1) + self.assertLess(y0, y1) + + def test_search_text_rects_not_found(self) -> None: + rects = search_text_rects(self._tmp.name, 0, "NONEXISTENT TEXT XYZ") + self.assertEqual(len(rects), 0) + + +class TestConfig(unittest.TestCase): + """Tests for config module.""" + + def test_load_api_key_returns_none_when_not_set(self) -> None: + # Temporarily clear env + old = os.environ.pop("GEMINI_API_KEY", None) + try: + key = load_api_key() + # May or may not be None depending on environment + # but should not crash + self.assertIsInstance(key, (str, type(None))) + finally: + if old is not None: + os.environ["GEMINI_API_KEY"] = old + + +if __name__ == "__main__": + unittest.main() diff --git a/gemini-pdf-reader/ui/__init__.py b/gemini-pdf-reader/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gemini-pdf-reader/ui/explanation_panel.py b/gemini-pdf-reader/ui/explanation_panel.py new file mode 100644 index 0000000..1b0c7eb --- /dev/null +++ b/gemini-pdf-reader/ui/explanation_panel.py @@ -0,0 +1,186 @@ +"""Explanation panel for displaying Gemini responses.""" + +from typing import List, Optional + +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QTextCursor +from PyQt6.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QLabel, + QTextBrowser, + QPushButton, + QProgressBar, + QFileDialog, +) + +from core.highlighter import HighlightSegment, extract_highlights, response_to_html + + +class ExplanationPanel(QWidget): + """Right-side panel displaying Gemini explanations with history.""" + + highlight_clicked = pyqtSignal(str) # Emitted when user clicks a highlight + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self._history: List[dict] = [] # List of {"query": ..., "html": ..., "raw": ...} + self._setup_ui() + + def _setup_ui(self) -> None: + """Build the panel layout.""" + layout = QVBoxLayout(self) + layout.setContentsMargins(4, 4, 4, 4) + + header = QLabel("Explanation Panel") + header.setStyleSheet("font-size: 14px; font-weight: bold; padding: 4px;") + layout.addWidget(header) + + # Progress bar (hidden by default) + self._progress_bar = QProgressBar() + self._progress_bar.setRange(0, 0) # Indeterminate + self._progress_bar.setTextVisible(False) + self._progress_bar.setFixedHeight(6) + self._progress_bar.hide() + layout.addWidget(self._progress_bar) + + self._loading_label = QLabel("Asking Gemini...") + self._loading_label.setStyleSheet("color: #888; font-style: italic;") + self._loading_label.hide() + layout.addWidget(self._loading_label) + + # Text browser for explanation + self._browser = QTextBrowser() + self._browser.setOpenExternalLinks(False) + self._browser.setReadOnly(True) + self._browser.anchorClicked.connect(self._on_anchor_clicked) + self._browser.setHtml( + '

Open a PDF and select text to get started.

' + ) + layout.addWidget(self._browser) + + # Bottom buttons + btn_layout = QHBoxLayout() + self._clear_btn = QPushButton("Clear History") + self._clear_btn.clicked.connect(self.clear_history) + + self._export_btn = QPushButton("Export Explanations") + self._export_btn.clicked.connect(self._export_explanations) + + btn_layout.addWidget(self._clear_btn) + btn_layout.addWidget(self._export_btn) + btn_layout.addStretch() + layout.addLayout(btn_layout) + + def show_loading(self) -> None: + """Show the loading indicator.""" + self._progress_bar.show() + self._loading_label.show() + + def hide_loading(self) -> None: + """Hide the loading indicator.""" + self._progress_bar.hide() + self._loading_label.hide() + + def show_explanation( + self, query: str, response_text: str + ) -> List[HighlightSegment]: + """Display a Gemini response and return highlight segments. + + Args: + query: The user's original query/selection. + response_text: Raw response text from Gemini. + + Returns: + List of HighlightSegment objects found in the response. + """ + self.hide_loading() + html = response_to_html(response_text) + highlights = extract_highlights(response_text) + + entry = {"query": query, "html": html, "raw": response_text} + self._history.append(entry) + + self._render_history() + return highlights + + def show_error(self, message: str) -> None: + """Display an error message. + + Args: + message: Error message to display. + """ + self.hide_loading() + error_html = ( + f'
' + f"Error: {message}
" + ) + self._browser.setHtml(error_html + self._get_history_html()) + + def clear_history(self) -> None: + """Clear all explanation history.""" + self._history.clear() + self._browser.setHtml( + '

History cleared. Select text to get a new explanation.

' + ) + + def _render_history(self) -> None: + """Render the full conversation history.""" + html_parts = [] + for i, entry in enumerate(reversed(self._history)): + idx = len(self._history) - i + html_parts.append( + f'
' + f'
' + f"#{idx} — Query: {entry['query'][:80]}...
" + f"{entry['html']}
" + ) + self._browser.setHtml("".join(html_parts)) + # Scroll to top (most recent) + cursor = self._browser.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.Start) + self._browser.setTextCursor(cursor) + + def _get_history_html(self) -> str: + """Build HTML for all history entries.""" + parts = [] + for i, entry in enumerate(reversed(self._history)): + idx = len(self._history) - i + parts.append( + f'
' + f'
' + f"#{idx} — Query: {entry['query'][:80]}...
" + f"{entry['html']}
" + ) + return "".join(parts) + + def _on_anchor_clicked(self, url) -> None: + """Handle clicks on links in the explanation.""" + pass # External links are disabled + + def _export_explanations(self) -> None: + """Export all explanations to a Markdown file.""" + if not self._history: + return + + path, _ = QFileDialog.getSaveFileName( + self, + "Export Explanations", + "explanations.md", + "Markdown Files (*.md);;All Files (*)", + ) + if not path: + return + + lines = ["# Gemini PDF Reader — Exported Explanations\n\n"] + for i, entry in enumerate(self._history, 1): + lines.append(f"## Explanation #{i}\n\n") + lines.append(f"**Query:** {entry['query']}\n\n") + lines.append(f"{entry['raw']}\n\n---\n\n") + + with open(path, "w", encoding="utf-8") as f: + f.writelines(lines) diff --git a/gemini-pdf-reader/ui/main_window.py b/gemini-pdf-reader/ui/main_window.py new file mode 100644 index 0000000..143ca03 --- /dev/null +++ b/gemini-pdf-reader/ui/main_window.py @@ -0,0 +1,309 @@ +"""Main window layout for the Gemini PDF Reader.""" + +from typing import Optional + +from PyQt6.QtCore import Qt, QThread, pyqtSignal +from PyQt6.QtGui import QAction, QKeySequence +from PyQt6.QtWidgets import ( + QMainWindow, + QSplitter, + QFileDialog, + QInputDialog, + QLineEdit, + QMessageBox, + QStatusBar, + QApplication, +) + +from core.gemini_client import GeminiClient +from core.highlighter import extract_highlights +from core.pdf_processor import PDFDocument, chunk_text +from ui.pdf_viewer import PDFViewer +from ui.explanation_panel import ExplanationPanel +from utils.config import load_api_key, save_api_key +from utils.themes import LIGHT_THEME, DARK_THEME + + +class GeminiWorker(QThread): + """Background thread for Gemini API calls.""" + + finished = pyqtSignal(str, str) # (query, response) + error = pyqtSignal(str) + + def __init__( + self, + client: GeminiClient, + action: str, + context: str, + selected_text: str, + ) -> None: + super().__init__() + self._client = client + self._action = action + self._context = context + self._selected = selected_text + + def run(self) -> None: + """Execute the Gemini API call.""" + try: + if self._action == "explain": + result = self._client.explain(self._context, self._selected) + elif self._action == "summarize": + result = self._client.summarize(self._context, self._selected) + elif self._action == "define": + result = self._client.define(self._context, self._selected) + else: + result = self._client.explain(self._context, self._selected) + self.finished.emit(self._selected, result) + except Exception as exc: + self.error.emit(str(exc)) + + +class MainWindow(QMainWindow): + """Main application window with dual-pane layout.""" + + def __init__(self) -> None: + super().__init__() + self.setWindowTitle("Gemini PDF Reader") + self.setMinimumSize(1200, 700) + + self._gemini_client: Optional[GeminiClient] = None + self._pdf_doc: Optional[PDFDocument] = None + self._dark_mode = False + self._worker: Optional[GeminiWorker] = None + + self._setup_ui() + self._setup_menu() + self._setup_shortcuts() + self._setup_statusbar() + self._try_init_gemini() + + # Apply default theme + self.setStyleSheet(LIGHT_THEME) + + def _setup_ui(self) -> None: + """Build the main dual-pane layout.""" + splitter = QSplitter(Qt.Orientation.Horizontal) + + self._pdf_viewer = PDFViewer() + self._pdf_viewer.explain_requested.connect( + lambda text: self._ask_gemini("explain", text) + ) + self._pdf_viewer.summarize_requested.connect( + lambda text: self._ask_gemini("summarize", text) + ) + self._pdf_viewer.define_requested.connect( + lambda text: self._ask_gemini("define", text) + ) + + self._explanation_panel = ExplanationPanel() + + splitter.addWidget(self._pdf_viewer) + splitter.addWidget(self._explanation_panel) + splitter.setSizes([700, 500]) + + self.setCentralWidget(splitter) + + def _setup_menu(self) -> None: + """Create the menu bar.""" + menubar = self.menuBar() + + # File menu + file_menu = menubar.addMenu("File") + + open_action = QAction("Open PDF...", self) + open_action.setShortcut(QKeySequence("Ctrl+O")) + open_action.triggered.connect(self._open_pdf) + file_menu.addAction(open_action) + + file_menu.addSeparator() + + exit_action = QAction("Exit", self) + exit_action.setShortcut(QKeySequence("Ctrl+Q")) + exit_action.triggered.connect(self.close) + file_menu.addAction(exit_action) + + # View menu + view_menu = menubar.addMenu("View") + + dark_mode_action = QAction("Toggle Dark Mode", self) + dark_mode_action.setShortcut(QKeySequence("Ctrl+D")) + dark_mode_action.triggered.connect(self._toggle_dark_mode) + view_menu.addAction(dark_mode_action) + + # Help menu + help_menu = menubar.addMenu("Help") + + api_key_action = QAction("Set API Key...", self) + api_key_action.triggered.connect(self._set_api_key) + help_menu.addAction(api_key_action) + + about_action = QAction("About", self) + about_action.triggered.connect(self._show_about) + help_menu.addAction(about_action) + + def _setup_shortcuts(self) -> None: + """Register keyboard shortcuts.""" + explain_shortcut = QAction("Explain Selection", self) + explain_shortcut.setShortcut(QKeySequence("Ctrl+E")) + explain_shortcut.triggered.connect(self._explain_shortcut) + self.addAction(explain_shortcut) + + def _setup_statusbar(self) -> None: + """Create the status bar.""" + self._statusbar = QStatusBar() + self.setStatusBar(self._statusbar) + self._statusbar.showMessage("Ready") + + def _try_init_gemini(self) -> None: + """Attempt to initialize the Gemini client.""" + api_key = load_api_key() + if api_key: + try: + self._gemini_client = GeminiClient(api_key) + self._update_status() + except ValueError: + self._gemini_client = None + else: + self._gemini_client = None + self._update_status() + + def _open_pdf(self) -> None: + """Open a PDF file via file dialog.""" + path, _ = QFileDialog.getOpenFileName( + self, "Open PDF", "", "PDF Files (*.pdf);;All Files (*)" + ) + if not path: + return + + try: + self._pdf_doc = self._pdf_viewer.load_pdf(path) + if not self._pdf_doc.has_text: + QMessageBox.warning( + self, + "No Text Found", + "This PDF does not contain extractable text. " + "It may be a scanned/image PDF. Consider using OCR " + "software to extract text first.", + ) + self._update_status() + except Exception as exc: + QMessageBox.critical( + self, "Error", f"Failed to open PDF:\n{exc}" + ) + + def _ask_gemini(self, action: str, selected_text: str) -> None: + """Send a request to Gemini in a background thread. + + Args: + action: One of 'explain', 'summarize', 'define'. + selected_text: The user-selected text. + """ + if not selected_text.strip(): + self._explanation_panel.show_error("No text selected.") + return + + if not self._gemini_client: + self._explanation_panel.show_error( + "No Gemini API key configured. Use Help → Set API Key." + ) + return + + if not self._pdf_doc: + self._explanation_panel.show_error("No PDF loaded.") + return + + self._explanation_panel.show_loading() + self._statusbar.showMessage(f"Asking Gemini to {action}...") + + context = chunk_text( + self._pdf_doc, self._pdf_viewer.current_page + ) + + self._worker = GeminiWorker( + self._gemini_client, action, context, selected_text + ) + self._worker.finished.connect(self._on_gemini_response) + self._worker.error.connect(self._on_gemini_error) + self._worker.start() + + def _on_gemini_response(self, query: str, response: str) -> None: + """Handle a successful Gemini response. + + Args: + query: The original query text. + response: The Gemini response text. + """ + highlights = self._explanation_panel.show_explanation(query, response) + self._pdf_viewer.highlight_references(highlights) + self._statusbar.showMessage("Explanation received.", 5000) + + def _on_gemini_error(self, message: str) -> None: + """Handle a Gemini API error. + + Args: + message: Error message. + """ + self._explanation_panel.show_error(message) + self._statusbar.showMessage("Error getting explanation.", 5000) + + def _explain_shortcut(self) -> None: + """Handle Ctrl+E shortcut — explain selected text.""" + # Try to get selected text from the PDF viewer + label = self._pdf_viewer._page_label + text = label._get_selected_text() + if text: + self._ask_gemini("explain", text) + + def _toggle_dark_mode(self) -> None: + """Toggle between dark and light themes.""" + self._dark_mode = not self._dark_mode + app = QApplication.instance() + if app: + if self._dark_mode: + self.setStyleSheet(DARK_THEME) + else: + self.setStyleSheet(LIGHT_THEME) + + def _set_api_key(self) -> None: + """Prompt the user to enter their Gemini API key.""" + key, ok = QInputDialog.getText( + self, + "Set Gemini API Key", + "Enter your Google Gemini API key:", + QLineEdit.EchoMode.Password, + ) + if ok and key.strip(): + save_api_key(key.strip()) + try: + self._gemini_client = GeminiClient(key.strip()) + QMessageBox.information( + self, "Success", "API key saved and validated." + ) + except ValueError as exc: + QMessageBox.warning(self, "Error", str(exc)) + self._update_status() + + def _show_about(self) -> None: + """Show the about dialog.""" + QMessageBox.about( + self, + "About Gemini PDF Reader", + "Gemini PDF Reader v1.0\n\n" + "A dual-pane PDF reader with Google Gemini Pro\n" + "contextual explanations.\n\n" + "Built with PyQt6, PyMuPDF, and Google Generative AI.", + ) + + def _update_status(self) -> None: + """Update the status bar with current state.""" + parts = ["Ready"] + if self._pdf_doc: + parts.append(f"PDF: {self._pdf_doc.path.split('/')[-1]}") + else: + parts.append("No PDF loaded") + if self._gemini_client: + parts.append("Gemini: ✓") + else: + parts.append("Gemini: ✗ (no API key)") + self._statusbar.showMessage(" | ".join(parts)) diff --git a/gemini-pdf-reader/ui/pdf_viewer.py b/gemini-pdf-reader/ui/pdf_viewer.py new file mode 100644 index 0000000..d6ccbd3 --- /dev/null +++ b/gemini-pdf-reader/ui/pdf_viewer.py @@ -0,0 +1,411 @@ +"""PDF rendering widget with text selection and highlighting.""" + +from typing import List, Optional, Tuple + +from PyQt6.QtCore import Qt, QPoint, QRect, pyqtSignal +from PyQt6.QtGui import ( + QImage, + QPixmap, + QPainter, + QColor, + QMouseEvent, + QAction, + QKeySequence, +) +from PyQt6.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QLabel, + QScrollArea, + QPushButton, + QSpinBox, + QMenu, +) + +from core import pdf_processor + + +class PDFPageLabel(QLabel): + """Label widget that displays a rendered PDF page and supports selection.""" + + text_selected = pyqtSignal(str) # Emitted with selected text + explain_requested = pyqtSignal(str) + summarize_requested = pyqtSignal(str) + define_requested = pyqtSignal(str) + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft) + self._selection_start: Optional[QPoint] = None + self._selection_end: Optional[QPoint] = None + self._selecting = False + self._highlight_rects: List[Tuple[float, float, float, float]] = [] + self._zoom = 2.0 + self._base_pixmap: Optional[QPixmap] = None + + def set_page_image(self, png_data: bytes, zoom: float = 2.0) -> None: + """Load a page image from PNG data. + + Args: + png_data: PNG image bytes. + zoom: The zoom factor used to render the page. + """ + self._zoom = zoom + image = QImage.fromData(png_data) + self._base_pixmap = QPixmap.fromImage(image) + self._highlight_rects = [] + self._selection_start = None + self._selection_end = None + self._repaint() + + def set_highlight_rects( + self, rects: List[Tuple[float, float, float, float]] + ) -> None: + """Set rectangles to highlight on the page. + + Args: + rects: List of (x0, y0, x1, y1) in PDF coordinates. + """ + self._highlight_rects = rects + self._repaint() + + def clear_highlights(self) -> None: + """Remove all highlight overlays.""" + self._highlight_rects = [] + self._repaint() + + def _repaint(self) -> None: + """Redraw the page with highlights and selection overlay.""" + if self._base_pixmap is None: + return + + pixmap = self._base_pixmap.copy() + painter = QPainter(pixmap) + + # Draw highlight rectangles (from Gemini references) + highlight_color = QColor(255, 235, 59, 80) # Semi-transparent yellow + painter.setBrush(highlight_color) + painter.setPen(Qt.PenStyle.NoPen) + for x0, y0, x1, y1 in self._highlight_rects: + rect = QRect( + int(x0 * self._zoom), + int(y0 * self._zoom), + int((x1 - x0) * self._zoom), + int((y1 - y0) * self._zoom), + ) + painter.drawRect(rect) + + # Draw selection rectangle + if self._selection_start and self._selection_end: + sel_color = QColor(66, 133, 244, 60) # Semi-transparent blue + painter.setBrush(sel_color) + painter.setPen(QColor(66, 133, 244, 150)) + rect = QRect(self._selection_start, self._selection_end).normalized() + painter.drawRect(rect) + + painter.end() + self.setPixmap(pixmap) + + # --- Mouse events for text selection --- + + def mousePressEvent(self, event: QMouseEvent) -> None: # type: ignore[override] + """Start selection on left click.""" + if event.button() == Qt.MouseButton.LeftButton: + self._selecting = True + self._selection_start = event.pos() + self._selection_end = event.pos() + self._repaint() + + def mouseMoveEvent(self, event: QMouseEvent) -> None: # type: ignore[override] + """Update selection rectangle while dragging.""" + if self._selecting: + self._selection_end = event.pos() + self._repaint() + + def mouseReleaseEvent(self, event: QMouseEvent) -> None: # type: ignore[override] + """Finish selection and extract text.""" + if event.button() == Qt.MouseButton.LeftButton and self._selecting: + self._selecting = False + self._selection_end = event.pos() + self._repaint() + + def contextMenuEvent(self, event): # type: ignore[override] + """Show context menu with Gemini actions.""" + selected = self._get_selected_text() + if not selected: + return + + menu = QMenu(self) + explain_action = QAction("Explain with Gemini", self) + explain_action.setShortcut(QKeySequence("Ctrl+E")) + explain_action.triggered.connect(lambda: self.explain_requested.emit(selected)) + + summarize_action = QAction("Summarize Selection", self) + summarize_action.triggered.connect( + lambda: self.summarize_requested.emit(selected) + ) + + define_action = QAction("Define Term", self) + define_action.triggered.connect(lambda: self.define_requested.emit(selected)) + + menu.addAction(explain_action) + menu.addAction(summarize_action) + menu.addAction(define_action) + menu.exec(event.globalPos()) + + def _get_selected_text(self) -> str: + """Retrieve text within the selection rectangle from the parent viewer.""" + parent = self.parent() + while parent and not isinstance(parent, PDFViewer): + parent = parent.parent() + if parent and isinstance(parent, PDFViewer): + return parent.get_selected_text( + self._selection_start, self._selection_end + ) + return "" + + +class PDFViewer(QWidget): + """PDF viewer widget with page navigation, zoom, and text selection.""" + + explain_requested = pyqtSignal(str) + summarize_requested = pyqtSignal(str) + define_requested = pyqtSignal(str) + page_changed = pyqtSignal(int) + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self._pdf_path: Optional[str] = None + self._total_pages = 0 + self._current_page = 0 + self._zoom = 2.0 + self._pdf_doc: Optional[pdf_processor.PDFDocument] = None + + self._setup_ui() + + def _setup_ui(self) -> None: + """Build the viewer layout.""" + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + # Page display area + self._scroll_area = QScrollArea() + self._page_label = PDFPageLabel() + self._page_label.explain_requested.connect(self.explain_requested) + self._page_label.summarize_requested.connect(self.summarize_requested) + self._page_label.define_requested.connect(self.define_requested) + self._scroll_area.setWidget(self._page_label) + self._scroll_area.setWidgetResizable(False) + layout.addWidget(self._scroll_area) + + # Navigation bar + nav_layout = QHBoxLayout() + self._prev_btn = QPushButton("<") + self._prev_btn.setFixedWidth(30) + self._prev_btn.clicked.connect(self._prev_page) + + self._page_spin = QSpinBox() + self._page_spin.setMinimum(1) + self._page_spin.setPrefix("Page: ") + self._page_spin.valueChanged.connect(self._on_page_spin_changed) + + self._page_count_label = QLabel("of 0") + + self._next_btn = QPushButton(">") + self._next_btn.setFixedWidth(30) + self._next_btn.clicked.connect(self._next_page) + + self._zoom_in_btn = QPushButton("+") + self._zoom_in_btn.setFixedWidth(30) + self._zoom_in_btn.clicked.connect(self._zoom_in) + + self._zoom_out_btn = QPushButton("−") + self._zoom_out_btn.setFixedWidth(30) + self._zoom_out_btn.clicked.connect(self._zoom_out) + + self._zoom_label = QLabel("100%") + + nav_layout.addWidget(self._prev_btn) + nav_layout.addWidget(self._page_spin) + nav_layout.addWidget(self._page_count_label) + nav_layout.addWidget(self._next_btn) + nav_layout.addStretch() + nav_layout.addWidget(self._zoom_out_btn) + nav_layout.addWidget(self._zoom_label) + nav_layout.addWidget(self._zoom_in_btn) + + layout.addLayout(nav_layout) + + def load_pdf(self, pdf_path: str) -> pdf_processor.PDFDocument: + """Load and display a PDF file. + + Args: + pdf_path: Path to the PDF file. + + Returns: + The extracted PDFDocument. + + Raises: + Exception: On load failure. + """ + self._pdf_path = pdf_path + self._pdf_doc = pdf_processor.extract_text(pdf_path) + self._total_pages = self._pdf_doc.total_pages + self._current_page = 0 + self._page_spin.setMaximum(self._total_pages) + self._page_spin.setValue(1) + self._page_count_label.setText(f"of {self._total_pages}") + self._render_current_page() + return self._pdf_doc + + def _render_current_page(self) -> None: + """Render and display the current page.""" + if not self._pdf_path: + return + png_data = pdf_processor.render_page( + self._pdf_path, self._current_page, self._zoom + ) + self._page_label.set_page_image(png_data, self._zoom) + + def get_selected_text( + self, + start: Optional[QPoint], + end: Optional[QPoint], + ) -> str: + """Extract text within the selection rectangle. + + Maps pixel coordinates back to PDF coordinates and extracts text. + + Args: + start: Selection start point in widget coordinates. + end: Selection end point in widget coordinates. + + Returns: + The selected text string, or empty string if no selection. + """ + if not start or not end or not self._pdf_path: + return "" + + import fitz + + doc = fitz.open(self._pdf_path) + page = doc[self._current_page] + + # Convert widget coordinates to PDF coordinates + rect = QRect(start, end).normalized() + pdf_rect = fitz.Rect( + rect.x() / self._zoom, + rect.y() / self._zoom, + (rect.x() + rect.width()) / self._zoom, + (rect.y() + rect.height()) / self._zoom, + ) + text = page.get_text("text", clip=pdf_rect).strip() + doc.close() + return text + + def highlight_text_on_page( + self, page_num: int, text: str + ) -> None: + """Highlight matching text on a specific page. + + Args: + page_num: Zero-based page index. + text: Text to highlight. + """ + if not self._pdf_path: + return + + if page_num != self._current_page: + self.go_to_page(page_num) + + rects = pdf_processor.search_text_rects(self._pdf_path, page_num, text) + if rects: + self._page_label.set_highlight_rects(rects) + + def highlight_references(self, highlights: list) -> None: + """Highlight all Gemini references on the current page. + + Args: + highlights: List of HighlightSegment objects. + """ + if not self._pdf_path: + return + + all_rects: List[Tuple[float, float, float, float]] = [] + for seg in highlights: + rects = pdf_processor.search_text_rects( + self._pdf_path, self._current_page, seg.text + ) + all_rects.extend(rects) + self._page_label.set_highlight_rects(all_rects) + + def go_to_page(self, page_num: int) -> None: + """Navigate to a specific page. + + Args: + page_num: Zero-based page index. + """ + if 0 <= page_num < self._total_pages: + self._current_page = page_num + self._page_spin.blockSignals(True) + self._page_spin.setValue(page_num + 1) + self._page_spin.blockSignals(False) + self._render_current_page() + self.page_changed.emit(page_num) + + def scroll_to_highlight(self, text: str) -> None: + """Find and scroll to highlighted text across all pages. + + Args: + text: The text to find and scroll to. + """ + if not self._pdf_path: + return + + for page_num in range(self._total_pages): + rects = pdf_processor.search_text_rects( + self._pdf_path, page_num, text + ) + if rects: + self.go_to_page(page_num) + self._page_label.set_highlight_rects(rects) + # Scroll to first match + x0, y0, _, _ = rects[0] + self._scroll_area.ensureVisible( + int(x0 * self._zoom), + int(y0 * self._zoom), + 50, 50, + ) + return + + @property + def current_page(self) -> int: + """Return the current zero-based page index.""" + return self._current_page + + @property + def pdf_document(self) -> Optional[pdf_processor.PDFDocument]: + """Return the loaded PDF document.""" + return self._pdf_doc + + def _prev_page(self) -> None: + if self._current_page > 0: + self.go_to_page(self._current_page - 1) + + def _next_page(self) -> None: + if self._current_page < self._total_pages - 1: + self.go_to_page(self._current_page + 1) + + def _on_page_spin_changed(self, value: int) -> None: + self.go_to_page(value - 1) + + def _zoom_in(self) -> None: + self._zoom = min(self._zoom + 0.25, 5.0) + self._zoom_label.setText(f"{int(self._zoom / 2.0 * 100)}%") + self._render_current_page() + + def _zoom_out(self) -> None: + self._zoom = max(self._zoom - 0.25, 0.5) + self._zoom_label.setText(f"{int(self._zoom / 2.0 * 100)}%") + self._render_current_page() diff --git a/gemini-pdf-reader/utils/__init__.py b/gemini-pdf-reader/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gemini-pdf-reader/utils/config.py b/gemini-pdf-reader/utils/config.py new file mode 100644 index 0000000..d5587e7 --- /dev/null +++ b/gemini-pdf-reader/utils/config.py @@ -0,0 +1,52 @@ +"""API key management and application configuration.""" + +import os +from pathlib import Path +from typing import Optional + +from dotenv import load_dotenv + + +CONFIG_DIR = Path.home() / ".gemini-pdf-reader" +CONFIG_FILE = CONFIG_DIR / "config.env" + + +def _ensure_config_dir() -> None: + """Create the config directory if it doesn't exist.""" + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + + +def load_api_key() -> Optional[str]: + """Load the Gemini API key from environment or config file. + + Checks in order: + 1. GEMINI_API_KEY environment variable + 2. .env file in current directory + 3. User config file (~/.gemini-pdf-reader/config.env) + + Returns: + The API key string, or None if not found. + """ + load_dotenv() + key = os.getenv("GEMINI_API_KEY") + if key and key != "your_key_here": + return key + + if CONFIG_FILE.exists(): + load_dotenv(CONFIG_FILE) + key = os.getenv("GEMINI_API_KEY") + if key and key != "your_key_here": + return key + + return None + + +def save_api_key(api_key: str) -> None: + """Save the API key to the user config file. + + Args: + api_key: The Gemini API key to save. + """ + _ensure_config_dir() + CONFIG_FILE.write_text(f"GEMINI_API_KEY={api_key}\n") + os.environ["GEMINI_API_KEY"] = api_key diff --git a/gemini-pdf-reader/utils/themes.py b/gemini-pdf-reader/utils/themes.py new file mode 100644 index 0000000..32b5d8f --- /dev/null +++ b/gemini-pdf-reader/utils/themes.py @@ -0,0 +1,131 @@ +"""Dark and light theme definitions for the application.""" + +LIGHT_THEME = """ +QMainWindow, QWidget { + background-color: #f5f5f5; + color: #333333; +} +QMenuBar { + background-color: #e0e0e0; + color: #333333; +} +QMenuBar::item:selected { + background-color: #c0c0c0; +} +QMenu { + background-color: #ffffff; + color: #333333; + border: 1px solid #cccccc; +} +QMenu::item:selected { + background-color: #e0e0e0; +} +QSplitter::handle { + background-color: #cccccc; +} +QScrollArea { + border: 1px solid #cccccc; + background-color: #ffffff; +} +QTextBrowser { + background-color: #ffffff; + color: #333333; + border: 1px solid #cccccc; + padding: 8px; +} +QPushButton { + background-color: #e0e0e0; + color: #333333; + border: 1px solid #bbbbbb; + padding: 5px 15px; + border-radius: 3px; +} +QPushButton:hover { + background-color: #d0d0d0; +} +QStatusBar { + background-color: #e0e0e0; + color: #333333; +} +QLabel { + color: #333333; +} +QLineEdit { + background-color: #ffffff; + color: #333333; + border: 1px solid #cccccc; + padding: 4px; + border-radius: 3px; +} +QSpinBox { + background-color: #ffffff; + color: #333333; + border: 1px solid #cccccc; + padding: 2px; +} +""" + +DARK_THEME = """ +QMainWindow, QWidget { + background-color: #2b2b2b; + color: #e0e0e0; +} +QMenuBar { + background-color: #3c3c3c; + color: #e0e0e0; +} +QMenuBar::item:selected { + background-color: #505050; +} +QMenu { + background-color: #3c3c3c; + color: #e0e0e0; + border: 1px solid #555555; +} +QMenu::item:selected { + background-color: #505050; +} +QSplitter::handle { + background-color: #555555; +} +QScrollArea { + border: 1px solid #555555; + background-color: #1e1e1e; +} +QTextBrowser { + background-color: #1e1e1e; + color: #e0e0e0; + border: 1px solid #555555; + padding: 8px; +} +QPushButton { + background-color: #3c3c3c; + color: #e0e0e0; + border: 1px solid #555555; + padding: 5px 15px; + border-radius: 3px; +} +QPushButton:hover { + background-color: #505050; +} +QStatusBar { + background-color: #3c3c3c; + color: #e0e0e0; +} +QLabel { + color: #e0e0e0; +} +QLineEdit { + background-color: #1e1e1e; + color: #e0e0e0; + border: 1px solid #555555; + padding: 4px; + border-radius: 3px; +} +QSpinBox { + background-color: #1e1e1e; + color: #e0e0e0; + border: 1px solid #555555; + padding: 2px; +} +"""