PDF Tool

Fast, lightweight PDF viewer with highlight, freehand draw, notes, type-in text boxes, and PNG export. No tracking. No fuss.

Download for Windows (.exe)

Source Code


import sys, os, fitz, platform
from PyQt6.QtCore import Qt, QRectF, QPointF
from PyQt6.QtGui import QAction, QPixmap, QImage, QPainter, QColor, QIcon, QKeySequence
from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QFileDialog, QLabel, QToolBar, QSpinBox,
    QStatusBar, QMessageBox, QInputDialog, QWidget, QVBoxLayout, QPushButton,
    QScrollArea, QColorDialog, QFontComboBox, QToolButton, QHBoxLayout
)

APP_NAME = "PDF Tool"
APP_PROGID = "PDFTool.Viewer"

def pil2qpixmap(img_bytes_w, img_h, img_w, stride, fmt=QImage.Format.Format_RGBA8888):
    qimg = QImage(img_bytes_w, img_w, img_h, stride, fmt)
    return QPixmap.fromImage(qimg)

def qcolor_to_rgb01(c: QColor):
    return (c.red() / 255.0, c.green() / 255.0, c.blue() / 255.0)

def resolve_fontname(family: str, bold: bool, italic: bool):
    f = family.lower()
    base = "helv"
    if "cour" in f or "mono" in f or "consolas" in f or "code" in f:
        base = "cour"
    elif "times" in f or "georgia" in f or "serif" in f:
        base = "times"
    name = base
    if bold and italic:
        name += "bi"
    elif bold:
        name += "b"
    elif italic:
        name += "i"
    return name

