Skip to content

Latest commit

 

History

History
1488 lines (1166 loc) · 62.5 KB

File metadata and controls

1488 lines (1166 loc) · 62.5 KB

Contributing to PDFApps

Thank you for your interest in contributing to PDFApps! This guide covers the full codebase architecture, every module, class, and function — so you can understand the code and start contributing right away.


Table of Contents


Getting Started

Prerequisites

  • Python 3.14+
  • Windows / macOS / Linux
  • (Optional) Tesseract OCR — for the OCR tool
  • (Optional) Ghostscript — for advanced PDF compression

Setup

git clone https://github.com/nelsonduarte/PDFApps.git
cd PDFApps
python -m venv venv
venv/Scripts/activate        # Windows
# source venv/bin/activate   # macOS / Linux
pip install -r requirements.txt
python pdfapps.py

Dependencies

Package Purpose
PySide6 Qt 6 GUI framework
pypdf PDF manipulation (split, merge, encrypt, metadata)
pymupdf (fitz) PDF rendering (viewer, editor, compression)
qtawesome Font Awesome icons in Qt widgets
pillow Image processing (import tool, icon generation)
pytesseract OCR engine wrapper (Tesseract integration)
python-docx DOCX export in the convert tool
pyinstaller Executable building (dev/CI only)

Optional runtime dependencies:

  • Ghostscript (gs / gswin64c) — used by the compression pipeline for high-quality image downsampling
  • Tesseract — used by the OCR tool to add text layers to scanned PDFs

Architecture Overview

PDFApps is a modular desktop application built with PySide6. The app is organized around a single MainWindow that manages three main areas: a sidebar for navigation, a tabbed PDF viewer, and a stack of tool panels.

┌─────────────────────────────────────────────────────────────┐
│ Workspace Bar                                               │
│ [≡] Workspace title    [📁][🕐][🖨][🔍][−][%][+] ... [?][PT][☀]│
├──────────┬──────────────────────────────────────────────────┤
│          │  QSplitter (horizontal)                          │
│ Sidebar  │  ┌──────────────────┬───────────────────────┐    │
│          │  │ Tool Stack       │ Tabbed Viewer          │    │
│ PDFApps  │  │ (QStackedWidget) │ (QTabBar +             │    │
│ logo     │  │                  │  QStackedWidget of     │    │
│          │  │ Hidden when no   │  PdfViewerPanel)       │    │
│ 13 tools │  │ tool is active;  │                        │    │
│ (nav)    │  │ shown when user  │ Each tab holds one     │    │
│          │  │ selects a tool   │ PdfViewerPanel with    │    │
│ footer   │  │ from sidebar     │ its own _SelectCanvas  │    │
│          │  └──────────────────┴───────────────────────┘    │
├──────────┴──────────────────────────────────────────────────┤
│ QStatusBar                                                  │
└─────────────────────────────────────────────────────────────┘

Key Principles

  • Offline first — no network calls except the optional update check on startup
  • Single entry pointpdfapps.py bootstraps the entire application
  • Consistent tool UX — all 13 tools inherit from BasePage for a uniform layout
  • Lazy rendering — the viewer only renders visible pages plus a small buffer
  • Theme-aware — every widget, icon, and style adapts to dark/light mode
  • i18n everywhere — all user-facing strings go through t() with 8 languages

Directory Structure

PDFApps/
├── pdfapps.py              # Entry point — bootstraps QApplication + MainWindow
├── installer.py            # Cross-platform installer (tkinter GUI, ~970 lines)
├── uninstaller.py          # Cross-platform uninstaller (tkinter GUI, ~230 lines)
├── requirements.txt        # Python dependencies
│
├── pdfapps.spec            # PyInstaller spec — main app executable
├── installer.spec          # PyInstaller spec — installer (bundles app + uninstaller)
├── uninstaller.spec        # PyInstaller spec — uninstaller
│
├── icon.ico                # Multi-size Windows icon (PNG-compressed, up to 512px)
├── icon_512.png            # High-res icon source (2048px)
├── pdfapps.svg             # Vector logo (used in sidebar brand area)
│
├── app/                    # ── Main application package ──
│   ├── __init__.py
│   ├── window.py           # MainWindow — layout, navigation, tabs, theme (~720 lines)
│   ├── base.py             # BasePage — standard 3-section tool layout template
│   ├── constants.py        # Color tokens, version string, paths
│   ├── styles.py           # QSS stylesheets (dark + light, ~450 lines)
│   ├── widgets.py          # DropFileEdit, MultiDropWidget (drag & drop)
│   ├── utils.py            # Helpers: compression, palettes, UI factories (~360 lines)
│   ├── i18n.py             # Translation system + config management (~150 lines)
│   ├── translations.json   # 300+ keys × 8 languages
│   ├── updater.py          # Auto-update via GitHub Releases API
│   │
│   ├── viewer/             # ── PDF viewer subsystem ──
│   │   ├── __init__.py
│   │   ├── panel.py        # PdfViewerPanel — viewer UI (header, search, print)
│   │   ├── canvas.py       # _SelectCanvas — threaded continuous-scroll renderer (~450 lines)
│   │   └── presentation.py # PresentationWidget — fullscreen slideshow (F5)
│   │
│   ├── editor/             # ── PDF editor subsystem ──
│   │   ├── __init__.py
│   │   ├── tab.py          # TabEditar — editor tool with 8 modes (~650 lines)
│   │   ├── canvas.py       # PdfEditCanvas — continuous-scroll edit canvas with overlays
│   │   └── dialogs.py      # Text, note, password, text-edit dialogs
│   │
│   └── tools/              # ── 13 tool tabs ──
│       ├── split.py        # TabDividir — split PDF by page ranges
│       ├── merge.py        # TabJuntar — merge multiple PDFs
│       ├── rotate.py       # TabRotar — rotate pages by angle
│       ├── extract.py      # TabExtrair — extract page subsets
│       ├── reorder.py      # TabReordenar — drag-reorder pages
│       ├── compress.py     # TabComprimir — 3-pass compression pipeline
│       ├── encrypt.py      # TabEncriptar — encrypt / decrypt PDFs
│       ├── watermark.py    # TabMarcaDagua — watermark overlay
│       ├── ocr.py          # TabOCR — add OCR text layer (Tesseract)
│       ├── convert.py      # TabConverter — export to PNG/JPG/DOCX/TXT
│       ├── import_pdf.py   # TabImport — import TXT/images/Markdown → PDF
│       └── info.py         # TabInfo — display PDF metadata
│
├── docs/                   # GitHub Pages website
│   ├── index.html          # Landing page (JS-based i18n, 8 languages)
│   └── *.png               # Screenshots
│
├── flatpak/                # Flathub submission (manifest, metainfo, desktop)
├── winget/                 # Windows winget manifests (3 YAMLs per version)
├── snap/                   # Snap Store (snapcraft.yaml + gui/banner)
├── rpm/                    # Fedora Copr RPM spec
├── aur/
│   ├── pdfapps/            # AUR source PKGBUILD
│   └── pdfapps-bin/        # AUR binary PKGBUILD
├── appimage/               # AppImage build script + AppRun launcher
│
└── .github/workflows/
    └── build.yml           # CI/CD — Win/Linux/macOS + AppImage + Release

