Skip to content
57 changes: 54 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,15 +203,66 @@ def main():
# 7. sys.exit(app.exec())
```

**Config file:** `~/.pdfapps_config.json`
### `config.json` schema

The application stores user preferences in `config.json` at:

- **Linux**: `$XDG_CONFIG_HOME/pdfapps/config.json` (default `~/.config/pdfapps/`)
- **Windows / macOS**: `~/.pdfapps_config.json` (legacy; macOS support for `~/Library/Application Support/PDFApps/` planned)

Schema:

| Key | Type | Default | Description |
|---|---|---|---|
| `language` | str | system locale | UI language code (`en`, `pt`, `es`, `fr`, `de`, `it`, `nl`, `zh`) |
| `dark_mode` | bool | `true` | Theme preference |
| `recent_files` | list[str] | `[]` | Paths to recently opened PDFs (max controlled by `max_recent_files`) |
| `max_recent_files` | int | `10` | Cap on the recent files list (clamped 1-50) |
| `tool_usage` | dict[str, int] | `{}` | Per-tool invocation counter (for surface ordering) |

Example:

```json
{
"dark_mode": true,
"language": "pt",
"recent_files": ["C:/path/to/file1.pdf", "C:/path/to/file2.pdf"]
"dark_mode": true,
"recent_files": ["C:/path/to/file1.pdf", "C:/path/to/file2.pdf"],
"max_recent_files": 10,
"tool_usage": {"compress": 12, "merge": 5}
}
```

## Environment Variables

PDFApps respects the following environment variables for platform integration and PyInstaller relaunch handling.

### Standard platform vars

| Var | Source | Behaviour |
|---|---|---|
| `XDG_CONFIG_HOME` | Linux XDG Base Directory spec | Overrides config path location (`<XDG_CONFIG_HOME>/pdfapps/`) |
| `FLATPAK_ID` | set by Flatpak runtime | Detected for sandbox-aware behaviour |
| `SNAP` | set by snapd runtime | Detected for snap-aware paths |
| `APPIMAGE` | set by AppImage runtime | Path to mounted AppImage; used to locate bundled resources |
| `APPDIR` | set by AppImage AppRun | Mount point for AppImage extraction |

### PyInstaller internals (auto-managed)

These are cleared by `_restart_app` before relaunching to prevent stale state contamination. Do not set manually.

- `_PYI_APPLICATION_HOME_DIR`
- `_PYI_PARENT_PROCESS_LEVEL`
- `_PYI_ARCHIVE_FILE`
- `_MEIPASS`, `_MEIPASS2`

See `app/window.py:_restart_app` and memory entry `feedback_pyinstaller_relaunch.md` for the relaunch contract.

### Tesseract OCR

| Var | Behaviour |
|---|---|
| `TESSDATA_PREFIX` | Override the location of `tessdata/` directory; set automatically by `app/tools/ocr.py:_ensure_tesseract` when a non-standard install is detected |

---

## Main Window
Expand Down
5 changes: 3 additions & 2 deletions app/editor/dialogs.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
_LO, _LP, _LQ, _LN,
)
from app.i18n import t
from app.utils import error_color
from app.utils import error_color, format_size_localized

Check notice

Code scanning / CodeQL

Cyclic import Note

Import of module
app.utils
begins an import cycle.


def _theme_colors(parent):
Expand Down Expand Up @@ -117,7 +117,8 @@
pri, sec, bg, brd = _theme_colors(parent)
v = QVBoxLayout(self); v.setContentsMargins(20, 20, 20, 16); v.setSpacing(10)

lbl_orig = QLabel(t("dialog.edit_text_detected", size=f"{font_size:.1f}"))
lbl_orig = QLabel(t("dialog.edit_text_detected",
size=format_size_localized(font_size)))
lbl_orig.setStyleSheet(f"color:{sec}; font-size:10pt;")
v.addWidget(lbl_orig)

Expand Down
6 changes: 4 additions & 2 deletions app/tools/compress.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
from app.base import BasePage
from app.i18n import t
from app.utils import (section, info_lbl, _compress_pdf, _find_gs,
show_error, result_label_style)
show_error, result_label_style,
format_size_localized)
from app.worker import TaskRunner, run_task
from app.constants import DESKTOP, TEXT_SEC
from app.widgets import DropFileEdit
Expand Down Expand Up @@ -105,7 +106,8 @@ def _load_input(self, p: str):
size = os.path.getsize(p)
try:
r = self._open_reader(p)
self.lbl_info.setText(t("tool.compress.pages_info", n=len(r.pages), size=f"{size/1024:.1f}"))
self.lbl_info.setText(t("tool.compress.pages_info", n=len(r.pages),
size=format_size_localized(size / 1024)))
except Exception as e: self.lbl_info.setText(t("tool.split.error_info", e=e))

def auto_load(self, path: str):
Expand Down
5 changes: 3 additions & 2 deletions app/tools/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def _atomic_save(out_path: str, write_cb):
from app.i18n import t
from app.utils import (
section, info_lbl, pick_folder, show_error, result_label_style,
CancelledError,
CancelledError, format_size_localized,
)
from app.constants import DESKTOP
from app.widgets import DropFileEdit
Expand Down Expand Up @@ -171,7 +171,8 @@ def _load_input(self, p: str):
size = os.path.getsize(p)
try:
r = self._open_reader(p)
self.lbl_info.setText(t("tool.compress.pages_info", n=len(r.pages), size=f"{size/1024:.1f}"))
self.lbl_info.setText(t("tool.compress.pages_info", n=len(r.pages),
size=format_size_localized(size / 1024)))
except Exception as e:
self.lbl_info.setText(t("tool.split.error_info", e=e))
# auto-set output paths
Expand Down
15 changes: 15 additions & 0 deletions app/tools/import_pdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,21 @@ def _run(self):

def _convert_txt(self, sources: list, out_path: str):
n = len(sources)
# Mirror page_numbers.py L4: ``helv`` is a Type-1 Latin-1 font; any
# codepoint > U+00FF (CJK, Cyrillic, Arabic, etc.) renders as tofu.
# Pre-scan a bounded prefix of each input so the user gets the same
# status-bar warning as the page-numbers tool surfaces.
try:
for _src in sources:
with open(_src, "r", encoding="utf-8", errors="replace") as _f:
_chunk = _f.read(65536)
if any(ord(c) > 0xFF for c in _chunk):
self._status(t("tool.warn.font_latin_only"))
break
except OSError:
# File read errors will be surfaced again inside do_work; the
# warning pre-scan is best-effort and must never block the run.
pass

def do_work(worker):
import fitz
Expand Down
5 changes: 3 additions & 2 deletions app/tools/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from app.base import BasePage
from app.i18n import t
from app.utils import format_size_localized


def _format_pdf_date(raw: str) -> str:
Expand Down Expand Up @@ -80,7 +81,7 @@ def _show(self, path: str):
f" 📁 {path}",
"",
f" {t('tool.info.pages'):<16}{page_count}",
f" {t('tool.info.size'):<16}{size/1024:.1f} KB ({size:,} bytes)".replace(",", " "),
f" {t('tool.info.size'):<16}{format_size_localized(size/1024)} KB ({size:,} bytes)".replace(",", " "),
f" {t('tool.info.encrypted'):<16}{enc}",
"",
]
Expand All @@ -103,6 +104,6 @@ def _show(self, path: str):
f" {w/72*25.4:.0f} × {h/72*25.4:.0f} mm",
]
self.txt.setPlainText("\n".join(lines))
self._status(f"ℹ {os.path.basename(path)} · {page_count} {t('tool.info.pages').lower()} · {size/1024:.1f} KB")
self._status(f"ℹ {os.path.basename(path)} · {page_count} {t('tool.info.pages').lower()} · {format_size_localized(size/1024)} KB")
except Exception as e:
self.txt.setPlainText(t("tool.info.error", e=e))
14 changes: 14 additions & 0 deletions app/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@ def resource_path(rel):
return os.path.join(base, rel)


def format_size_localized(value: float, decimals: int = 1) -> str:
"""Format a numeric value using the system locale's decimal separator.

DE/FR/IT/ES typically use comma; EN/PT use period. Falls back to a plain
``f"{value:.{decimals}f}"`` (period) if QLocale is unavailable, e.g. in
headless test environments where the Qt plugin failed to load.
"""
try:
from PySide6.QtCore import QLocale
return QLocale.system().toString(float(value), 'f', decimals)
except Exception:
return f"{value:.{decimals}f}"


def _make_palette(dark: bool) -> QPalette:
p = QPalette()
if dark:
Expand Down
4 changes: 3 additions & 1 deletion app/viewer/panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,9 @@ def _refresh_recents(self):
"font-size: 10pt; font-weight: 600; opacity: 0.7;")
lay.addWidget(rec_title)
for rp in recents[:5]:
if not os.path.isfile(rp):
# Use os.path.lexists to avoid hydrating OneDrive Files-On-Demand
# placeholders at viewer startup (matches i18n.py:290 fix).
if not os.path.lexists(rp):
continue
fname = os.path.basename(rp)
row = QWidget()
Expand Down
12 changes: 8 additions & 4 deletions app/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,25 +300,29 @@ def _a11y(btn, tip):
from PySide6.QtGui import QPainter, QImage
_svg_path = resource_path("pdfapps.svg")
_h = 36 # target height
# Honor actual display DPR rather than assuming HiDPI (PR-J #4 pattern).
dpr = self.devicePixelRatioF() if hasattr(self, 'devicePixelRatioF') else 1.0
if dpr <= 0:
dpr = 1.0
if os.path.exists(_svg_path):
renderer = QSvgRenderer(_svg_path)
vb = renderer.viewBox()
ratio = vb.width() / vb.height() if vb.height() else 1.0
_w = int(_h * ratio)
img = QImage(_w * 2, _h * 2, QImage.Format.Format_ARGB32_Premultiplied)
img = QImage(int(_w * dpr), int(_h * dpr), QImage.Format.Format_ARGB32_Premultiplied)
img.fill(0)
p = QPainter(img)
renderer.render(p)
p.end()
_app_pix = _QPixmap.fromImage(img)
_app_pix.setDevicePixelRatio(2.0)
_app_pix.setDevicePixelRatio(dpr)
else:
_w = _h
_ico_path = resource_path("icon.ico")
_app_pix = _QPixmap(_ico_path).scaled(
_w * 2, _h * 2, Qt.AspectRatioMode.KeepAspectRatio,
int(_w * dpr), int(_h * dpr), Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation)
_app_pix.setDevicePixelRatio(2.0)
_app_pix.setDevicePixelRatio(dpr)
ico_lbl.setPixmap(_app_pix)
ico_lbl.setObjectName("app_icon")
ico_lbl.setFixedSize(_w, _h)
Expand Down
Loading