class PageCanvas(QLabel):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
        self.setMouseTracking(True)
        self._dragging = False
        self._drag_start = None
        self._drag_end = None
        self._drawing = False
        self._path = []
        self._hand_panning = False
        self._hand_last_pos = None
        self.scroll_area = None
        self.mode = "hand"
        self.scale = 1.0
        self.page = None
        self.page_pixmap = None
        self.doc = None
        self.pen_color = QColor(0, 0, 255)
        self.text_color = QColor(0, 0, 0)
        self.pen_width = 2
        self.text_font_family = "Helvetica"
        self.text_font_size = 14
        self.text_bold = False
        self.text_italic = False
        self.selected_annot = None
        self._selected_annot_rect_page = None
        self._selected_view_rect = None
        self._moving_annot = False
        self._move_start_view = None
        self._move_delta_view = QPointF(0, 0)
        self.owner = None

    def set_page(self, doc, page, scale, pixmap):
        self.doc = doc
        self.page = page
        self.scale = scale
        self.page_pixmap = pixmap
        self.setPixmap(pixmap)
        self.setFixedSize(pixmap.size())
        self.selected_annot = None
        self._selected_annot_rect_page = None
        self._selected_view_rect = None
        self._moving_annot = False
        self._move_start_view = None
        self._move_delta_view = QPointF(0, 0)

    def mousePressEvent(self, e):
        if not self.page:
            return

        if self.mode == "hand" and e.button() == Qt.MouseButton.LeftButton and self.scroll_area:
            self._hand_panning = True
            self._hand_last_pos = e.globalPosition()
            self.setCursor(Qt.CursorShape.ClosedHandCursor)
            return

        if self.mode == "select" and e.button() == Qt.MouseButton.LeftButton:
            view_pt = e.position()
            page_pt = self._view_to_page_point(view_pt)
            if not page_pt:
                return
            hit_annot = None
            annot = self.page.first_annot
            while annot:
                if annot.rect.contains(page_pt):
                    hit_annot = annot
                    break
                annot = annot.next
            if hit_annot:
                self.selected_annot = hit_annot
                self._selected_annot_rect_page = hit_annot.rect
                self._selected_view_rect = self._page_rect_to_view_rect(hit_annot.rect)
                self._move_start_view = view_pt
                self._move_delta_view = QPointF(0, 0)
                self._moving_annot = True
                self.update()
            else:
                self.selected_annot = None
                self._selected_annot_rect_page = None
                self._selected_view_rect = None
                self._moving_annot = False
                self._move_start_view = None
                self._move_delta_view = QPointF(0, 0)
                self.update()
            return

        if self.mode == "highlight" and e.button() == Qt.MouseButton.LeftButton:
            self._dragging = True
            self._drag_start = e.position()
            self._drag_end = e.position()
            self.update()

        elif self.mode == "note" and e.button() == Qt.MouseButton.LeftButton:
            pos = e.position()
            self._add_text_annot_at_view(pos)

        elif self.mode == "type" and e.button() == Qt.MouseButton.LeftButton:
            self._dragging = True
            self._drag_start = e.position()
            self._drag_end = e.position()
            self.update()

        elif self.mode == "draw" and e.button() == Qt.MouseButton.LeftButton:
            self._drawing = True
            self._path = [e.position()]
            self.update()

        else:
            super().mousePressEvent(e)

    def _page_rect_to_view_rect(self, rect: fitz.Rect) -> QRectF:
        x = rect.x0 * self.scale
        y = rect.y0 * self.scale
        w = (rect.x1 - rect.x0) * self.scale
        h = (rect.y1 - rect.y0) * self.scale
        return QRectF(x, y, w, h)

    def mouseMoveEvent(self, e):
        if self._hand_panning and self.scroll_area:
            delta = e.globalPosition() - self._hand_last_pos
            self._hand_last_pos = e.globalPosition()
            hbar = self.scroll_area.horizontalScrollBar()
            vbar = self.scroll_area.verticalScrollBar()
            hbar.setValue(hbar.value() - int(delta.x()))
            vbar.setValue(vbar.value() - int(delta.y()))
            return

        if self.mode == "select" and self._moving_annot and self._selected_view_rect is not None:
            self._move_delta_view = e.position() - self._move_start_view
            self.update()
            return

        if self._dragging and (self.mode in ("highlight", "type")):
            self._drag_end = e.position()
            self.update()

        elif self._drawing and self.mode == "draw":
            self._path.append(e.position())
            self.update()

        else:
            super().mouseMoveEvent(e)

    def mouseReleaseEvent(self, e):
        if self._hand_panning and e.button() == Qt.MouseButton.LeftButton:
            self._hand_panning = False
            self.setCursor(Qt.CursorShape.OpenHandCursor)
            return

        if self.mode == "select" and self._moving_annot and e.button() == Qt.MouseButton.LeftButton:
            self._moving_annot = False
            if self.selected_annot and self._selected_annot_rect_page and self._move_delta_view is not None:
                dx_page = self._move_delta_view.x() / self.scale
                dy_page = self._move_delta_view.y() / self.scale
                r = self._selected_annot_rect_page
                new_rect = fitz.Rect(
                    r.x0 + dx_page,
                    r.y0 + dy_page,
                    r.x1 + dx_page,
                    r.y1 + dy_page,
                )
                if self.owner:
                    self.owner.push_undo_state()
                try:
                    self.selected_annot.set_rect(new_rect)
                    self.selected_annot.update()
                except Exception:
                    pass
                self._selected_annot_rect_page = new_rect
                self._selected_view_rect = self._page_rect_to_view_rect(new_rect)
                self._move_start_view = None
                self._move_delta_view = QPointF(0, 0)
                self._refresh_raster()
            return

        if self._dragging and self.mode == "highlight":
            self._dragging = False
            rect = QRectF(self._drag_start, self._drag_end).normalized()
            if rect.width() > 5 and rect.height() > 5:
                self._add_rect_highlight_annot(rect)
            self._drag_start = self._drag_end = None
            self.update()

        elif self._dragging and self.mode == "type":
            self._dragging = False
            rect = QRectF(self._drag_start, self._drag_end).normalized()
            self._drag_start = self._drag_end = None
            if rect.width() > 10 and rect.height() > 10:
                self._add_freetext_annot(rect)
            self.update()

        elif self._drawing and self.mode == "draw":
            self._drawing = False
            if len(self._path) >= 2:
                self._commit_ink_path(self._path)
            self._path = []
            self.update()

        else:
            super().mouseReleaseEvent(e)

    def paintEvent(self, event):
        super().paintEvent(event)

        if self._dragging and self._drag_start and self._drag_end and self.mode in ("highlight", "type"):
            painter = QPainter(self)
            painter.setPen(Qt.GlobalColor.yellow if self.mode == "highlight" else Qt.GlobalColor.black)
            painter.drawRect(QRectF(self._drag_start, self._drag_end).normalized())
            painter.end()

        if self._drawing and len(self._path) >= 2:
            painter = QPainter(self)
            pen = painter.pen()
            pen.setColor(self.pen_color)
            pen.setWidth(self.pen_width)
            painter.setPen(pen)
            for i in range(len(self._path) - 1):
                painter.drawLine(self._path[i], self._path[i + 1])
            painter.end()

        if self.selected_annot and self._selected_view_rect is not None:
            painter = QPainter(self)
            pen = painter.pen()
            pen.setColor(QColor(0, 120, 215))
            pen.setWidth(2)
            pen.setStyle(Qt.PenStyle.DashLine)
            painter.setPen(pen)
            rect = self._selected_view_rect
            if self._moving_annot:
                rect = rect.translated(self._move_delta_view)
            painter.drawRect(rect)
            painter.end()

    def _view_to_page_point(self, pt: QPointF):
        if not self.page_pixmap:
            return None
        x = pt.x() / self.scale
        y = pt.y() / self.scale
        return fitz.Point(x, y)

    def _view_rect_to_page_rect(self, rect: QRectF):
        p1 = self._view_to_page_point(rect.topLeft())
        p2 = self._view_to_page_point(rect.bottomRight())
        if not p1 or not p2:
            return None
        return fitz.Rect(p1, p2)

    def _add_rect_highlight_annot(self, view_rect: QRectF):
        page_rect = self._view_rect_to_page_rect(view_rect)
        if not page_rect:
            return
        if self.owner:
            self.owner.push_undo_state()
        annot = self.page.add_rect_annot(page_rect)
        annot.set_colors(stroke=(1, 1, 0), fill=(1, 1, 0))
        annot.set_opacity(0.30)
        annot.update()
        self._refresh_raster()

    def _add_text_annot_at_view(self, view_pt: QPointF):
        page_pt = self._view_to_page_point(view_pt)
        if not page_pt:
            return
        text, ok = QInputDialog.getText(self, "Add Note", "Note text:")
        if not ok or not text.strip():
            return
        if self.owner:
            self.owner.push_undo_state()
        self.page.add_text_annot(page_pt, text.strip())
        self._refresh_raster()

    def _add_freetext_annot(self, view_rect: QRectF):
        page_rect = self._view_rect_to_page_rect(view_rect)
        if not page_rect:
            return
        text, ok = QInputDialog.getMultiLineText(self, "Type Text", "Text:")
        if not ok or not text.strip():
            return
        fontname = resolve_fontname(self.text_font_family, self.text_bold, self.text_italic)
        if self.owner:
            self.owner.push_undo_state()
        try:
            annot = self.page.add_freetext_annot(
                page_rect,
                text.strip(),
                fontsize=self.text_font_size,
                fontname=fontname,
                text_color=qcolor_to_rgb01(self.text_color),
                fill_color=None,
                align=0,
            )
            annot.set_border(width=0.0)
            annot.update()
        except Exception:
            self.page.insert_textbox(
                page_rect,
                text.strip(),
                fontsize=self.text_font_size,
                color=qcolor_to_rgb01(self.text_color),
                fontname="helv",
                align=0,
                render_mode=0,
            )
        self._refresh_raster()

    def _commit_ink_path(self, pts):
        if not pts or self.page is None:
            return
        pg_pts = []
        for p in pts:
            page_pt = self._view_to_page_point(p)
            if page_pt is None:
                continue
            x = float(getattr(page_pt, "x", page_pt[0]))
            y = float(getattr(page_pt, "y", page_pt[1]))
            pg_pts.append((x, y))
        if len(pg_pts) < 2:
            return
        ink_list = [pg_pts]
        if self.owner:
            self.owner.push_undo_state()
        annot = self.page.add_ink_annot(ink_list)
        annot.set_colors(stroke=qcolor_to_rgb01(self.pen_color))
        try:
            annot.set_border(width=max(0.5, float(self.pen_width)))
        except Exception:
            pass
        annot.update()
        self._refresh_raster()

    def _refresh_raster(self):
        if not self.page:
            return
        mat = fitz.Matrix(self.scale, self.scale)
        pm = self.page.get_pixmap(matrix=mat, alpha=True)
        qpm = pil2qpixmap(pm.samples, pm.h, pm.w, pm.stride)
        self.page_pixmap = qpm
        self.setPixmap(qpm)
        self.setFixedSize(qpm.size())

class PDFWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle(APP_NAME)
        self.resize(1200, 850)
        self.doc = None
        self.path = None
        self.page_index = 0
        self.scale = 1.5
        self.undo_stack = []
        self.redo_stack = []
        self.max_history = 20
        self._in_undo_redo = False
        self.scroll = QScrollArea()
        self.scroll.setWidgetResizable(True)
        self.canvas = PageCanvas()
        self.canvas.scroll_area = self.scroll
        wrapper = QWidget()
        v = QVBoxLayout(wrapper)
        v.setContentsMargins(10, 10, 10, 10)
        v.addWidget(self.canvas)
        self.scroll.setWidget(wrapper)

        self.preview_panel = QWidget()
        self.preview_panel.setFixedWidth(200)
        self.preview_layout = QVBoxLayout(self.preview_panel)
        self.preview_layout.setContentsMargins(4, 4, 4, 4)
        self.preview_layout.setSpacing(8)
        self.preview_title = QLabel("Next pages")
        self.preview_title.setAlignment(Qt.AlignmentFlag.AlignHCenter)
        self.preview_layout.addWidget(self.preview_title)
        self.preview_layout.addStretch()
        self.preview_labels = []
        central = QWidget()
        h = QHBoxLayout(central)
        h.setContentsMargins(0, 0, 0, 0)
        h.setSpacing(0)
        h.addWidget(self.scroll, 1)
        h.addWidget(self.preview_panel)
        self.setCentralWidget(central)
        self._make_toolbar()
        self.status = QStatusBar(self)
        self.setStatusBar(self.status)
        self.canvas_doc_link()

    def canvas_doc_link(self):
        self.canvas.doc = self.doc

    def push_undo_state(self):
        if self._in_undo_redo:
            return
        if not self.doc:
            return
        try:
            state = self.doc.tobytes()
        except Exception:
            return
        self.undo_stack.append((state, self.page_index))
        if len(self.undo_stack) > self.max_history:
            self.undo_stack.pop(0)
        self.redo_stack.clear()

    def undo(self):
        if not self.undo_stack:
            return
        if self.doc:
            try:
                current_state = self.doc.tobytes()
            except Exception:
                current_state = None
            if current_state is not None:
                self.redo_stack.append((current_state, self.page_index))
                if len(self.redo_stack) > self.max_history:
                    self.redo_stack.pop(0)
        state, page_index = self.undo_stack.pop()
        self._in_undo_redo = True
        if self.doc:
            try:
                self.doc.close()
            except Exception:
                pass
        self.doc = fitz.open(stream=state, filetype="pdf")
        self.canvas.doc = self.doc
        self.page_index = min(page_index, len(self.doc) - 1)
        self._render_current_page()
        self._in_undo_redo = False

    def redo(self):
        if not self.redo_stack:
            return
        if self.doc:
            try:
                current_state = self.doc.tobytes()
            except Exception:
                current_state = None
            if current_state is not None:
                self.undo_stack.append((current_state, self.page_index))
                if len(self.undo_stack) > self.max_history:
                    self.undo_stack.pop(0)
        state, page_index = self.redo_stack.pop()
        self._in_undo_redo = True
        if self.doc:
            try:
                self.doc.close()
            except Exception:
                pass
        self.doc = fitz.open(stream=state, filetype="pdf")
        self.canvas.doc = self.doc
        self.page_index = min(page_index, len(self.doc) - 1)
        self._render_current_page()
        self._in_undo_redo = False

    def _make_toolbar(self):
        tb = QToolBar("Main")
        self.addToolBar(tb)

        act_open = QAction("Open", self)
        act_open.triggered.connect(self.open_pdf)
        act_save = QAction("Save", self)
        act_save.triggered.connect(self.save_pdf)
        act_saveas = QAction("Save As", self)
        act_saveas.triggered.connect(self.save_as_pdf)
        act_export = QAction("Export PNG", self)
        act_export.triggered.connect(self.export_png)

        act_undo = QAction("↶", self)
        act_undo.setToolTip("Undo (Ctrl+Z)")
        act_undo.setShortcut(QKeySequence("Ctrl+Z"))
        act_undo.triggered.connect(self.undo)

        act_redo = QAction("↷", self)
        act_redo.setToolTip("Redo (Ctrl+Y)")
        act_redo.setShortcut(QKeySequence("Ctrl+Y"))
        act_redo.triggered.connect(self.redo)

        tb.addAction(act_open)
        tb.addAction(act_save)
        tb.addAction(act_saveas)
        tb.addAction(act_export)
        tb.addSeparator()
        tb.addAction(act_undo)
        tb.addAction(act_redo)
        tb.addSeparator()

        self.zoom_box = QSpinBox()
        self.zoom_box.setRange(25, 600)
        self.zoom_box.setSuffix("%")
        self.zoom_box.setValue(int(self.scale * 100))
        self.zoom_box.valueChanged.connect(self.on_zoom_changed)
        tb.addWidget(self.zoom_box)

        tb.addSeparator()
        self.btn_prev = QPushButton("◀ Prev")
        self.btn_next = QPushButton("Next ▶")
        self.btn_prev.clicked.connect(self.prev_page)
        self.btn_next.clicked.connect(self.next_page)
        tb.addWidget(self.btn_prev)
        tb.addWidget(self.btn_next)

        tb.addSeparator()
        act_select = QAction("Select", self)
        act_select.triggered.connect(lambda: self.set_mode("select"))
        act_hand = QAction("Hand", self)
        act_hand.triggered.connect(lambda: self.set_mode("hand"))
        act_hl = QAction("Highlight", self)
        act_hl.triggered.connect(lambda: self.set_mode("highlight"))
        act_note = QAction("Note", self)
        act_note.triggered.connect(lambda: self.set_mode("note"))
        act_draw = QAction("Draw", self)
        act_draw.triggered.connect(lambda: self.set_mode("draw"))
        act_type = QAction("Type", self)
        act_type.triggered.connect(lambda: self.set_mode("type"))
        tb.addAction(act_select)
        tb.addAction(act_hand)
        tb.addAction(act_hl)
        tb.addAction(act_note)
        tb.addAction(act_draw)

        self.pen_color_btn = QToolButton()
        self.pen_color_btn.setText("🖊")
        self.pen_color_btn.setToolTip("Pen Color")
        self.pen_color_btn.clicked.connect(self.pick_pen_color)
        tb.addWidget(self.pen_color_btn)

        tb.addAction(act_type)

        tb2 = QToolBar("Format")
        self.addToolBar(tb2)

        self.font_combo = QFontComboBox()
        self.font_combo.setMaximumWidth(220)
        self.font_combo.currentFontChanged.connect(lambda f: setattr(self.canvas, "text_font_family", f.family()))
        tb2.addWidget(self.font_combo)

        self.size_box = QSpinBox()
        self.size_box.setRange(6, 96)
        self.size_box.setValue(14)
        self.size_box.valueChanged.connect(lambda v: setattr(self.canvas, "text_font_size", v))
        tb2.addWidget(self.size_box)

        self.btn_bold = QToolButton()
        self.btn_bold.setText("B")
        self.btn_bold.setCheckable(True)
        self.btn_bold.setToolTip("Bold")
        self.btn_bold.clicked.connect(lambda s: setattr(self.canvas, "text_bold", s))
        tb2.addWidget(self.btn_bold)

        self.btn_italic = QToolButton()
        self.btn_italic.setText("I")
        self.btn_italic.setCheckable(True)
        self.btn_italic.setToolTip("Italic")
        self.btn_italic.clicked.connect(lambda s: setattr(self.canvas, "text_italic", s))
        tb2.addWidget(self.btn_italic)

        self.color_btn = QToolButton()
        self.color_btn.setText("Text Color")
        self.color_btn.clicked.connect(self.pick_text_color)
        tb2.addWidget(self.color_btn)

        self.pen_w_box = QSpinBox()
        self.pen_w_box.setRange(1, 20)
        self.pen_w_box.setValue(2)
        self.pen_w_box.valueChanged.connect(lambda v: setattr(self.canvas, "pen_width", v))
        tb2.addWidget(self.pen_w_box)

        tb.addSeparator()
        act_register = QAction("Register Default", self)
        act_register.triggered.connect(self.register_open_with)
        act_set_default_help = QAction("Help", self)
        act_set_default_help.triggered.connect(self.show_default_help)
        tb.addAction(act_register)
        tb.addAction(act_set_default_help)

    def set_mode(self, m):
        self.canvas.mode = m
        if m == "hand":
            self.canvas.setCursor(Qt.CursorShape.OpenHandCursor)
        else:
            self.canvas.setCursor(Qt.CursorShape.ArrowCursor)
        self.status.showMessage(f"Mode: {m}", 3000)

    def pick_text_color(self):
        c = QColorDialog.getColor(self.canvas.text_color, self, "Choose Text Color")
        if c.isValid():
            self.canvas.text_color = c

    def pick_pen_color(self):
        c = QColorDialog.getColor(self.canvas.pen_color, self, "Choose Pen Color")
        if c.isValid():
            self.canvas.pen_color = c

    def open_pdf(self):
        fn, _ = QFileDialog.getOpenFileName(self, "Open PDF", "", "PDF Files (*.pdf)")
        if not fn:
            return
        try:
            self._load_doc(fn)
        except Exception as e:
            QMessageBox.critical(self, "Open failed", str(e))

    def _load_doc(self, path):
        if self.doc:
            self.doc.close()
        self.doc = fitz.open(path)
        self.path = path
        self.page_index = 0
        self.canvas.doc = self.doc
        self.undo_stack.clear()
        self.redo_stack.clear()
        self._render_current_page()
        self.setWindowTitle(f"{APP_NAME} — {os.path.basename(path)}  ({self.page_index+1}/{len(self.doc)})")

    def _render_current_page(self):
        if not self.doc:
            return
        page = self.doc.load_page(self.page_index)
        mat = fitz.Matrix(self.scale, self.scale)
        pm = page.get_pixmap(matrix=mat, alpha=True)
        qpm = pil2qpixmap(pm.samples, pm.h, pm.w, pm.stride)
        self.canvas.set_page(self.doc, page, self.scale, qpm)
        self.setWindowTitle(f"{APP_NAME} — {os.path.basename(self.path)}  ({self.page_index+1}/{len(self.doc)})")
        self._update_previews()

    def _update_previews(self):
        if not self.doc:
            self.preview_panel.setVisible(False)
            return

        total = len(self.doc)
        if total <= 1:
            self.preview_panel.setVisible(False)
            return

        self.preview_panel.setVisible(True)

        for lbl in self.preview_labels:
            self.preview_layout.removeWidget(lbl)
            lbl.deleteLater()
        self.preview_labels.clear()

        max_preview = 4
        start = self.page_index + 1
        end = min(total, start + max_preview)
        if start >= end:
            return

        max_width = 140

        for i in range(start, end):
            page = self.doc.load_page(i)
            mat = fitz.Matrix(0.4, 0.4)
            pm = page.get_pixmap(matrix=mat, alpha=True)
            qpm = pil2qpixmap(pm.samples, pm.h, pm.w, pm.stride)
            thumb = qpm.scaledToWidth(max_width, Qt.TransformationMode.SmoothTransformation)

            lbl = QLabel()
            lbl.setPixmap(thumb)
            lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
            lbl.setToolTip(f"Page {i+1}")

            self.preview_layout.insertWidget(self.preview_layout.count() - 1, lbl)
            self.preview_labels.append(lbl)


    def save_pdf(self):
        if not self.doc or not self.path:
            return self.save_as_pdf()
        try:
            self.doc.saveIncr()
            self.status.showMessage("Saved.", 3000)
        except Exception:
            tmp = self.path + ".tmp.pdf"
            self.doc.save(tmp)
            os.replace(tmp, self.path)
            self.status.showMessage("Saved (full).", 3000)

    def save_as_pdf(self):
        if not self.doc:
            return
        fn, _ = QFileDialog.getSaveFileName(self, "Save As", self.path or "document.pdf", "PDF Files (*.pdf)")
        if not fn:
            return
        if not fn.lower().endswith(".pdf"):
            fn += ".pdf"
        self.doc.save(fn)
        self.path = fn
        self.status.showMessage(f"Saved to {fn}", 3000)
        self.setWindowTitle(f"{APP_NAME} — {os.path.basename(self.path)}  ({self.page_index+1}/{len(self.doc)})")

    def export_png(self):
        if not self.doc:
            return
        page = self.doc.load_page(self.page_index)
        pm = page.get_pixmap(matrix=fitz.Matrix(self.scale, self.scale), alpha=True)
        fn, _ = QFileDialog.getSaveFileName(self, "Export Page as PNG", f"page-{self.page_index+1}.png", "PNG Files (*.png)")
        if not fn:
            return
        if not fn.lower().endswith(".png"):
            fn += ".png"
        pm.save(fn)
        self.status.showMessage(f"Exported {fn}", 3000)

    def on_zoom_changed(self, val):
        self.scale = max(0.25, min(6.0, val / 100.0))
        if self.doc:
            self._render_current_page()

    def prev_page(self):
        if not self.doc:
            return
        if self.page_index > 0:
            self.page_index -= 1
            self._render_current_page()

    def next_page(self):
        if not self.doc:
            return
        if self.page_index < len(self.doc) - 1:
            self.page_index += 1
            self._render_current_page()

    def register_open_with(self):
        if platform.system() != "Windows":
            QMessageBox.information(self, "Not Windows", "Registration is Windows-only.")
            return
        try:
            import winreg
            exe = sys.executable
            if getattr(sys, "frozen", False):
                exe = sys.argv[0]
                cmd = f"\"{exe}\" \"%1\""
            else:
                script = os.path.abspath(sys.argv[0])
                cmd = f"\"{sys.executable}\" \"{script}\" \"%1\""
            with winreg.CreateKey(winreg.HKEY_CURRENT_USER, rf"Software\Classes\{APP_PROGID}") as k:
                winreg.SetValueEx(k, None, 0, winreg.REG_SZ, APP_NAME)
                with winreg.CreateKey(k, r"shell\open\command") as kc:
                    winreg.SetValueEx(kc, None, 0, winreg.REG_SZ, cmd)
            with winreg.CreateKey(winreg.HKEY_CURRENT_USER, r"Software\Classes\.pdf\OpenWithProgids") as k2:
                winreg.SetValueEx(k2, APP_PROGID, 0, winreg.REG_DWORD, 0)
            QMessageBox.information(self, "Registered", "Added to 'Open with' for .pdf.")
        except Exception as e:
            QMessageBox.critical(self, "Registration failed", str(e))

    def show_default_help(self):
        msg = (
            "To set this app as the default PDF viewer:\n"
            "1) Right-click any .pdf → Open with → Choose another app.\n"
            f"2) Select “{APP_NAME}” → check ‘Always use this app to open .pdf files’ → OK.\n\n"
            "Or open Settings → Apps → Default apps → search ‘.pdf’ and select this app.\n"
        )
        QMessageBox.information(self, "Set Default", msg)