Entry Point

pdfapps.py

The main entry point (~52 lines). Bootstrapping flow:

  1. Dependency check — verifies pypdf is installed; shows an error dialog if not
  2. Config loading — reads ~/.pdfapps_config.json for dark mode preference
  3. QApplication — creates the app with Fusion style
  4. Theme application — sets palette and stylesheet based on saved preference
  5. MainWindow — creates and shows the main window
  6. CLI argument — if a PDF path was passed (e.g., double-click on a .pdf file), opens it
  7. Event loopapp.exec()
def main():
    # 1. Check pypdf
    # 2. Load dark_mode from config
    # 3. QApplication with Fusion style
    # 4. Apply palette + stylesheet
    # 5. MainWindow().show()
    # 6. Open sys.argv[1] if present
    # 7. sys.exit(app.exec())

Config file: ~/.pdfapps_config.json

{
  "dark_mode": true,
  "language": "pt",
  "recent_files": ["C:/path/to/file1.pdf", "C:/path/to/file2.pdf"]
}

Main Window

app/window.pyMainWindow(QMainWindow)

The central orchestrator (~720 lines). This is the largest and most important file.

Signals

Signal Type Purpose
_update_ready Signal() Emitted when a new version is found (cross-thread)

Layout Construction (__init__)

The constructor builds the entire UI in this order:

  1. Window setup — title, icon, size (1280×780), status bar
  2. Workspace bar — horizontal toolbar at the top:
    • Sidebar toggle button (hamburger / close )
    • Title + subtitle labels
    • Open PDF, Recent, Print, Search buttons
    • Zoom widget (−, %, +, Reset)
    • Page navigation widget (‹, input, /total, ›)
    • Tool badge label ("Mode: Viewer" / "Mode: Split")
    • Help, Language, Theme buttons
    • Update button (hidden, shown when update available)
  3. Body — horizontal layout containing:
    • Sidebar (fixed 228px) — brand area, nav list (13 items), footer
    • QSplitter — tool stack (left) + tabbed viewer (right)
  4. Navigation wiring — connects sidebar clicks to tool switching
  5. Theme loading — applies saved dark/light preference

Navigation System

Tools are defined in NAV_ITEMS — a list of tuples:

NAV_ITEMS = [
    (t("nav.split"),     "fa5s.cut",          TabDividir),
    (t("nav.merge"),     "fa5s.layer-group",   TabJuntar),
    (t("nav.rotate"),    "fa5s.sync-alt",      TabRotar),
    # ... 10 more tools ...
]

Each tuple maps a translated name, a Font Awesome icon, and a Tab class. The sidebar QListWidget is populated from this list, and clicking an item calls _on_nav_clicked().

Methods Reference

Properties:

Method Returns Description
_viewer PdfViewerPanel Returns the active tab's viewer panel

Tab Management:

Method Parameters Description
_add_viewer_tab(path="") path: str Creates a new viewer tab; loads PDF if path given
_update_tab_visibility() Shows/hides tab bar based on open document count
_on_tab_changed(idx) idx: int Switches active viewer when tab changes
_close_tab(idx) idx: int Closes tab; resets to placeholder if last tab

Navigation:

Method Parameters Description
_on_nav_clicked(item) QListWidgetItem Toggles tool visibility; shows tool stack or viewer
_open_tool_by_name(name) name: str Opens a tool programmatically by its nav name
_try_auto_load(index) index: int Calls auto_load(path) on the tool if viewer has a PDF
_edit_tool_idx() Returns the index of TabEditar in NAV_ITEMS

File Operations:

Method Parameters Description
_open_pdf() Opens file dialog, calls _load_and_track()
_load_and_track(path) path: str Loads PDF; opens new tab if current tab has a document
_open_in_new_tab() Opens file dialog and loads in a new tab
_show_recent_menu() Shows popup menu with recent files list
_clear_recent() Clears recent files from config

Page Navigation (workspace bar):

Method Parameters Description
_update_page_nav() Updates page input and total label from viewer scroll position
_goto_prev_page() Scrolls viewer to previous page
_goto_next_page() Scrolls viewer to next page
_goto_input_page() Scrolls to the page number typed in the input field
_setup_zoom_bar(active, canvas) active: bool, canvas=None Connects/disconnects zoom buttons to a canvas

UI State:

Method Parameters Description
_toggle_sidebar() Toggles sidebar visibility (fully hides/shows)
_toggle_fullscreen() Toggles fullscreen (F11): hides/shows workspace bar, sidebar, status bar
_start_presentation() Starts presentation mode (F5): opens PresentationWidget fullscreen
_toggle_theme() Switches dark↔light mode and saves preference
_apply_theme() Reapplies QSS, palette, and all icon colors
_set_status(msg) msg: str Shows message in status bar
_show_language_menu() Shows language selection popup
_set_language(code, name) code: str, name: str Changes language, shows restart message

Drag & Drop:

Method Parameters Description
dragEnterEvent(e) QDragEnterEvent Accepts drop if dragged file is a PDF
dropEvent(e) QDropEvent Opens dropped PDF via _load_and_track()

Auto-Update:

Method Parameters Description
_check_for_updates_async() Spawns QThread to check GitHub Releases API
_on_update_found() Called in worker thread when update exists
_notify_update() Shows update notification (main thread, via signal)
_show_update_dialog() Opens UpdateDialog for download + install

Base Page (Tool Template)

app/base.pyBasePage(QWidget)

Abstract base class that all 13 tool tabs inherit from. Provides a consistent 3-section layout:

┌──────────────────────────────┐
│ ToolHeader                   │  ← Fixed at top (icon + title + description)
│ (icon, title, desc)          │
├──────────────────────────────┤
│                              │
│ Scrollable form area         │  ← self._form (QVBoxLayout)
│                              │     Subclasses add their widgets here
│ [DropFileEdit]               │
│ [Options GroupBox]           │
│ [Output DropFileEdit]        │
│                              │
├──────────────────────────────┤
│ ActionBar (primary button)   │  ← Fixed at bottom
└──────────────────────────────┘

