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, QSize
from PyQt6.QtGui import QAction, QPixmap, QImage, QPainter, QColor, QIcon
from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QFileDialog, QLabel, QToolBar, QSpinBox,
    QStatusBar, QMessageBox, QInputDialog, QWidget, QVBoxLayout, QPushButton,
    QScrollArea, QColorDialog, QFontComboBox, QToolButton
)

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._dragging = False
        self._drag_start = None
        self._drag_end = None
        self._drawing = False
        self._path = []
        self.mode = "hand"
        self.scale = 1.0
        self.page = None
        self.page_pixmap = None
        self.doc = None
        self.pen_color = QColor(255, 0, 0)
        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

    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())

    def mousePressEvent(self, e):
        if not self.page:
            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 mouseMoveEvent(self, e):
        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._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()

    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
        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
        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)
        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:
            return
        pg_pts = [self._view_to_page_point(p) for p in pts]
        pg_pts = [p for p in pg_pts if p is not None]
        if len(pg_pts) < 2:
            return
        annot = self.page.add_ink_annot([pg_pts])
        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.scroll = QScrollArea()
        self.scroll.setWidgetResizable(True)
        self.canvas = PageCanvas()
        wrapper = QWidget()
        v = QVBoxLayout(wrapper)
        v.setContentsMargins(10, 10, 10, 10)
        v.addWidget(self.canvas)
        self.scroll.setWidget(wrapper)
        self.setCentralWidget(self.scroll)
        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 _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 Page PNG", self); act_export.triggered.connect(self.export_png)

        tb.addAction(act_open)
        tb.addAction(act_save)
        tb.addAction(act_saveas)
        tb.addAction(act_export)
        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_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_hand); tb.addAction(act_hl); tb.addAction(act_note); tb.addAction(act_draw); 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_color_btn = QToolButton(); self.pen_color_btn.setText("Pen Color")
        self.pen_color_btn.clicked.connect(self.pick_pen_color); tb2.addWidget(self.pen_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 ‘Open with…’", self); act_register.triggered.connect(self.register_open_with)
        act_set_default_help = QAction("How to set default…", 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
        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._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)})")

    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.\nRight-click a PDF → Open with → Choose another app → pick this app → 'Always'.")
        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 1.0.0 Updated Sep 14, 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 1.0.0 Updated 2025-09-14 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