PDF Tool
Fast, lightweight PDF viewer with highlight, freehand draw, notes, type-in text boxes, and PNG export. No tracking. No fuss.
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()
Why you’ll like it
Smooth page rendering with adjustable zoom and easy page navigation.
Click-drag to highlight; add sticky notes anywhere for quick callouts.
Freehand ink with color/width controls and typed text boxes with font/size/bold/italic.
Export current page to PNG and save incremental or full PDF updates.
Register as an option for .pdf files; make it your default in Windows settings.
No network calls. Everything happens locally on your machine.
How it works
Use File → Open or the toolbar. Navigate pages with the Prev/Next buttons.
Select a mode: Highlight, Note, Draw, or Type. Adjust colors, pen width, and text formatting.
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.
Key tools
Click-drag a rectangle to create a translucent highlight.
Click anywhere to drop a sticky note.
Freehand ink with color and width controls.
Click-drag a box and type; set font, size, bold, italic, and color.
Save current page as a PNG for sharing.
System requirements
- Windows 10 or later
- ~55 MB free space
- Standalone .exe (PyQt6 & PyMuPDF bundled)