Methods

Method Parameters Description
__init__(icon, title, desc, action_text, status_fn) icon: str, title/desc/action_text: str, status_fn: callable Creates layout with header, scroll area, action bar
_build() Override point — add widgets to self._form
_run() Override point — called when action button is clicked
paintEvent(event) Ensures stylesheet backgrounds render correctly

Key Attributes

  • self._formQVBoxLayout inside the scroll area; add your tool's widgets here
  • self._status — callable to update the status bar: self._status("Done!")

Constants & Design Tokens

app/constants.py

Central location for all color constants, version, and paths.

APP_VERSION = "1.7.9"
GITHUB_REPO = "nelsonduarte/PDFApps"
DESKTOP = os.path.join(os.path.expanduser("~"), "Desktop")

Dark Theme Colors

Constant Value Usage
ACCENT #14B8A6 Primary teal — buttons, selections, active states
ACCENT_H #0D9488 Hover state
ACCENT_P #0F766E Pressed state
BG_BASE #0B0F12 Window background
BG_SIDE #11161A Sidebar background
BG_CARD #182127 Cards, headers, tab bar
BG_INPUT #1D2A33 Input fields, drop zones
BG_INNER #121B22 Scroll area / canvas background
BORDER #2A3944 Borders and separators
TEXT_PRI #E6F4F1 Primary text
TEXT_SEC #93A9A3 Secondary / muted text

Light Theme Colors

Prefixed with _L: _LA (accent), _LB (base bg), _LS (sidebar bg), _LC (card bg), _LI (input bg), _LN (inner bg), _LO (border), _LP (primary text), _LQ (secondary text).


Stylesheets

app/styles.py

Two complete Qt Style Sheets (~450 lines total): STYLE (dark) and STYLE_LIGHT (light). Both use Python f-string formatting with the color constants.

Key QSS Selectors

Selector Widget Notes
#sidebar Left navigation panel Fixed width 228px
#brand_area Logo + app name area Top of sidebar
#nav_list Tool navigation list Custom selected/hover states
#nav_list::item:selected Active tool Teal accent background
#workspace_bar Top toolbar Dark header with border-bottom
#workspace_title "Workspace" label Bold, primary text
#workspace_badge Mode indicator "Mode: Viewer" pill
#viewer_tabs Tab bar Closable, movable tabs
#viewer_nav_btn Toolbar icon buttons 28×28, transparent bg, rounded hover
#content_area Tool stack container
#tool_header Tool header section Icon + title + description
#th_icon Tool header icon button 40×40, teal border
#action_bar Bottom action bar Fixed at bottom of tool
#btn_primary Primary action button Teal background, white text
#btn_danger Danger button Red background
#drop_zone File drop widget Dashed border, hover highlight
#theme_btn Theme/language/help buttons 28×28, bordered
#page_input Page number input Small text field in nav
QStatusBar Bottom status bar Subtle text, thin
QSplitter::handle Splitter between panels 1px, border color

Helper Function

Function Parameters Returns Description
_make_palette(dark) dark: bool QPalette Creates themed palette for dark/light mode

Widgets

app/widgets.py

DropFileEdit(QWidget) — File Selector with Drag & Drop

The primary input widget used by every tool. Supports both "open" mode (select existing file) and "save" mode (choose output path).

Signals:

Signal Type Description
path_changed Signal(str) Emitted whenever the file path changes

Constructor:

DropFileEdit(
    placeholder: str = None,   # hint text (e.g., "Drop PDF here")
    filters: str = None,       # file dialog filter (e.g., "PDF (*.pdf)")
    save: bool = False,        # True = save dialog, False = open dialog
    default_name: str = "result.pdf"  # default filename for save dialog
)

Methods:

Method Returns Description
path() str Returns the current file path (empty if none)
set_path(p) Sets path, updates UI display, emits path_changed
clear() Resets to placeholder state, emits path_changed("")

Drag & Drop:

  • dragEnterEvent — accepts if drag contains file URLs
  • dropEvent — extracts first URL, calls set_path()
  • _browse() — opens native file dialog (save or open mode)

MultiDropWidget(QWidget) — Multi-File Drop Zone

Used by Merge and Import tools for accepting multiple files.

Constructor:

MultiDropWidget(on_drop_callback: callable)
# callback receives list of PDF file paths

Behavior:

  • Accepts multiple file drops
  • Filters to .pdf files only
  • Calls on_drop_callback(paths) with the filtered list

Utilities

app/utils.py

A collection of helper functions used across the application (~360 lines).

Resource Management

Function Signature Description
resource_path (rel: str) -> str Resolves paths for both dev mode and PyInstaller bundles. Uses sys._MEIPASS when frozen.

Theme Helpers

Function Signature Description
_make_palette (dark: bool) -> QPalette Creates a complete QPalette with all color roles for dark or light theme
_paint_bg (widget: QWidget) Forces a QWidget subclass to honour stylesheet backgrounds via QStyleOption painting

Page Parsing

Function Signature Description
parse_pages (text: str, total: int) -> list[int] Parses "1,3,5-7" into [0, 2, 4, 5, 6] (zero-indexed). Raises ValueError if out of range.

File Dialogs

Function Signature Description
pick_pdfs (parent) -> list[str] Multi-select PDF file dialog
pick_folder (parent) -> str Directory selection dialog

UI Factories

These create pre-styled widgets used by BasePage and tools:

Function Signature Returns Description
ToolHeader (icon_name, title, desc) QWidget Fixed header with icon button (40×40), title, and description
ActionBar (btn_text, slot) (QWidget, QPushButton) Bottom bar with primary action button
section (text) QLabel Uppercase section label
info_lbl () QLabel Empty info/status label
primary_btn (text) QPushButton Teal primary button
danger_btn (text) QPushButton Red danger button
scrolled (widget) QScrollArea Wraps widget in frameless scroll area

Compression Pipeline

_compress_pdf(
    src: str,                # source PDF path
    dst: str,                # destination PDF path
    level: str = "recommended",  # "extreme" | "recommended" | "low"
    progress_fn = None       # callback(stage: str, current: int, total: int) → False to cancel
) -> tuple[int, int]         # (before_size, after_size) in bytes

Three-pass strategy — runs all available passes, keeps the smallest result:

Pass Engine What it Does
A Ghostscript (if installed) Full page re-render with image downsampling
B PyMuPDF (fitz) Metadata scrub, font subsetting, image rewrite
C pikepdf (if installed) Structural optimization, object stream compression