def main():
    app = QApplication(sys.argv)
    w = PDFWindow()
    w.show()
    if len(sys.argv) >= 2 and os.path.isfile(sys.argv[-1]) and sys.argv[-1].lower().endswith(".pdf"):
        try:
            w._load_doc(sys.argv[-1])
        except Exception as e:
            QMessageBox.critical(w, "Open failed", str(e))
    sys.exit(app.exec())

if __name__ == "__main__":
    main()
          
Version 2.0.0 Updated Nov 25, 2025 Size ~55 MB

Why you’ll like it

Clean viewing

Smooth page rendering with adjustable zoom and easy page navigation.

Highlight & notes

Click-drag to highlight; add sticky notes anywhere for quick callouts.

Draw & type

Freehand ink with color/width controls and typed text boxes with font/size/bold/italic.

Export & save

Export current page to PNG and save incremental or full PDF updates.

“Open with…”

Register as an option for .pdf files; make it your default in Windows settings.

Private by design

No network calls. Everything happens locally on your machine.

How it works

1
Open a PDF

Use File → Open or the toolbar. Navigate pages with the Prev/Next buttons.

2
Annotate

Select a mode: Highlight, Note, Draw, or Type. Adjust colors, pen width, and text formatting.

3
Save / Export

Save changes back to the PDF or export the current page as a PNG.

FAQ

Does it require admin rights?

No. All features work without admin. The optional “Open with…” registration writes to your user registry hive.

Will it work offline?

Yes—no internet usage.

Where are settings stored?

Standard user profile (Qt defaults). Uninstalling/removing the folder clears them.

How do I make it default for PDFs?

Use the app’s Register ‘Open with…’ action, then set the default via Windows “Choose another app” or Default apps.

Download

Installer (.exe)
Version 2.0.0 Updated 2025-11-25 Size ~55 MB

Key tools

Highlight

Click-drag a rectangle to create a translucent highlight.

Note

Click anywhere to drop a sticky note.

Draw

Freehand ink with color and width controls.

Type

Click-drag a box and type; set font, size, bold, italic, and color.

Export PNG

Save current page as a PNG for sharing.

System requirements

  • Windows 10 or later
  • ~55 MB free space
  • Standalone .exe (PyQt6 & PyMuPDF bundled)
Want more utilities? Explore the free tools on the home page.

← Back to Home