Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
name: Tests

on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: tests-${{ github.ref }}
cancel-in-progress: true

jobs:
pytest:
name: pytest (Ubuntu, Python 3.14)
runs-on: ubuntu-latest
timeout-minutes: 20

steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up Python 3.14
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.14'

- name: Install Qt system deps
# Two packages where build.yml's "Install Linux system deps" has
# three: that job freezes the app with PyInstaller, this one only
# runs the suite, with QT_QPA_PLATFORM=offscreen set on the "Run
# test suite" step. PySide6 facts below are for 6.11.2.
#
# libxcb-cursor0 is left out: libxcb-cursor.so.0 is DT_NEEDED
# only by libqxcb.so, libQt6XcbQpa.so.6 and the two
# xcbglintegrations plugins, and a full offscreen run of this
# suite under LD_DEBUG=files (Ubuntu 26.04) loaded none of them.
#
# libQt6Gui.so.6 has libEGL.so.1 and libxkbcommon.so.0 as
# DT_NEEDED entries; with either one absent, `from PySide6 import
# QtGui` raises ImportError. On image ubuntu-24.04
# 20260927.320.1, apt-get installed libegl1 as a new package and
# found libxkbcommon0 already installed.
#
# libqoffscreen.so and libQt6Gui.so.6 also need libX11.so.6 and
# libGL.so.1, and libX11.so.6 needs libxcb.so.1. None of the
# packages apt-get installed or upgraded there ships any of the
# three, so the image already had them. Of these three, only
# libGL.so.1 depends on the image alone: libx11-6 and libxcb1
# would also come in with libegl1 (via libegl-mesa0 on Ubuntu
# 24.04 and 26.04), libgl1 would not. A missing one fails the
# same way, with an ImportError naming it.
run: |
sudo apt-get update
sudo apt-get install -y libegl1 libxkbcommon0

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
# requirements-dev.txt carries the test runners (pytest,
# pytest-qt) plus ruff and PyYAML, which parts of the suite
# invoke as subprocesses / import directly rather than
# skipping when absent.
pip install -r requirements-dev.txt

- name: Run test suite
env:
# Mandatory: the suite instantiates real QWidgets, and the
# default xcb plugin aborts with "could not connect to
# display" on a headless runner.
QT_QPA_PLATFORM: offscreen
run: |
python -m pytest -q -rs
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ artifacts-*/
artefacts-*/

# Testes / temporários
# coverage>=7.16.1 is declared in requirements-dev.txt, so `coverage run
# -m pytest` / `--cov` leave these behind in the repo root.
.coverage
.coverage.*
htmlcov/
test_ctrl_scroll.py
test_*.pdf
test_*.xlsx
Expand Down
44 changes: 22 additions & 22 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ python pdfapps.py
|---------|---------|
| `PySide6` | Qt 6 GUI framework |
| `pypdf` | PDF manipulation (split, merge, encrypt, metadata) |
| `pymupdf` (fitz) | PDF rendering (viewer, editor, compression) |
| `pymupdf` | 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) |
Expand Down Expand Up @@ -614,7 +614,7 @@ _compress_pdf(
| 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 |
| **B** | PyMuPDF | Metadata scrub, font subsetting, image rewrite |
| **C** | pikepdf (if installed) | Structural optimization, object stream compression |

**Compression levels:**
Expand Down Expand Up @@ -847,7 +847,7 @@ _SelectCanvas
│
├── QThreadPool (max 2 workers)
│ └── _PageJob (QRunnable) # renders one page in background
│ ├── Opens fitz.Document
│ ├── Opens pymupdf.Document
│ ├── Renders at zoom × devicePixelRatio
│ ├── Extracts word list
│ └── Emits _RenderSignals.page_ready
Expand All @@ -868,7 +868,7 @@ _SelectCanvas

| Method | Signature | Description |
|--------|-----------|-------------|
| `load` | `(doc, page_idx=0, path="", password="")` | Loads fitz.Document, creates page entries, triggers initial render |
| `load` | `(doc, page_idx=0, path="", password="")` | Loads pymupdf.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 |

Expand Down Expand Up @@ -921,7 +921,7 @@ _SelectCanvas

Runs in `QThreadPool`. Each job:

1. Opens `fitz.Document(path)` independently (thread-safe)
1. Opens `pymupdf.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)`
Expand Down Expand Up @@ -954,7 +954,7 @@ A standalone fullscreen widget for slideshow-style PDF viewing. Launched via F5
| 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 |
| `_render()` | Opens PyMuPDF 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 |
Expand Down Expand Up @@ -1029,12 +1029,12 @@ 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)
"rect": pymupdf.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.
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 PyMuPDF.