Compression levels:

Level DPI Quality Grayscale
"extreme" 72 40% Yes
"recommended" 150 65% No
"low" 300 80% No

Raises: CancelledError if cancelled, ValueError if no compression gain, RuntimeError if no engines available.

Ghostscript Detection

Function Signature Description
_find_gs () -> str | None Searches PATH, then Windows common install paths for gswin64c, gswin32c, or gs

Exceptions

Class Description
CancelledError Raised when user cancels a long-running operation

Internationalization (i18n)

app/i18n.py

The translation system (~150 lines). All user-facing strings go through the t() function.

Core Function

from app.i18n import t

t("app.name")                          # → "PDFApps"
t("tool.split.desc")                   # → "Split a PDF into multiple files"
t("update.available", version="1.8")   # → format string with kwargs

Fallback chain: current language → English → raw key string.

Functions Reference

Function Signature Description
t (key: str, **kwargs) -> str Returns translated string. Supports str.format(**kwargs).
init () Initializes i18n: loads JSON, detects language. Called once at import.
get_language () -> str Returns active language code (e.g., "pt")
set_language (lang: str) Changes language and saves to config
available_languages () -> list[str] Returns list of available language codes
get_recent_files () -> list[str] Returns recent file paths from config (max 10)
add_recent_file (path: str) Adds path to recent list (deduplicates, max 10)

Internal Functions

Function Description
_load_translations() Loads translations.json into global _TRANSLATIONS dict
_detect_system_language() Detects OS language via Windows kernel32.GetUserDefaultUILanguage() or locale.getlocale()
_load_config_language() Reads saved language from ~/.pdfapps_config.json
_save_config_language(lang) Writes language to config file

Language Detection Priority

  1. Saved preference in ~/.pdfapps_config.json
  2. Windows API: kernel32.GetUserDefaultUILanguage() → maps LCID to language code
  3. Unix: locale.getlocale() → extracts language prefix
  4. Fallback order: PT → ES → FR → DE → ZH → IT → NL → EN

app/translations.json Structure

{
  "en": {
    "app.name": "PDFApps",
    "app.subtitle": "PDF Editor",
    "app.credits": "pypdf  +  PySide6",
    "workspace.title": "Workspace",
    "nav.split": "Split",
    "tool.split.name": "Split PDF",
    "tool.split.desc": "Split a PDF into multiple files",
    "tool.split.btn": "Split",
    "tool.split.source": "Source PDF",
    "update.available": "Version {version} is available!",
    ...
  },
  "pt": { ... },
  "es": { ... },
  "fr": { ... },
  "de": { ... },
  "zh": { ... },
  "it": { ... },
  "nl": { ... }
}

Supported languages: EN, PT, ES, FR, DE, ZH, IT, NL (8 total, 300+ keys each)

How to Add a Translation Key

  1. Add the key to all 8 language blocks in app/translations.json
  2. Use t("your.new.key") in code
  3. For format strings: t("your.key", name=value) — use {name} in the JSON value

How to Add a New Language

  1. Add a new block in translations.json with the language code as key
  2. Add the code to _lang_labels dict in app/window.py
  3. Add installer/uninstaller translations in installer.py (_INSTALLER_STRINGS)
  4. Add website translations in docs/index.html (the const T = {...} block)
  5. Update the LCID mapping in _detect_system_language() if needed

Viewer System

The viewer is split into two layers: a high-level panel (UI) and a low-level canvas (rendering).

app/viewer/panel.pyPdfViewerPanel(QWidget)

The viewer UI wrapper. Each open tab in the viewer holds one PdfViewerPanel.

Layout

┌──────────────────────────────────────────────────┐
│ QSplitter (horizontal)                           │
│ ┌──────────┬──────────────────────────────────┐  │
│ │ TOC tree │ _SelectCanvas (scroll area)      │  │
│ │ (hidden  │ continuous page scroll           │  │
│ │  if no   │                                  │  │
│ │  outline)│                                  │  │
│ └──────────┴──────────────────────────────────┘  │
├──────────────────────────────────────────────────┤
│ [🔍 Search input] [▲] [▼] [✕]  n/m results      │  ← hidden by default
├──────────────────────────────────────────────────┤
│ Selection status: "Drag to select and copy"      │
└──────────────────────────────────────────────────┘

The viewer's own header (_hdr) exists but is permanently hidden — all viewer controls (open, bookmarks, zoom, page nav, night mode, print, search) live in the MainWindow workspace bar at the top. _refresh_viewer_top_buttons() is called whenever a tab changes to sync the bookmark/night-mode buttons to the active viewer.

Bookmarks panel

_populate_toc(doc) reads doc.get_toc() (PyMuPDF) which returns [[level, title, page], ...]. Builds a QTreeWidget hierarchy using a stack to track levels. Each item stores the 0-indexed page in Qt.ItemDataRole.UserRole. On click, _on_toc_clicked calls _canvas.scroll_to_page(idx) and sets the scrollbar value. The panel and _toc_top_btn (in MainWindow) are hidden if the PDF has no outline.

Night reading mode

_canvas.set_night_mode(active) toggles a flag, forces the canvas background to black, and invalidates the page cache so all pages re-render through _PageJob with night_mode=True. The job calls pix.invert_irect() after get_pixmap() but before PNG conversion. Independent from the app theme — works with light app + night-mode PDF or vice versa. State is per-_SelectCanvas (per tab).

Methods

Loading & State:

Method Signature Description
load (path: str) Loads a PDF. Handles password-protected files (shows dialog). Updates header.
current_path () -> str Returns path of currently loaded PDF

Search (Ctrl+F):

Method Description
_toggle_search() Shows/hides the search bar
_close_search() Closes search bar, clears highlights
_on_search_text_changed(text) Triggers search as user types
_do_search(query) Searches all pages for text, builds highlight list
_search_next() Moves to next match
_search_prev() Moves to previous match
_update_search_highlight() Updates highlight colors and scrolls to current match

Navigation:

Method Description
_prev_page() Scrolls to previous page
_next_page() Scrolls to next page
_update_page_label() Updates "Page X / Y" display based on scroll position
_zoom_fit() Resets zoom to fit width

Other:

Method Description
_print_pdf() Opens system print dialog and renders all pages
update_theme(dark) Updates all icon colors for theme change
_on_text_copied(text) Shows "Copied!" status for 4 seconds
eventFilter(obj, event) Handles Ctrl+Wheel zoom and viewport resize

app/viewer/canvas.py_SelectCanvas(QWidget)

The low-level continuous-scroll PDF renderer (~450 lines). This is the most performance-critical component.

