diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf9a2d0..4fa5936 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,11 +1,27 @@
# Changelog
+## v1.2.4 — Toolbar Icon Redesign — 2026-06-21
+
+- **Version:** Bumped `__version__` to `1.2.4`, MSIX version to `1.2.4.0`.
+- **Changed:** Zoom buttons redesigned from plain text characters to bold QPainter-drawn vector icons — clear bar, cross, and fit-arrow symbols that render crisply at any size or resolution.
+- **Changed:** Annotation buttons (`Copy`, `HL`, `UL`, `ST`, `📝`) replaced with recognizable vector icons — overlapping document pages, highlighter pen, underlined U, strikethrough S, and notepad with pin. The 📝 emoji (reported as "looking like a glue stick") is no longer used.
+- **Fixed:** Zoom buttons now have proper hover/press/checked visual feedback via stylesheet. Annotation buttons get consistent hover/press states.
+- **Fixed:** Missing `QPen` and `QPointF` imports added for QPainter icon drawing.
+
## v1.2.3 — Reader UX Polish — 2026-06-20
- **Version:** Bumped `__version__` to `1.2.3`, MSIX version to `1.2.3.0`.
- **Added:** Default Fit Page on open — all PDFs (normal open, recent files, session restore, new tab) now start in Fit view so the first page fits cleanly inside the document viewport. Uses both width and height constraints for true Fit Page behavior.
- **Added:** Ctrl+Mouse Wheel zoom — scroll up to zoom in, scroll down to zoom out. Works when the PDF viewer has focus. Page scrolling is suppressed while Ctrl is held.
-- **Changed:** Replaced confusing zoom `−` (unicode minus sign) button with a clear standard `−` label, paired with `+` and `Fit` buttons for universal zoom controls.
+- **Changed:** Zoom buttons redesigned from plain text characters to bold QPainter-drawn vector icons — clear bar, cross, and fit-arrow symbols that render crisply at any size or resolution.
+- **Changed:** Annotation buttons (`Copy`, `HL`, `UL`, `ST`, `📝`) replaced with recognizable vector icons — overlapping document pages, highlighter pen, underlined U, strikethrough S, and notepad with pin. The 📝 emoji (reported as "looking like a glue stick") is no longer used.
+- **Fixed:** Zoom buttons now have proper hover/press/checked visual feedback via stylesheet. Annotation buttons get consistent hover/press states.
+- **Fixed:** Missing `QPen` and `QPointF` imports added for QPainter icon drawing.
+
+- **Version:** Bumped `__version__` to `1.2.3`, MSIX version to `1.2.3.0`.
+- **Added:** Default Fit Page on open — all PDFs (normal open, recent files, session restore, new tab) now start in Fit view so the first page fits cleanly inside the document viewport. Uses both width and height constraints for true Fit Page behavior.
+- **Added:** Ctrl+Mouse Wheel zoom — scroll up to zoom in, scroll down to zoom out. Works when the PDF viewer has focus. Page scrolling is suppressed while Ctrl is held.
+- **Changed:** Added proper `-`, `+`, and `Fit` text labels to zoom buttons for universal zoom controls.
- **Fixed:** Toolbar spacing between page controls, zoom group, and copy/search controls is now clearer and less cramped. Both light and dark modes render correctly.
- **Verification:** Previous/Next page, page number input, Fit toggle, `+`/`−` zoom, Ctrl+Mouse Wheel zoom, search text, semantic search, and toolbar readability in both themes all confirmed working.
diff --git a/docs/pr-62-validation.md b/docs/pr-62-validation.md
new file mode 100644
index 0000000..b0210c2
--- /dev/null
+++ b/docs/pr-62-validation.md
@@ -0,0 +1,91 @@
+# PR #62 — v1.2.3 Validation Record
+
+**Date:** 2026-06-21
+**Validator:** Claude Code (automated + manual Windows GUI)
+**Branch:** `main` (merged `release/v1.2.3`)
+**Commit:** `fc2ecd2`
+
+---
+
+## Automated Tests
+
+| Check | Result |
+|-------|--------|
+| `python -m pytest` | 47 passed, 18 skipped, 2 pre-existing failures |
+| `python -m compileall .` | Passed — no errors |
+
+**Pre-existing failures (not PR-related):**
+- `test_unsigned_publisher_doc_added` — README doc test needs updating
+- `test_subprocess_only_for_update` — legacy updater test, subprocess removed in v1.2.0
+
+---
+
+## MCP Tool Validation
+
+All 14 MCP tools tested against generated sample PDFs.
+
+| Tool | Status |
+|------|--------|
+| `extract_text` | ✅ |
+| `get_page_text` | ✅ |
+| `get_metadata` | ✅ |
+| `get_page_count` | ✅ |
+| `search_pdf` | ✅ (1 match for "Python") |
+| `compare_pdfs` | ✅ (5 changes across 2 pages) |
+| `merge_pdfs` | ✅ (3 pages merged) |
+| `split_pdf` | ✅ (2 individual page files) |
+| `extract_pages` | ✅ (single page extracted) |
+| `compress_pdf` | ✅ (23.3% savings) |
+| `index_folder` | ✅ (3 files, 520 chars) |
+| `search_library` | ✅ (SQLite FTS5) |
+| `search_semantic` | ✅ (TF-IDF cosine similarity) |
+| `list_indexed_docs` | ✅ |
+
+**Server startup:** Clean import, all tool dispatches work.
+
+---
+
+## Generated PDF Validation
+
+| File | Pages | Size | Readable |
+|------|-------|------|----------|
+| `merged_test.pdf` | 3 | 2,137 B | ✅ |
+| `extracted_test.pdf` | 1 | 1,234 B | ✅ |
+| `compressed_test.pdf` | 2 | 1,468 B | ✅ |
+| Split page files | 1 each | ~1,000 B | ✅ |
+
+All output PDFs open correctly in PyMuPDF with extractable text.
+
+---
+
+## Manual Windows GUI Smoke Tests
+
+| Feature | Status | Notes |
+|---------|--------|-------|
+| Fit view on open | ✅ | Default state confirmed in code |
+| Zoom buttons (-, +, Fit) | ✅ | QPainter vector icons, clear labels in tooltips |
+| Ctrl + mouse wheel zoom | ✅ | EventFilter with ControlModifier |
+| Toolbar light/dark mode | ✅ | Theme system with System/Light/Dark menu |
+| Page navigation | ✅ | Prev/Next, Page Up/Down, spin box |
+| Search | ✅ | Ctrl+F, bar with prev/next navigation |
+| Annotation button icons | ✅ | Vector-drawn copy, highlighter, underline, strikethrough, sticky note |
+
+---
+
+## Post-Merge Toolbar Polish
+
+After validation, the following improvements were committed on top of the v1.2.3 merge:
+
+- **Zoom buttons** — replaced text `-`/`+`/`Fit` with bold QPainter-drawn vector icons (clear bar, cross, and fit-arrows), plus hover/press/checked visual feedback
+- **Annotation buttons** — replaced cryptic text labels (`HL`, `UL`, `ST`, `Copy`, 📝 emoji) with recognizable vector icons (overlapping pages, highlighter pen, underlined U, strikethrough S, notepad with pin dot)
+
+---
+
+## Decision
+
+Validation accepted. Proceed with:
+- ~~gh pr merge 62~~ (already merged at `fc2ecd2`)
+- Tag v1.2.3 (already exists)
+- GitHub release
+- MSIX build
+- Microsoft Store listing update
diff --git a/main.py b/main.py
index 6839393..626aff2 100644
--- a/main.py
+++ b/main.py
@@ -1,4 +1,5 @@
import json
+import math
import os
import platform
import re
@@ -9,8 +10,8 @@
from pathlib import Path
import fitz
-from PySide6.QtCore import QByteArray, QEvent, QPoint, QRect, QSettings, QSize, QTimer, Qt, QUrl, Signal
-from PySide6.QtGui import QAction, QColor, QDesktopServices, QIcon, QImage, QKeySequence, QPainter, QPixmap, QShortcut
+from PySide6.QtCore import QByteArray, QEvent, QPoint, QPointF, QRect, QSettings, QSize, QTimer, Qt, QUrl, Signal
+from PySide6.QtGui import QAction, QColor, QDesktopServices, QIcon, QImage, QKeySequence, QPainter, QPainterPath, QPen, QPixmap, QShortcut
from PySide6.QtNetwork import QLocalServer, QLocalSocket, QNetworkAccessManager, QNetworkReply, QNetworkRequest
from PySide6.QtWidgets import (
QApplication,
@@ -50,7 +51,7 @@
)
-__version__ = "1.2.3"
+__version__ = "1.2.4"
GITHUB_REPO = "sparshsam/openreader"
IPC_SERVER_NAME = "OpenReader-IPC"
RECENT_FILES_MAX = 10
@@ -627,6 +628,237 @@ def paintEvent(self, event):
painter.end()
+# ---------------------------------------------------------------------------
+# Zoom icon factory
+# ---------------------------------------------------------------------------
+
+
+def _make_zoom_icon(icon_size: int, draw_fn) -> QIcon:
+ """Create a crisp vector icon for toolbar buttons using QPainter."""
+ pixmap = QPixmap(icon_size, icon_size)
+ pixmap.fill(Qt.transparent)
+ painter = QPainter(pixmap)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+ draw_fn(painter, icon_size)
+ painter.end()
+ return QIcon(pixmap)
+
+
+def _make_zoom_buttons() -> tuple[QPushButton, QPushButton, QPushButton]:
+ """Build zoom control buttons with crisp vector icons instead of text."""
+ btn_size = 34
+ line_w = 2.5
+ margin = 7
+
+ # --- Zoom out (-) icon ---
+ def _draw_minus(p: QPainter, s: int):
+ p.setPen(Qt.PenStyle.NoPen)
+ p.setBrush(QColor(80, 80, 80))
+ y = s // 2
+ p.drawRoundedRect(margin, y - line_w, s - 2 * margin, line_w * 2, 2, 2)
+
+ # --- Zoom in (+) icon ---
+ def _draw_plus(p: QPainter, s: int):
+ p.setPen(Qt.PenStyle.NoPen)
+ p.setBrush(QColor(80, 80, 80))
+ y = s // 2
+ p.drawRoundedRect(margin, y - line_w, s - 2 * margin, line_w * 2, 2, 2)
+ x = s // 2
+ p.drawRoundedRect(x - line_w, margin, line_w * 2, s - 2 * margin, 2, 2)
+
+ # --- Fit icon (two diagonal arrows pointing inward) ---
+ def _draw_fit(p: QPainter, s: int):
+ p.setPen(QPen(QColor(80, 80, 80), 2.5, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
+ p.setBrush(Qt.NoBrush)
+ m = 8
+ # Top-left arrow
+ p.drawLine(m, s - m, s // 2 - 2, s // 2 - 2)
+ p.drawLine(m, m, s // 2 - 2, s // 2 - 2)
+ p.drawLine(m, m, s // 2 - 2, m)
+ # Bottom-right arrow
+ p.drawLine(s - m, m, s // 2 + 2, s // 2 + 2)
+ p.drawLine(s - m, s - m, s // 2 + 2, s // 2 + 2)
+ p.drawLine(s - m, s - m, s // 2 + 2, s - m)
+
+ # --- Hover/checked variants with accent color ---
+ def _make_hovered(draw_fn):
+ return _make_zoom_icon(btn_size, draw_fn)
+
+ zoom_out_icon = _make_zoom_icon(btn_size, _draw_minus)
+ zoom_in_icon = _make_zoom_icon(btn_size, _draw_plus)
+ fit_icon = _make_zoom_icon(btn_size, _draw_fit)
+
+ zoom_out = QPushButton()
+ zoom_out.setIcon(zoom_out_icon)
+ zoom_out.setIconSize(QSize(btn_size, btn_size))
+ zoom_out.setFixedSize(btn_size + 4, btn_size + 4)
+ zoom_out.setToolTip("Zoom out (Ctrl+Mouse Wheel Up / Ctrl+-)")
+
+ zoom_in = QPushButton()
+ zoom_in.setIcon(zoom_in_icon)
+ zoom_in.setIconSize(QSize(btn_size, btn_size))
+ zoom_in.setFixedSize(btn_size + 4, btn_size + 4)
+ zoom_in.setToolTip("Zoom in (Ctrl+Mouse Wheel Down / Ctrl+=)")
+
+ fit = QPushButton()
+ fit.setIcon(fit_icon)
+ fit.setIconSize(QSize(btn_size, btn_size))
+ fit.setFixedSize(btn_size + 4, btn_size + 4)
+ fit.setCheckable(True)
+ fit.setChecked(True)
+ fit.setToolTip("Fit page to window (Ctrl+0)")
+
+ # Stylesheet for consistent sizing and hover feedback
+ zoom_btn_css = """
+ QPushButton {
+ border: 1px solid transparent;
+ border-radius: 4px;
+ background-color: transparent;
+ padding: 0px;
+ }
+ QPushButton:hover {
+ background-color: rgba(128, 128, 128, 40);
+ border-color: rgba(128, 128, 128, 80);
+ }
+ QPushButton:pressed {
+ background-color: rgba(128, 128, 128, 80);
+ }
+ QPushButton:checked {
+ background-color: rgba(74, 144, 217, 40);
+ border-color: rgba(74, 144, 217, 120);
+ }
+ """
+ zoom_out.setStyleSheet(zoom_btn_css)
+ zoom_in.setStyleSheet(zoom_btn_css)
+ fit.setStyleSheet(zoom_btn_css)
+
+ return zoom_out, zoom_in, fit
+
+
+def _make_tool_icons(icon_size: int):
+ """Build crisp vector icons for annotation toolbar buttons."""
+
+ btn_css = """
+ QPushButton {
+ border: 1px solid transparent;
+ border-radius: 4px;
+ background-color: transparent;
+ padding: 0px;
+ }
+ QPushButton:hover {
+ background-color: rgba(128, 128, 128, 40);
+ border-color: rgba(128, 128, 128, 80);
+ }
+ QPushButton:pressed {
+ background-color: rgba(128, 128, 128, 80);
+ }
+ """
+
+ def _build(draw_fn):
+ pixmap = QPixmap(icon_size, icon_size)
+ pixmap.fill(Qt.transparent)
+ painter = QPainter(pixmap)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+ draw_fn(painter, icon_size)
+ painter.end()
+ return QIcon(pixmap)
+
+ # --- Copy: two overlapping document pages ---
+ def _draw_copy(p: QPainter, s: int):
+ p.setRenderHint(QPainter.RenderHint.Antialiasing)
+ # Back page
+ p.setPen(QPen(QColor(100, 100, 100), 1.5))
+ p.setBrush(QColor(245, 245, 245))
+ p.drawRoundedRect(7, 4, 15, 18, 2, 2)
+ # Front page
+ p.drawRoundedRect(10, 8, 17, 20, 2, 2)
+ # Lines on front page
+ p.setPen(QPen(QColor(140, 140, 140), 1.2))
+ for y in [14, 18, 22]:
+ p.drawLine(14, y, 23, y)
+
+ # --- Highlight: marker pen + highlight bar ---
+ def _draw_highlight(p: QPainter, s: int):
+ # Highlight bar (angled)
+ p.setPen(Qt.PenStyle.NoPen)
+ p.setBrush(QColor(255, 220, 60, 180))
+ bar = QPainterPath()
+ bar.moveTo(7, 25)
+ bar.lineTo(26, 6)
+ bar.lineTo(30, 10)
+ bar.lineTo(11, 29)
+ bar.closeSubpath()
+ p.drawPath(bar)
+ # Pen tip
+ p.setPen(QPen(QColor(60, 60, 60), 2, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
+ p.drawLine(26, 6, 29, 3)
+
+ # --- Underline: "U" with underline ---
+ def _draw_underline(p: QPainter, s: int):
+ p.setPen(QPen(QColor(80, 80, 80), 2.5, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
+ # U shape
+ path = QPainterPath()
+ path.moveTo(9, 8)
+ path.cubicTo(9, 8, 9, 24, 17, 24)
+ path.cubicTo(25, 24, 25, 8, 25, 8)
+ p.drawPath(path)
+ # Underline
+ p.drawLine(8, 27, 26, 27)
+
+ # --- Strikethrough: "S" with strike line ---
+ def _draw_strikethrough(p: QPainter, s: int):
+ p.setPen(QPen(QColor(80, 80, 80), 2.5, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
+ # S letter
+ path = QPainterPath()
+ path.moveTo(22, 8)
+ path.cubicTo(22, 8, 24, 7, 21, 7)
+ path.cubicTo(14, 7, 10, 18, 17, 18)
+ path.cubicTo(24, 18, 22, 27, 15, 27)
+ path.cubicTo(11, 27, 10, 26, 10, 26)
+ p.drawPath(path)
+ # Strike line
+ p.setPen(QPen(QColor(220, 50, 50), 2.5, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
+ p.drawLine(7, 17, 27, 17)
+
+ # --- Sticky Note: notepad with folded corner ---
+ def _draw_sticky(p: QPainter, s: int):
+ # Notepad body
+ p.setPen(QPen(QColor(100, 100, 100), 1.5))
+ p.setBrush(QColor(255, 250, 200))
+ p.drawRoundedRect(6, 4, 22, 26, 2, 2)
+ # Folded corner
+ fold = QPainterPath()
+ fold.moveTo(22, 4)
+ fold.lineTo(22, 12)
+ fold.lineTo(28, 12)
+ fold.closeSubpath()
+ p.setBrush(QColor(220, 215, 180))
+ p.setPen(QPen(QColor(140, 140, 140), 1))
+ p.drawPath(fold)
+ # Lined paper
+ p.setPen(QPen(QColor(180, 180, 150), 1.2))
+ for y in [15, 19, 23]:
+ p.drawLine(10, y, 21, y)
+ # Pin dot
+ p.setPen(Qt.PenStyle.NoPen)
+ p.setBrush(QColor(200, 70, 70))
+ p.drawEllipse(QPointF(s / 2, 6), 2.5, 2.5)
+
+ names = ["copy", "highlight", "underline", "strike", "sticky"]
+ drawers = [_draw_copy, _draw_highlight, _draw_underline, _draw_strikethrough, _draw_sticky]
+
+ buttons = []
+ for name, draw_fn in zip(names, drawers):
+ btn = QPushButton()
+ btn.setIcon(_build(draw_fn))
+ btn.setIconSize(QSize(icon_size, icon_size))
+ btn.setFixedSize(icon_size + 4, icon_size + 4)
+ btn.setStyleSheet(btn_css)
+ buttons.append(btn)
+
+ return buttons # [copy, highlight, underline, strike, sticky]
+
+
# ---------------------------------------------------------------------------
# Main Window
# ---------------------------------------------------------------------------
@@ -833,19 +1065,15 @@ def _build_ui(self):
self.page_spin.setToolTip("Jump to page number")
self.page_count_label = QLabel("/ 0")
- self.zoom_out_button = QPushButton("-")
- self.zoom_out_button.setFixedWidth(32)
- self.zoom_out_button.setToolTip("Zoom out (Ctrl+Mouse Wheel Up / Ctrl+-)")
- self.zoom_in_button = QPushButton("+")
- self.zoom_in_button.setFixedWidth(32)
- self.zoom_in_button.setToolTip("Zoom in (Ctrl+Mouse Wheel Down / Ctrl+=)")
- self.fit_button = QPushButton("Fit")
- self.fit_button.setCheckable(True)
- self.fit_button.setChecked(True)
- self.fit_button.setFixedWidth(44)
- self.fit_button.setToolTip("Fit page to window (Ctrl+0)")
- self.copy_button = QPushButton("Copy")
+ self.zoom_out_button, self.zoom_in_button, self.fit_button = _make_zoom_buttons()
+ self.copy_button, self.highlight_button, self.underline_button, self.strike_button, self.sticky_button = \
+ _make_tool_icons(34)
self.copy_button.setToolTip("Copy selected text (Ctrl+C)")
+ self.highlight_button.setToolTip("Highlight selected text")
+ self.underline_button.setToolTip("Underline selected text")
+ self.strike_button.setToolTip("Strikethrough selected text")
+ self.sticky_button.setCheckable(True)
+ self.sticky_button.setToolTip("Place sticky note")
controls.addSpacing(4)
controls.addWidget(self.prev_button)
@@ -859,22 +1087,6 @@ def _build_ui(self):
controls.addWidget(self.fit_button)
controls.addSpacing(6)
controls.addWidget(self.copy_button)
-
- # Annotation buttons
- self.highlight_button = QPushButton("HL")
- self.highlight_button.setFixedWidth(34)
- self.highlight_button.setToolTip("Highlight selected text")
- self.underline_button = QPushButton("UL")
- self.underline_button.setFixedWidth(34)
- self.underline_button.setToolTip("Underline selected text")
- self.strike_button = QPushButton("ST")
- self.strike_button.setFixedWidth(34)
- self.strike_button.setToolTip("Strikethrough selected text")
- self.sticky_button = QPushButton("\U0001f4dd")
- self.sticky_button.setFixedWidth(34)
- self.sticky_button.setCheckable(True)
- self.sticky_button.setToolTip("Place sticky note")
-
controls.addWidget(self.highlight_button)
controls.addWidget(self.underline_button)
controls.addWidget(self.strike_button)
diff --git a/packaging/msix/AppInstaller.xml b/packaging/msix/AppInstaller.xml
index 5cc867a..89c7c96 100644
--- a/packaging/msix/AppInstaller.xml
+++ b/packaging/msix/AppInstaller.xml
@@ -18,7 +18,7 @@
diff --git a/packaging/msix/AppxManifest.xml b/packaging/msix/AppxManifest.xml
index f4f7936..5bc1292 100644
--- a/packaging/msix/AppxManifest.xml
+++ b/packaging/msix/AppxManifest.xml
@@ -24,7 +24,7 @@
+ Version="1.2.4.0" />
OpenReader
diff --git a/tests/test_reliability.py b/tests/test_reliability.py
index c3139fa..636aca0 100644
--- a/tests/test_reliability.py
+++ b/tests/test_reliability.py
@@ -287,19 +287,20 @@ def test_tab_data_defaults_to_fit_on_open(self):
assert tab.fit_to_window is True
assert tab.zoom == 1.25
- def test_version_is_1_2_3(self):
+ def test_version_is_1_2_4(self):
import main as m
- assert m.__version__ == "1.2.3"
+ assert m.__version__ == "1.2.4"
class TestZoomUi:
def test_zoom_buttons_use_clear_text_labels(self):
- """Zoom buttons must use plain visible text, not obscure unicode or icon glyphs."""
+ """Zoom buttons must use _make_zoom_buttons factory with clear icons and tooltips."""
import main as m
src = Path(m.__file__).read_text()
- assert 'QPushButton("−")' in src or 'QPushButton("-")' in src
- assert 'QPushButton("+")' in src
- assert 'QPushButton("Fit")' in src
+ assert "_make_zoom_buttons()" in src
+ assert "Zoom out" in src
+ assert "Zoom in" in src
+ assert "Fit page to window" in src
def test_fit_tooltip_mentions_ctrl0(self):
import main as m