|
1 | | -# Architecture |
| 1 | +# Architecture — PDF Equilibrist |
2 | 2 |
|
3 | | -> **English translation in progress.** The French version is available at [/docs/](/docs/). |
| 3 | +> ← [Index](1.INDEX.md) · [Contributing](3.CONTRIBUTING.md) · [Operations](4.OPERATIONS.md) · [Build](5.BUILD.md) |
4 | 4 |
|
5 | | -Please refer to the French documentation while the translation is being prepared. |
| 5 | +--- |
| 6 | + |
| 7 | +## Overview |
| 8 | + |
| 9 | +PDF Equilibrist is a desktop PDF editor built on **PyQt6** (graphical interface) |
| 10 | +and **PyMuPDF / fitz** (PDF engine). The architecture follows a strict separation |
| 11 | +between business logic and the user interface. |
| 12 | + |
| 13 | +``` |
| 14 | +src/pdf_equilibrist/ |
| 15 | +├── main.py # Entry point |
| 16 | +├── app.py # QApplication configuration + theme |
| 17 | +├── utils.py # resource_path() — dev/exe paths |
| 18 | +├── registration.py # Windows "Open with..." registration |
| 19 | +├── core/ |
| 20 | +│ └── document.py # Document model — sole owner of the fitz.Document |
| 21 | +├── ui/ # Presentation layer (PyQt6) |
| 22 | +│ ├── main_window.py |
| 23 | +│ ├── title_bar.py |
| 24 | +│ ├── viewer.py |
| 25 | +│ ├── thumbnail_panel.py |
| 26 | +│ ├── floating_item.py |
| 27 | +│ ├── page_edit_widget.py |
| 28 | +│ ├── splash_screen.py |
| 29 | +│ ├── signature_dialog.py |
| 30 | +│ ├── batch_dialog.py |
| 31 | +│ ├── widgets.py |
| 32 | +│ ├── dialogs.py |
| 33 | +│ └── tabs/ |
| 34 | +│ ├── tab_afficher.py |
| 35 | +│ ├── tab_modifier.py |
| 36 | +│ ├── tab_convertir.py |
| 37 | +│ ├── tab_annoter.py |
| 38 | +│ ├── tab_page.py |
| 39 | +│ └── tab_proteger.py |
| 40 | +└── operations/ # Pure PDF logic — no UI, testable |
| 41 | + ├── edit.py |
| 42 | + ├── pages.py |
| 43 | + ├── annotate.py |
| 44 | + ├── convert.py |
| 45 | + └── protect.py |
| 46 | +``` |
| 47 | + |
| 48 | +--- |
| 49 | + |
| 50 | +## Core principle: the `changed` signal |
| 51 | + |
| 52 | +`Document` inherits from `QObject` and emits `changed` after every modification. |
| 53 | +All UI components subscribe to it to refresh themselves automatically. |
| 54 | + |
| 55 | +``` |
| 56 | +Any PDF operation |
| 57 | + └── document.changed.emit() |
| 58 | + ├── PdfViewer._on_doc_changed() → re-renders the pages |
| 59 | + ├── ThumbnailPanel.rebuild() → re-renders the thumbnails |
| 60 | + └── MainWindow._on_doc_changed() → status bar, tab title |
| 61 | +``` |
| 62 | + |
| 63 | +**Rule**: functions in `operations/` modify the `fitz.Document` in place. |
| 64 | +The calling tab is responsible for emitting `document.changed` after the call. |
| 65 | + |
| 66 | +--- |
| 67 | + |
| 68 | +## Typical data flow |
| 69 | + |
| 70 | +``` |
| 71 | +User clicks "Watermark" in tab_modifier |
| 72 | + │ |
| 73 | + ▼ |
| 74 | +tab_modifier._add_watermark() |
| 75 | + │ ask_watermark_text() → "CONFIDENTIAL" |
| 76 | + ▼ |
| 77 | +operations.edit.add_watermark(document.fitz_doc, "CONFIDENTIAL") |
| 78 | + │ modifies the fitz.Document in place (insert_text on every page) |
| 79 | + ▼ |
| 80 | +document.changed.emit() |
| 81 | + ├── viewer.refresh() → re-renders the QLabel for each page |
| 82 | + └── thumbs.rebuild() → re-renders the thumbnails |
| 83 | +``` |
| 84 | + |
| 85 | +--- |
| 86 | + |
| 87 | +## Multi-document handling |
| 88 | + |
| 89 | +`MainWindow` maintains a list of `Document` instances, one per open tab. |
| 90 | + |
| 91 | +```python |
| 92 | +_documents : list[Document] # one Document per tab |
| 93 | +_active_idx : int # index of the displayed document |
| 94 | +_current_doc : Document # direct reference (rebound by _rebind_doc) |
| 95 | +``` |
| 96 | + |
| 97 | +### `_rebind_doc(doc)` — the central method |
| 98 | + |
| 99 | +Called on every tab switch. It: |
| 100 | + |
| 101 | +1. Disconnects **all** signals from the previous document (`old.changed.disconnect()`) |
| 102 | +2. Reconnects the new one to the 3 slots: `_on_doc_changed`, `viewer._on_doc_changed`, `thumbs.rebuild` |
| 103 | +3. Updates the `document` reference in every ribbon tab |
| 104 | +4. Forces an immediate refresh of the viewer and thumbnails |
| 105 | + |
| 106 | +The viewer and thumbnail panel are **shared** across all tabs and rebound on |
| 107 | +every switch. They are never recreated. |
| 108 | + |
| 109 | +--- |
| 110 | + |
| 111 | +## Frameless window |
| 112 | + |
| 113 | +`MainWindow` inherits from `QWidget` (not `QMainWindow`) with `FramelessWindowHint`. |
| 114 | + |
| 115 | +``` |
| 116 | +MainWindow (QWidget, FramelessWindowHint) |
| 117 | +├── TitleBar ← drag via startSystemMove() |
| 118 | +├── QMenuBar ← File menu |
| 119 | +├── Ribbon (QTabBar + QStackedWidget) |
| 120 | +├── QSplitter |
| 121 | +│ ├── ThumbnailPanel ← fixed, adaptive width |
| 122 | +│ └── PdfViewer ← stretch=1, takes the remaining space |
| 123 | +└── Status bar |
| 124 | +``` |
| 125 | + |
| 126 | +**Resizing**: `mousePressEvent` on the edges calls |
| 127 | +`windowHandle().startSystemResize(edge)` — native Qt6 API. |
| 128 | + |
| 129 | +**Moving**: `TitleBar.mousePressEvent` → `startSystemMove()` — native. |
| 130 | + |
| 131 | +**PowerToys FancyZones / Windows Snap**: `showEvent` calls `_apply_win32_styles()`, |
| 132 | +which adds `WS_THICKFRAME | WS_MAXIMIZEBOX | WS_MINIMIZEBOX` via `SetWindowLong`. |
| 133 | +This makes the window recognizable by PowerToys and enables Windows Snap (Win+arrow). |
| 134 | + |
| 135 | +**Windows 11 Snap Layouts**: `nativeEvent` intercepts `WM_NCHITTEST` and returns |
| 136 | +`HTMAXBUTTON` when the cursor hovers over the maximize button → Windows shows |
| 137 | +the Snap layout popup. `WM_SYSCOMMAND` is also intercepted for |
| 138 | +SC_MAXIMIZE / SC_RESTORE / SC_MINIMIZE sent by Snap and PowerToys. |
| 139 | + |
| 140 | +> Memory safety: `ctypes.string_at` + `from_buffer_copy` copies the MSG struct's |
| 141 | +> bytes into a Python buffer before reading, avoiding the segfault that |
| 142 | +> `from_address` can cause by reading in place from a potentially invalid address. |
| 143 | +
|
| 144 | +--- |
| 145 | + |
| 146 | +## Text editing mode |
| 147 | + |
| 148 | +``` |
| 149 | +1. extract_text_blocks(doc, page_i) |
| 150 | + └─ get_text("dict") → list of TextBlock |
| 151 | + (rect, baseline origin, fontname, fontsize, color, font_buffer) |
| 152 | +
|
| 153 | +2. viewer.enter_edit_mode(blocks_by_page) |
| 154 | + └─ replaces the QLabel with a PageEditWidget per page |
| 155 | +
|
| 156 | +3. Click on a block → _BlockEditor (inline QTextEdit) |
| 157 | + └─ positioned pixel-perfectly on span["origin"] (baseline point) |
| 158 | +
|
| 159 | +4. Enter (confirm) → apply_text_edits() |
| 160 | + ├─ add_redact_annot(rect, fill=None) ← removes without a white rectangle |
| 161 | + ├─ apply_redactions() |
| 162 | + └─ insert_text(origin, new_text, ...) ← reinserts at the exact baseline |
| 163 | +
|
| 164 | +5. viewer.exit_edit_mode() → refresh() |
| 165 | +``` |
| 166 | + |
| 167 | +**Key point**: `fill=None` in the redaction avoids the visible white rectangle |
| 168 | +on pages with a colored background or a background image. |
| 169 | + |
| 170 | +--- |
| 171 | + |
| 172 | +## Floating placement (text / image / stamp) |
| 173 | + |
| 174 | +``` |
| 175 | +viewer.show_floating_item(data, page_index, on_commit, on_cancel) |
| 176 | + └─ FloatingItem(data, zoom, page_widget) |
| 177 | + ├─ drag → move (manual startSystemMove-like behavior) |
| 178 | + ├─ corners → resize |
| 179 | + ├─ green handle → rotate (atan2 around the center) |
| 180 | + └─ "✔ Confirm" |
| 181 | + └─ _commit() |
| 182 | + ├─ pdf_rect = screen_coords / zoom |
| 183 | + └─ committed.emit({...pdf_rect, angle}) |
| 184 | + └─ tab._on_placement_commit() |
| 185 | + └─ page.insert_text/insert_image/draw_rect |
| 186 | + └─ document.changed.emit() |
| 187 | +``` |
| 188 | + |
| 189 | +--- |
| 190 | + |
| 191 | +## Thumbnail panel — drag & drop |
| 192 | + |
| 193 | +Drag & drop is handled by `ThumbnailPanel` (the panel itself, not the Qt list). |
| 194 | + |
| 195 | +- **Internal drag**: an event filter on the viewport → `drag_started(row)` → |
| 196 | + `QDrag` with mime type `application/x-pdf-equilibrist-row` → `doc.move_page(src, to)` |
| 197 | +- **External PDF drop**: `dragEnterEvent` on the panel accepts `.pdf` URLs → |
| 198 | + `doc.insert_pdf(src_doc, start_at=insert_before)` |
| 199 | +- **Insertion indicator**: a transparent `_DropLine` widget, overlaid on the |
| 200 | + viewport, draws the white line at the computed insertion position |
| 201 | + |
| 202 | +--- |
| 203 | + |
| 204 | +## Printing (Ctrl+P) |
| 205 | + |
| 206 | +``` |
| 207 | +Ctrl+P / Print button / File → Print |
| 208 | + └── MainWindow._print() |
| 209 | + └── ui/print_dialog.print_document(document, parent) |
| 210 | + ├── QPrintDialog ← Windows dialog box |
| 211 | + └── _win32_print() ← direct Win32 GDI printing |
| 212 | + ├── StartDoc / StartPage |
| 213 | + ├── fitz.get_pixmap(dpi/UPSAMPLE) ← render at half resolution |
| 214 | + ├── StretchDIBits HALFTONE ×2 ← upscale → fine lines stay visible |
| 215 | + └── _draw_vector_strokes() ← vector GDI overlay |
| 216 | + ├── get_drawings(extended=True) ← all PDF paths + XObjects |
| 217 | + ├── Polyline GDI (chains) ← clean joins, no blobs |
| 218 | + └── hatching filters ← clip / multi-segment / image |
| 219 | +``` |
| 220 | + |
| 221 | +### Hairline strategy (AutoCAD drawings) |
| 222 | + |
| 223 | +Thin lines (0–0.25 pt) rasterized at full printer resolution become invisible. |
| 224 | +Two-part solution: |
| 225 | + |
| 226 | +1. **UPSAMPLE=2**: PyMuPDF renders at `dpi/2`, then `StretchDIBits HALFTONE` upscales ×2. |
| 227 | + Each fitz pixel becomes a 2×2 px block on paper → thin lines stay visible. |
| 228 | +2. **GDI overlay**: `_draw_vector_strokes()` re-traces stroke-only paths in GDI |
| 229 | + on top of the raster for clean joins and normalized widths. |
| 230 | + |
| 231 | +### `_draw_vector_strokes()` — filters applied |
| 232 | + |
| 233 | +| Filter | Condition | Target | |
| 234 | +|---|---|---| |
| 235 | +| Clippath | `clip is not None` | AutoCAD hatching with a clippath | |
| 236 | +| Multi-line | `len(items) > 30`, all `l` | Hatching in a single path | |
| 237 | +| No stroke | `color` absent | Solid fills, wide polylines | |
| 238 | +| Large image | image > 50% of the page | Aerial photo, scanned background | |
| 239 | +| < 500 drawings | total drawings < 500 | Excel, Word, simple PDFs | |
| 240 | + |
| 241 | +`l` (line), `re` (rect), `qu` (quad), and `c` (cubic bézier) paths are all |
| 242 | +handled. Béziers are approximated with 8 line segments. |
| 243 | + |
| 244 | +--- |
| 245 | + |
| 246 | +## PyInstaller compatibility |
| 247 | + |
| 248 | +`resource_path(relative)` resolves asset paths: |
| 249 | + |
| 250 | +```python |
| 251 | +# Dev : src/pdf_equilibrist/utils.py → go up 3 levels → project root |
| 252 | +# Exe : sys._MEIPASS → PyInstaller's temporary extraction folder |
| 253 | +``` |
| 254 | + |
| 255 | +All asset access (logo, buttons, splash screen) goes through `resource_path()`. |
| 256 | + |
| 257 | +--- |
| 258 | + |
| 259 | +## Security — CVE checker (`cve_checker.py`) |
| 260 | + |
| 261 | +Accessible from the **Help → Check for CVE vulnerabilities** menu. |
| 262 | + |
| 263 | +``` |
| 264 | +CVEDialog |
| 265 | + └── _ScanThread (QThread) |
| 266 | + ├── cve_checker.get_installed_packages() |
| 267 | + │ └── importlib.metadata.distributions() |
| 268 | + │ └── {name: version} for each installed package |
| 269 | + │ (works in a PyInstaller exe without requirements.txt) |
| 270 | + ├── cve_checker.scan_dependencies(pkgs) |
| 271 | + │ └── ThreadPoolExecutor(max_workers=8) |
| 272 | + │ └── query_package_vulnerabilities(name, version) |
| 273 | + │ └── POST https://api.osv.dev/v1/query |
| 274 | + │ {package: {name, ecosystem:"PyPI"}, version: "x.y.z"} |
| 275 | + └── progress.emit(done, total) → progress bar X/N packages |
| 276 | +``` |
| 277 | + |
| 278 | +**Design note**: sending the installed version to OSV.dev restricts results to |
| 279 | +CVEs that precisely affect that version, eliminating false positives for |
| 280 | +already-patched versions. The 8 parallel HTTP requests reduce scan time from |
| 281 | +roughly N×t down to ~t. |
| 282 | + |
| 283 | +--- |
| 284 | + |
| 285 | +## Updates and statistics (`update.py` + `ui/update_dialog.py`) |
| 286 | + |
| 287 | +Accessible from **Help → Check for updates**. |
| 288 | + |
| 289 | +``` |
| 290 | +UpdateDialog |
| 291 | + ├── _CheckThread (QThread) ← starts in parallel with _StatsThread |
| 292 | + │ └── updater.get_latest_release_info(__version__) |
| 293 | + │ └── GET https://api.github.com/repos/{owner}/{repo}/releases/latest |
| 294 | + │ └── compares tag_name with __version__ (tuple) |
| 295 | + │ |
| 296 | + └── _StatsThread (QThread) |
| 297 | + └── updater.get_download_stats(repo, __version__) |
| 298 | + └── GET https://api.github.com/repos/{owner}/{repo}/releases?per_page=100 |
| 299 | + └── aggregates asset.download_count: |
| 300 | + "current": release whose tag == v{__version__} |
| 301 | + "total" : across all releases |
| 302 | + "releases": number of releases |
| 303 | +``` |
| 304 | + |
| 305 | +The UI shows two live counters: |
| 306 | +- **This version** (green `#6BBF4E`) — downloads for the current version |
| 307 | +- **Total** (white) — cumulative downloads across all releases |
| 308 | + |
| 309 | +Both threads start simultaneously in `__init__`; the counters are shown as |
| 310 | +soon as `_StatsThread` returns, independently of `_CheckThread`'s result. |
| 311 | + |
| 312 | +--- |
| 313 | + |
| 314 | +## Technologies |
| 315 | + |
| 316 | +| Component | Library | Role | |
| 317 | +|---|---|---| |
| 318 | +| Interface | PyQt6 6.11 | Window, widgets, signals | |
| 319 | +| PDF engine | PyMuPDF 1.27 | Rendering, editing, annotations | |
| 320 | +| PDF → Word | pdf2docx 0.5 | Structural conversion | |
| 321 | +| PDF → Excel | pdfplumber + openpyxl | Table extraction | |
| 322 | +| PDF → PPT | python-pptx | Image slides | |
| 323 | +| Office → PDF | docx2pdf / LibreOffice | COM automation or headless | |
| 324 | +| Images | Pillow 12 | Image manipulation | |
| 325 | +| Build | PyInstaller | Windows onefile exe | |
| 326 | +| CVE scan | importlib.metadata + OSV.dev | Dependency vulnerabilities | |
| 327 | +| Updates | GitHub Releases API | Version check + download stats | |
0 commit comments