Constants

Constant Value Description
_PAGE_GAP 4 Pixels between pages in continuous scroll
_BUFFER_PGS 2 Extra pages to pre-render above/below visible area
_MAX_THREADS 2 Maximum concurrent render workers
_NOTE_ICON_SIZE 22 Annotation note icon size in pixels

Architecture

_SelectCanvas
├── _entries: list[_PageEntry]        # one per page
│   └── _PageEntry
│       ├── y_off: int                # vertical offset in canvas
│       ├── w, h: int                 # page dimensions at current zoom
│       ├── pixmap: QPixmap | None    # rendered image (None = not yet rendered)
│       ├── words: list | None        # text words for search/selection
│       └── annots: list[(QRect, str)]# PDF annotations
│
├── QThreadPool (max 2 workers)
│   └── _PageJob (QRunnable)          # renders one page in background
│       ├── Opens fitz.Document
│       ├── Renders at zoom × devicePixelRatio
│       ├── Extracts word list
│       └── Emits _RenderSignals.page_ready
│
└── paintEvent()                      # only draws visible entries

Signals

Signal Type Description
zoom_changed Signal(int) Current zoom percentage (e.g., 150)
text_copied Signal(str) Text copied to clipboard (empty if no text layer)

Key Methods

Document Management:

Method Signature Description
load (doc, page_idx=0, path="", password="") Loads fitz.Document, creates page entries, triggers initial render
close_doc () Closes document, clears all page entries and pixmaps
page_count () -> int Returns total number of pages

Navigation & Zoom:

Method Signature Description
scroll_to_page (idx: int) -> int Returns Y offset for given page index
page_at_y (y: int) -> int Returns page index at given Y position
zoom_in () Zoom in by 1.25× (max 4.0×)
zoom_out () Zoom out by ÷1.25 (min 0.2×)
zoom_reset () Reset zoom to fit-width

Search:

Method Signature Description
set_search_highlights (highlights: list, current: int=-1) Sets search result rects to paint. Each item: (page_idx, QRect).

Rendering Pipeline (internal):

Method Description
_layout_and_schedule() Calculates page dimensions at current zoom, positions entries vertically, loads annotations, schedules visible page renders
_visible_range() Returns (first_page, last_page) indices currently visible (+ buffer)
_schedule_visible() Queues _PageJob for each unrendered visible page
_on_page_ready(gen, idx, pixmap, words) Callback from worker thread; stores pixmap and words in _entries[idx]
_invalidate_and_relayout() Clears all pixmaps and re-layouts (e.g., after zoom change)
_load_annotations() Reads PDF annotations (comments/notes) from all pages

Text Selection (internal):

Method Description
_find_closest_word(pos) Returns (page_idx, word_idx) for the word nearest to screen position
_compute_selection() Builds selection rectangles and extracts text between drag start and end
_clear_selection() Resets selection state

Paint & Input:

Method Description
paintEvent(_) Paints: background → page pixmaps → placeholder text → annotations → search highlights → selection rects
mousePressEvent(e) Starts text selection drag
mouseMoveEvent(e) Updates selection while dragging
mouseReleaseEvent(e) Completes selection, copies text to clipboard
contextMenuEvent(e) Shows right-click menu (Copy / Delete annotation)

_PageJob(QRunnable) — Background Page Renderer

Runs in QThreadPool. Each job:

  1. Opens fitz.Document(path) independently (thread-safe)
  2. Renders page at zoom × devicePixelRatio for crisp display
  3. Extracts word list via page.get_text("words")
  4. Emits page_ready(generation, page_index, pixmap, words)

The generation counter prevents stale results from a previous zoom level being applied.

app/viewer/presentation.pyPresentationWidget(QWidget)

A standalone fullscreen widget for slideshow-style PDF viewing. Launched via F5 or the toolbar button.

  • Opens as a top-level window (Qt.WindowType.Window) in fullscreen state
  • Renders one page at a time, scaled to fit the screen
  • Black background with the page centered
  • Cursor hidden for a clean presentation look

Keyboard Navigation:

Key Action
Right, Down, Space, PageDown Next page
Left, Up, Backspace, PageUp Previous page
Home First page
End Last page
Escape Close presentation

Page counter overlay: Shows "3 / 15" at the bottom center, auto-hides after 3 seconds via QTimer, re-shown on every page change.

Methods:

Method Description
__init__(path, password, start_page, total_pages) Creates widget, initialises all attributes before setWindowState (avoids resizeEvent crash)
_render() Opens fitz doc, renders current page at screen-fit zoom × DPR, stores pixmap
_update_counter() Updates counter label text and position, starts hide timer
paintEvent(_) Fills black, draws centered pixmap
keyPressEvent(e) Handles all navigation keys
resizeEvent(_) Repositions counter and re-renders (guarded by _ready flag)

Important implementation detail: All instance attributes (_counter, _hide_timer, etc.) must be created before calling setWindowState(WindowFullScreen), because Qt fires resizeEvent immediately during that call.

Fullscreen Mode (F11)

Fullscreen is handled directly in MainWindow._toggle_fullscreen():

  • Enter: Hides _workspace_bar, _sidebar, and status bar; calls showFullScreen()
  • Exit: Restores visibility (respecting _sidebar_collapsed state); calls showMaximized()
  • Bound to F11 via QShortcut

Editor System

The editor is split into three files: the tool tab (UI + logic), the canvas (rendering + interaction), and dialogs.

app/editor/tab.pyTabEditar(QWidget)

The visual PDF editor tool (~650 lines). Does not inherit from BasePage — it has its own layout because the editor needs a canvas + control panel side-by-side.

Layout

┌──────────────────────────────────────────────────────────┐
│ ToolHeader (icon, "Edit PDF", description)               │
├──────────────────────────┬───────────────────────────────┤
│                          │ Control panel (fixed 380px)   │
│ PdfEditCanvas            │ ┌───────────────────────────┐ │
│ (QScrollArea)            │ │ PDF file selector         │ │
│                          │ │ Page navigation (‹ n/m ›) │ │
│ Shows current page       │ │                           │ │
│ with overlays drawn      │ │ Mode buttons:             │ │
│ on top                   │ │  [Redact/Censor]          │ │
│                          │ │  [Add Text]               │ │
│                          │ │  [Add Image]              │ │
│                          │ │  [Highlight]              │ │
│                          │ │  [Note/Comment]           │ │
│                          │ │  [Fill Forms]             │ │
│                          │ │  [Edit Text]              │ │
│                          │ │  [Select]                 │ │
│                          │ │                           │ │
│                          │ │ [Undo] [Redo] [Clear]     │ │
│                          │ └───────────────────────────┘ │
├──────────────────────────┴───────────────────────────────┤
│ ActionBar — "Apply and Save"                             │
└──────────────────────────────────────────────────────────┘