#### Methods

Expand Down Expand Up @@ -1067,7 +1067,7 @@ Overlays are painted by `PdfEditCanvas.paintEvent()` on top of the PDF pixmap. W

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

**Navigation:**
Expand All @@ -1092,22 +1092,22 @@ Continuous-scroll canvas for the editor. Renders all pages vertically with a 4px

| 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) |
| `rect_selected` | `Signal(int, object)` | User finished drawing a rectangle (page_idx, pymupdf.Rect in PDF coords) |
| `point_clicked` | `Signal(int, object)` | User clicked a point (page_idx, pymupdf.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 |
| `load` | `(path: str)` | Opens PDF with PyMuPDF, 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 |
| `release_doc` | `()` | Closes PyMuPDF 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 |
Expand Down Expand Up @@ -1174,13 +1174,13 @@ class TabMyTool(BasePage):
| 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. |
| 6 | `TabComprimir` | `compress.py` | gs/PyMuPDF/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](#editor-system)). Not a BasePage subclass. |
| 12 | `TabImport` | `import_pdf.py` | fitz/PIL | Import TXT/images/Markdown → PDF. Type combo, file list. Batch support. |
| 9 | `TabOCR` | `ocr.py` | pytesseract/PyMuPDF | Add text layer to scanned PDFs. Language combo (PT/EN/ES/FR/DE). Progress dialog. |
| 10 | `TabConverter` | `convert.py` | PyMuPDF/docx | Export to PNG/JPG/DOCX/TXT. DPI selection for images. Output folder. |
| 11 | `TabEditar` | `editor/tab.py` | PyMuPDF | Visual editor (see [Editor System](#editor-system)). Not a BasePage subclass. |
| 12 | `TabImport` | `import_pdf.py` | PyMuPDF/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
Expand Down Expand Up @@ -1360,7 +1360,7 @@ 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)
├── Open pymupdf.Document(path, password)
├── Update header (filename, page count)
└── _SelectCanvas.load(doc)
├── Create _PageEntry slots (one per page)
Expand All @@ -1369,7 +1369,7 @@ MainWindow._load_and_track(path)
└── _schedule_visible()
├── Determine viewport + buffer range
└── Queue _PageJob for each unrendered page
├── (in thread) fitz renders page → QPixmap
├── (in thread) PyMuPDF renders page → QPixmap
├── (in thread) Extract word list for search
└── Emit page_ready signal
└── Canvas stores pixmap, calls update()
Expand All @@ -1393,7 +1393,7 @@ User fills form → clicks action button
▼
BasePage._run() (overridden by tool)
├── Validate inputs
├── Process PDF (pypdf / fitz / pytesseract / etc.)
├── Process PDF (pypdf / PyMuPDF / pytesseract / etc.)
├── Show progress dialog if needed
├── Save output file
└── Update status bar
Expand Down Expand Up @@ -1429,7 +1429,7 @@ _compress_pdf(src, dst, level, progress_fn)
│ └── Re-render entire PDF with image downsampling
│ DPI and quality depend on level
│
├── Pass B: PyMuPDF (fitz)
├── Pass B: PyMuPDF
│ ├── Scrub metadata
│ ├── Subset fonts
│ ├── Rewrite images with reduced quality
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ python -m PyInstaller --clean installer.spec
| Component | Technology | Version |
| ---------------- | --------------------------------------------------------------------------------------------------------------- | ------- |
| GUI | [PySide6](https://doc.qt.io/qtforpython/) (Qt 6) | 6.10.2 |
| PDF rendering | [PyMuPDF](https://pymupdf.readthedocs.io/) (fitz) | 1.27.2 |
| PDF rendering | [PyMuPDF](https://pymupdf.readthedocs.io/) | 1.27.2 |
| PDF manipulation | [pypdf](https://pypdf.readthedocs.io/) | 6.8.0 |
| OCR | [Tesseract](https://github.com/tesseract-ocr/tesseract) + [pytesseract](https://github.com/madmaze/pytesseract) | 0.3.13 |
| DOCX export | [python-docx](https://python-docx.readthedocs.io/) | 1.2.0 |
Expand Down Expand Up @@ -241,7 +241,7 @@ PDFApps/
│ │ ├── info.py
│ │ └── ocr.py
│ ├── viewer/ # Integrated PDF viewer
│ │ ├── canvas.py # Lazy page rendering in background threads (fitz)
│ │ ├── canvas.py # Lazy page rendering in background threads (PyMuPDF)
│ │ ├── panel.py # Viewer panel with controls
│ │ └── presentation.py # Fullscreen presentation mode (F5)
│ └── editor/ # Visual PDF editor
Expand Down
26 changes: 13 additions & 13 deletions app/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def __init__(self, icon, title, desc, action_text, status_fn):
self._pipeline_tmp_dir: str | None = None
# Password captured by _maybe_prompt_password for the loaded PDF.
# Persists for the lifetime of one input file so _run can re-open
# the same PDF (or fitz.Document) without re-prompting.
# the same PDF (or pymupdf.Document) without re-prompting.
self._pdf_password: str = ""

page_layout = QVBoxLayout(self)
Expand Down Expand Up @@ -330,18 +330,18 @@ def _maybe_prompt_password(self, path: str) -> bool:
stores a raw typed password without re-anchoring silently
reintroduces the original bug at every one of those sites.
"""
from app.pdf_password import authenticate_fitz
from app.pdf_password import authenticate_pymupdf
try:
import fitz
doc = fitz.open(path)
import pymupdf
doc = pymupdf.open(path)
except Exception:
return True # let downstream surface its own error
try:
if not doc.needs_pass:
self._pdf_password = ""
return True
if self._pdf_password:
winner = authenticate_fitz(doc, self._pdf_password)
winner = authenticate_pymupdf(doc, self._pdf_password)
if winner is not None:
# Re-anchor the cache on the spelling that worked so
# the ~30 raw ``self._pdf_password`` reads under
Expand All @@ -365,7 +365,7 @@ def _open_reader(self, path: str):
one is offered to :meth:`pypdf.PdfReader.decrypt` as raw UTF-8
``bytes`` first — pypdf's ``bytes`` branch skips its SASLprep /
Latin-1 encoding, which is what makes this agree byte-for-byte
with :meth:`_open_fitz`. See :mod:`app.pdf_password`.
with :meth:`_open_pymupdf`. See :mod:`app.pdf_password`.

Deliberately does *not* write the winning candidate back to
``self._pdf_password``: this runs inside ``_run_background``
Expand All @@ -383,7 +383,7 @@ def _open_reader(self, path: str):
raise WrongPasswordError(t("tool.err.wrong_password"))
return r

def _open_fitz(self, path: str):
def _open_pymupdf(self, path: str):
"""Open a PyMuPDF Document, authenticating with the stored
password if needed.

Expand All @@ -392,15 +392,15 @@ def _open_fitz(self, path: str):
for R>=5, so passing the candidate ``str`` here and its
``.encode("utf-8")`` there feeds both engines identical bytes.
"""
from app.pdf_password import authenticate_fitz
import fitz
doc = fitz.open(path)
from app.pdf_password import authenticate_pymupdf
import pymupdf
doc = pymupdf.open(path)
if doc.needs_pass and self._pdf_password:
# PyMuPDF's authenticate() returns a falsy value (0) on a
# wrong password and leaves the document locked — mirror
# _open_reader and raise instead of handing back a Document
# whose pages can't be read.
if authenticate_fitz(doc, self._pdf_password) is None:
if authenticate_pymupdf(doc, self._pdf_password) is None:
raise WrongPasswordError(t("tool.err.wrong_password"))
return doc

Expand Down Expand Up @@ -441,7 +441,7 @@ def _check_not_same_path(dst: str,
def _atomic_pdf_write(writer, dst: str, *,
sources: "Iterable[str] | None" = None,
save_opts: "dict | None" = None) -> None:
"""Write a PdfWriter (pypdf) or fitz.Document to ``dst`` atomically.
"""Write a PdfWriter (pypdf) or pymupdf.Document to ``dst`` atomically.

Thin wrapper around :func:`app.pdf_io.atomic_pdf_write` (R3):
the tempfile + ``os.replace`` + same-source-guard logic now
Expand All @@ -462,7 +462,7 @@ def _atomic_pdf_write(writer, dst: str, *,
``dst`` via :func:`os.replace` (works on POSIX and Windows).

``writer`` may be a pypdf ``PdfWriter`` (uses ``writer.write(fh)``)
or a PyMuPDF ``fitz.Document`` (uses ``writer.save(tmp)``).
or a PyMuPDF ``pymupdf.Document`` (uses ``writer.save(tmp)``).
Anything else with a ``.write(fh)`` method is accepted. The
writer is left OPEN (BasePage tools never save back onto the
input handle); the editor opts into ``close_writer`` directly
Expand Down
Loading
Loading