9 Editing Modes

# Mode Mouse Action Creates Overlay Dialog
1 Redact/Censor Draw rectangle {type: "redact", rect, fill, page}
2 Add Text Click point {type: "text", point, text, size, color, page} _TextDialog
3 Add Image Draw rectangle {type: "image", rect, path, page} File picker
4 Highlight Draw rectangle {type: "highlight", rect, color, page}
5 Note/Comment Click point {type: "note", point, text, page} _NoteDialog
6 Fill Forms Auto-detect Form field table
7 Edit Text Click on text {type: "text_edit", bbox, old, new, page} _TextEditDialog
8 Signature Draw rectangle {type: "signature", rect, path, page} _SignatureDialog
9 Select Text selection

Overlay System

Edits are stored as a list of dicts in self._pending. Each dict contains:

{
    "type": "redact",           # overlay type
    "page": 0,                  # page index
    "rect": fitz.Rect(...),     # bounding rectangle (PDF coordinates)
    "fill": (0, 0, 0),          # fill color (RGB, 0-1 range)
}

Overlays are painted by PdfEditCanvas.paintEvent() on top of the PDF pixmap. When the user clicks "Apply and Save", all overlays are applied permanently to the PDF via fitz.

Methods

PDF Management:

Method Description
_pick_pdf() Opens file picker
_load_pdf(path) Loads PDF, loads existing annotations and form fields
_close_pdf() Closes PDF and resets all state
auto_load(path) Auto-loads PDF if the field is empty (called by MainWindow)
_load_existing_annotations() Reads existing PDF text annotations as note overlays
_load_form_fields(path) Reads PDF form fields into editable table

Editing:

Method Description
_on_mode_btn(btn) Handles mode button click, updates cursor and canvas state
_on_rect(pdf_rect) Called when user draws a rectangle on canvas
_on_point(pdf_pt) Called when user clicks a point on canvas
_add(edit) Adds edit dict to _pending list, clears redo stack
_undo() Removes last edit, pushes to redo stack
_redo() Pops from redo stack, pushes to pending
_on_note_deleted(overlay) Removes deleted note from pending list
_clear_pending() Clears all pending edits and redo stack
_pick_image() Opens image file picker (for Add Image mode)

Saving:

Method Description
_run() Applies all pending edits to PDF via fitz and saves to user-selected path
_apply_forms(out) Applies form field changes from the table widget

Navigation:

Method Description
_update_nav() Updates "Page X / Y" label and button states
_prev_page() Goes to previous page
_next_page() Goes to next page

Theme:

Method Description
update_theme(dark) Updates mode button colors and icons for theme

app/editor/canvas.pyPdfEditCanvas(QWidget)

Continuous-scroll canvas for the editor. Renders all pages vertically with a 4px gap. Overlays are drawn at the correct page offset.

Signals

Signal Type Description
rect_selected Signal(int, object) User finished drawing a rectangle (page_idx, fitz.Rect in PDF coords)
point_clicked Signal(int, object) User clicked a point (page_idx, fitz.Point in PDF coords)
note_deleted Signal(dict) User deleted a note overlay via context menu
zoom_changed Signal(int) Zoom percentage changed

Methods

Method Signature Description
load (path: str) Opens PDF with fitz, renders first page
set_page (idx: int) Sets current page index (used by tab navigation)
set_overlays (overlays: list) Sets ALL overlay dicts to paint (all pages)
set_select_mode (active: bool) Toggles text selection cursor
get_span_at (page_idx, pdf_pt) -> dict|None Returns closest text span on given page
close_doc () Fully closes document and resets canvas
release_doc () Closes fitz doc to release file lock
zoom_in / zoom_out / zoom_reset () Zoom controls (1.25× steps)
page_count () -> int Total pages
scroll_to_page (idx: int) -> int Returns Y offset for a page index
page_at_y (y: int) -> int Returns page index at Y position
_render () Renders ALL pages at zoom × DPR, stacked vertically
_page_and_local (sx, sy) -> (page_idx, lx, ly) Converts screen coords to page-local coords
_to_pdf (page_idx, sx, sy) Converts page-local screen → PDF coordinates
paintEvent (_) Paints all page pixmaps + overlays with page Y offsets
mouseReleaseEvent (e) Emits rect_selected(page_idx, rect) or point_clicked(page_idx, point)
contextMenuEvent (e) Right-click menu to delete note annotations

app/editor/dialogs.py — Editor Dialogs

Dialog Purpose Key Attributes
_PdfPasswordDialog Password input for encrypted PDFs .password() → str
_TextDialog New text insertion (text, size, color) .edit (QLineEdit), .font_size (QSpinBox), .color_tuple() → RGB
_TextEditDialog Edit existing text (shows original) .new_text() → str
_NoteDialog Note/comment input .edit (QTextEdit)
_SignatureDialog Create signature (3 tabs: draw/type/import) .result_path() → str (PNG path)
_SignatureCanvas Freehand drawing widget (used inside _SignatureDialog) .to_image() → QImage, .is_empty(), .clear()

Tool System

All tools live in app/tools/ and inherit from BasePage. They follow the same pattern:

from app.base import BasePage
from app.widgets import DropFileEdit
from app.utils import section, info_lbl
from app.i18n import t

class TabMyTool(BasePage):
    def __init__(self, status_fn):
        super().__init__(
            icon="fa5s.icon-name",
            title=t("tool.mytool.name"),
            desc=t("tool.mytool.desc"),
            btn_text=t("tool.mytool.btn"),
            status_fn=status_fn,
        )
        # Add widgets to self._form
        self._src = DropFileEdit(t("tool.mytool.source"))
        self._form.addWidget(self._src)
        # ... options, output selector, etc.

    def _run(self):
        # Called when action button is clicked
        path = self._src.path()
        if not path:
            return
        # Process PDF...
        self._status(t("app.ready"))

Tool Reference

# Class File Library Description
1 TabDividir split.py pypdf Split by page ranges. Table widget for defining ranges + output names.
2 TabJuntar merge.py pypdf Merge multiple PDFs. Drag-reorder list + move up/down buttons.
3 TabRotar rotate.py pypdf Rotate pages. Page range input + angle combo (90°/180°/270°).
4 TabExtrair extract.py pypdf Extract page subsets. Page range input.
5 TabReordenar reorder.py pypdf Drag-reorder pages. Visual page list with up/down/delete/reset.
6 TabComprimir compress.py gs/fitz/pikepdf 3-pass compression. Level combo (Extreme/Recommended/Low). Shows before/after sizes.
7 TabEncriptar encrypt.py pypdf Encrypt/decrypt. Mode toggle, owner + user password fields.
8 TabMarcaDagua watermark.py pypdf Overlay watermark PDF. Layer position (below/above content).
9 TabOCR ocr.py pytesseract/fitz Add text layer to scanned PDFs. Language combo (PT/EN/ES/FR/DE). Progress dialog.
10 TabConverter convert.py fitz/docx Export to PNG/JPG/DOCX/TXT. DPI selection for images. Output folder.
11 TabEditar editor/tab.py fitz Visual editor (see Editor System). Not a BasePage subclass.
12 TabImport import_pdf.py fitz/PIL Import TXT/images/Markdown → PDF. Type combo, file list. Batch support.
13 TabInfo info.py pypdf Read-only metadata display: path, size, pages, author, title, dates, fonts, encryption.

Adding a New Tool

  1. Create app/tools/my_tool.py with a class inheriting BasePage
  2. Add translation keys to app/translations.json (all 8 languages):
    "nav.my_tool": "My Tool",
    "tool.my_tool.name": "My Tool",
    "tool.my_tool.desc": "Description of what it does",
    "tool.my_tool.btn": "Run",
    "tool.my_tool.source": "Source PDF"
  3. Import and register in app/window.pyNAV_ITEMS:
    from app.tools.my_tool import TabMyTool
    # Add to NAV_ITEMS list:
    (t("nav.my_tool"), "fa5s.icon-name", TabMyTool),
  4. The tool automatically appears in the sidebar and integrates with navigation, theme switching, and auto-load.

Auto-Updater

app/updater.py

Checks for updates via the GitHub Releases API and downloads/installs them.

Functions

Function Signature Description
check_for_update () -> dict | None Fetches latest release from GitHub API. Returns release dict if remote version > local, else None.
_find_asset (release: dict) -> dict | None Finds platform-specific binary: PDFAppsSetup.exe (Win), PDFApps-macOS.zip, PDFApps-Linux.tar.gz
_download (url, dest, signals) Downloads file with progress reporting. Emits signals.progress(0-100), signals.finished(path), or signals.error(msg).
_apply_update_windows (downloaded_installer: str) Runs installer with ShellExecuteW(None, "runas", ...). Raises OSError if return ≤ 32.
_apply_update_unix (downloaded: str) Replaces running binary and restarts via os.execv(). Creates backup, restores on failure.

Classes

_Signals(QObject) — cross-thread communication:

Signal Type When
progress Signal(int) Download progress 0-100
finished Signal(str) Download completed (path to file)
error Signal(str) Download failed (error message)

UpdateDialog(QDialog) — download and install UI:

Method Description
__init__(release, parent) Shows version info, install button, progress bar
_start_download() Creates QThread worker, starts download
_on_progress(pct) Updates progress bar
_on_finished(path) Applies update (platform-specific), shows restart message, quits app
_on_error(msg) Shows error in status label

Critical Design Constraint

The installer must never use uac_admin=True in its PyInstaller manifest. It must self-elevate via ShellExecuteW("runas") at startup. This ensures backward compatibility — old app versions using subprocess.Popen can still launch the installer without WinError 740.


Installer & Uninstaller

Both use tkinter (not PySide6) so they run as lightweight standalone executables.

installer.py (~970 lines)

Cross-platform GUI installer with:

  • 8-language support — auto-detects OS language, same detection as the main app

  • Installation steps:

    1. Copy PDFApps.exe and PDFAppsUninstall.exe to chosen folder
    2. Create desktop shortcut (optional)
    3. Create Start Menu / application menu entry (optional)
    4. Register PDF file association in Windows registry (optional)
    5. Download and install Tesseract OCR (optional)
    6. Download and install Ghostscript (optional)
  • Platform-specific behavior:

    • Windows: Registry keys (HKCU\Software\PDFApps, ProgID PDFApps.Document), shortcuts via shell
    • macOS: .app bundle creation, Homebrew integration
    • Linux: .desktop file, update-desktop-database
  • Self-elevation: Calls ShellExecuteW("runas") at startup if not already admin (Windows only). The spec has uac_admin=False.

uninstaller.py (~230 lines)

  • Confirmation dialog → removes installation folder, shortcuts, registry entries
  • 8-language support with same OS detection

PyInstaller Specs

installer.spec — bundles PDFApps.exe + PDFAppsUninstall.exe + icon.ico as data files. Excludes PySide6/pypdf/qtawesome (not needed). uac_admin=False.

uninstaller.spec — bundles icon.ico. Excludes PySide6/pypdf/qtawesome.


Build & Release

Build Order

# 1. Build main app
python -m PyInstaller --clean --noconfirm pdfapps.spec

# 2. Build uninstaller (must exist before installer)
python -m PyInstaller --clean --noconfirm uninstaller.spec

# 3. Build installer (bundles PDFApps.exe + PDFAppsUninstall.exe from dist/)
python -m PyInstaller --clean --noconfirm installer.spec

Important: Always use venv/Scripts/python.exe -m PyInstaller, not the pyinstaller command directly — it may silently use the wrong Python.

PyInstaller Data Files

The main app spec (pdfapps.spec) must explicitly include:

Data Why
qtawesome/fonts Font Awesome icons fail at runtime without bundled fonts
icon.ico Window icon
pdfapps.svg Sidebar brand logo
app/translations.json i18n strings

Version Bumping

Before any release, update version in all three locations:

File Field
app/constants.py APP_VERSION = "X.Y.Z"
installer.py APP_VERSION = "X.Y.Z"
docs/index.html v1.X.Y in hero section

Release Workflow

# 1. Bump version in all 3 files
# 2. Commit
git add app/constants.py installer.py docs/index.html
git commit -m "chore: bump version to v1.8.0"

# 3. Tag and push — triggers CI
git tag v1.8.0
git push origin main v1.8.0

CI/CD (.github/workflows/build.yml)

Triggered on v* tag push. Builds on three platforms (matrix strategy):

Platform Builds Release Asset
windows-latest PDFApps.exe + PDFAppsSetup.exe + PDFAppsUninstall.exe PDFApps.exe, PDFAppsSetup.exe
ubuntu-22.04 PDFAppsPDFApps-Linux.tar.gz PDFApps-Linux.tar.gz
macos-latest PDFAppsPDFApps-macOS.zip PDFApps-macOS.zip

Both PDFApps.exe (bare app) and PDFAppsSetup.exe (installer) are published. The bare exe is needed for backward compatibility with old updater versions that download PDFApps.exe.


Key Data Flows

PDF Loading (Viewer)

User opens PDF (file dialog / drag & drop / CLI argument / recent menu)
  │
  ▼
MainWindow._load_and_track(path)
  ├── If current tab has a document → _add_viewer_tab(path) (new tab)
  └── PdfViewerPanel.load(path)
        ├── If encrypted → show _PdfPasswordDialog
        ├── Open fitz.Document(path, password)
        ├── Update header (filename, page count)
        └── _SelectCanvas.load(doc)
              ├── Create _PageEntry slots (one per page)
              ├── Calculate page layouts at current zoom
              ├── Render first page synchronously
              └── _schedule_visible()
                    ├── Determine viewport + buffer range
                    └── Queue _PageJob for each unrendered page
                          ├── (in thread) fitz renders page → QPixmap
                          ├── (in thread) Extract word list for search
                          └── Emit page_ready signal
                                └── Canvas stores pixmap, calls update()

Tool Execution

User selects tool from sidebar
  │
  ▼
MainWindow._on_nav_clicked(item)
  ├── stack.setCurrentIndex(row)     # show tool UI
  ├── stack.setVisible(True)          # show tool stack
  ├── _tab_container.setVisible(False)# hide viewer
  └── _try_auto_load(row)            # load current PDF if tool supports it
        │
        ▼
User fills form → clicks action button
  │
  ▼
BasePage._run() (overridden by tool)
  ├── Validate inputs
  ├── Process PDF (pypdf / fitz / pytesseract / etc.)
  ├── Show progress dialog if needed
  ├── Save output file
  └── Update status bar

Theme Switch

User clicks theme button (☀ / 🌙)
  │
  ▼
MainWindow._toggle_theme()
  ├── self._dark_mode = not self._dark_mode
  └── _apply_theme()
        ├── Set QSS globally: STYLE or STYLE_LIGHT
        ├── Update QPalette via _make_palette(dark)
        ├── Recreate sidebar toggle button icons
        ├── Update all sidebar nav icons with new color
        ├── Update all workspace bar button icons
        ├── Call update_theme(dark) on every PdfViewerPanel
        ├── Call update_theme(dark) on TabEditar
        └── Save preference to ~/.pdfapps_config.json

Compression Pipeline

TabComprimir._run()
  │
  ▼
_compress_pdf(src, dst, level, progress_fn)
  ├── Pass A: Ghostscript (if installed)
  │     └── Re-render entire PDF with image downsampling
  │         DPI and quality depend on level
  │
  ├── Pass B: PyMuPDF (fitz)
  │     ├── Scrub metadata
  │     ├── Subset fonts
  │     ├── Rewrite images with reduced quality
  │     └── Save with deflate compression
  │
  ├── Pass C: pikepdf (if installed)
  │     └── Structural optimization + object stream compression
  │
  └── Compare all results → keep smallest → copy to dst
      Return (before_size, after_size)

Update Flow (Windows)

App startup
  │
  ▼
MainWindow._check_for_updates_async()
  └── QThread → check_for_update()
        ├── GET https://api.github.com/repos/.../releases/latest
        ├── Compare tag_name vs APP_VERSION
        └── If newer → emit _update_ready signal
              │
              ▼
        _notify_update() → show notification dialog
              │
              ▼
User clicks update button → UpdateDialog
  ├── _start_download() → QThread → _download()
  │     ├── Downloads PDFAppsSetup.exe to temp dir
  │     └── Progress bar updates via signals
  │
  └── _on_finished(path)
        ├── _apply_update_windows(path)
        │     └── ShellExecuteW(None, "runas", path) → UAC prompt
        │           └── Installer runs with admin privileges
        ├── Show "restart" message
        └── QApplication.quit()

Conventions & Guidelines

Code Style

  • Commits: English, conventional commits format (feat:, fix:, chore:, refactor:)
  • Language: Code and comments in English; user-facing strings via t() i18n system
  • Imports: Standard library → PySide6/Qt → third-party → app modules

Icons & DPI

  • Always use QPushButton + setIcon() + setIconSize() — Qt handles DPI scaling automatically
  • Never use QLabel + pixmap with hardcoded setDevicePixelRatio()
  • Every qta.icon() with a color must have a corresponding update in _apply_theme() for theme switching
  • Store buttons as self._ instance attributes if they need icon updates later

Theme Awareness

  • Use color constants from app.constants — never hardcode hex in setStyleSheet()
  • After adding any icon button, verify it updates in _apply_theme() / update_theme()
  • Dialogs: Use _theme_colors(parent) from editor/dialogs.py to get (pri, sec, bg, border) based on the parent's _dark_mode. Never use TEXT_PRI / TEXT_SEC directly in dialog setStyleSheet() — those are always dark.
  • Canvas: Both viewer and editor canvas use _bg_color updated via set_dark_mode(dark) — never hardcode BG_INNER in paintEvent()
  • Test every UI change in both dark and light mode

Security

When writing code that handles files, user input, or external processes, follow these rules:

File operations:

  • Always sanitize user-provided filenames with os.path.basename() before joining to an output directory — prevents path traversal (../../etc/evil)
  • Never use tar.extractall() or zip.extractall() without validating member paths — prevents ZIP slip attacks
  • Reject archive members that contain .., start with /, or are symlinks/hardlinks

Subprocess calls:

  • Never use shell=True with user-controlled input
  • Use list form (["cmd", "arg"]), not string form ("cmd arg")
  • Escape parameters when constructing PowerShell or shell commands (use single quotes with '' escaping)
  • Don't forward sys.argv to elevated processes — pass empty string or validated args only

Installer:

  • The installer spec must have uac_admin=False — it self-elevates via ShellExecuteW("runas")
  • This ensures backward compatibility with old updaters using subprocess.Popen

Dependencies:

  • Run pip-audit before releases to check for known CVEs
  • Keep minimum versions in requirements.txt updated when vulnerabilities are patched

Before a Release

  • Version bumped in all 3 locations (constants.py, installer.py, docs/index.html)
  • All translation keys present in all 8 languages
  • Tested in both dark and light mode
  • Tested tool execution with real PDFs
  • Tested failure paths (invalid input, missing files, cancelled operations)
  • Tested backward compatibility (old updater versions can launch the new installer)
  • Run pip-audit — zero known vulnerabilities
  • Run the app from source: python pdfapps.py

License

PDFApps is licensed under the MIT License. By contributing, you agree that your contributions will be licensed under the same license.