diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 388ab9d..32c7dc6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 (`/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 diff --git a/app/editor/dialogs.py b/app/editor/dialogs.py index 6cd9457..064c169 100644 --- a/app/editor/dialogs.py +++ b/app/editor/dialogs.py @@ -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 def _theme_colors(parent): @@ -117,7 +117,8 @@ def __init__(self, old_text: str, font_size: float, parent=None): 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) diff --git a/app/tools/compress.py b/app/tools/compress.py index a17d241..42ca719 100644 --- a/app/tools/compress.py +++ b/app/tools/compress.py @@ -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 @@ -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): diff --git a/app/tools/convert.py b/app/tools/convert.py index 6803f32..0cd1978 100644 --- a/app/tools/convert.py +++ b/app/tools/convert.py @@ -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 @@ -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 diff --git a/app/tools/import_pdf.py b/app/tools/import_pdf.py index b54f30f..cddb701 100644 --- a/app/tools/import_pdf.py +++ b/app/tools/import_pdf.py @@ -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 diff --git a/app/tools/info.py b/app/tools/info.py index a299a4a..adfca9d 100644 --- a/app/tools/info.py +++ b/app/tools/info.py @@ -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: @@ -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}", "", ] @@ -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)) diff --git a/app/utils.py b/app/utils.py index 862e542..0712476 100644 --- a/app/utils.py +++ b/app/utils.py @@ -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: diff --git a/app/viewer/panel.py b/app/viewer/panel.py index 505ee55..fc7f082 100644 --- a/app/viewer/panel.py +++ b/app/viewer/panel.py @@ -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() diff --git a/app/window.py b/app/window.py index 8500dde..6192d55 100644 --- a/app/window.py +++ b/app/window.py @@ -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) diff --git a/tests/test_polish_close_audit.py b/tests/test_polish_close_audit.py new file mode 100644 index 0000000..c2d9aae --- /dev/null +++ b/tests/test_polish_close_audit.py @@ -0,0 +1,221 @@ +"""Source-level + behavioral regression tests for PR-L polish fixes. + +Bug map (PR-L worklist โ€” final audit close-out): + #1 viewer recents use os.path.lexists (OneDrive Files-On-Demand) + #2 Brand SVG icon DPR scaling honours actual displayDevicePixelRatio + #3 import_pdf (txt-to-pdf) warns on non-Latin1 input characters + #4 format_size_localized helper for KB/MB displays (5 callsites) + #5 CONTRIBUTING.md config.json schema covers 5 keys + #6 CONTRIBUTING.md documents environment variables +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +# โ”€โ”€ #1 โ€” viewer recents use os.path.lexists โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_viewer_recents_uses_lexists(): + """app/viewer/panel.py:_refresh_recents must use os.path.lexists, + not os.path.isfile, to avoid hydrating OneDrive Files-On-Demand + placeholders on viewer startup. Matches the PR-G fix in + app/i18n.py:_load_translations (line 290).""" + src = _read("app/viewer/panel.py") + # The recents loop must rely on lexists. + assert "os.path.lexists(rp)" in src, ( + "viewer/panel.py recents loop must use os.path.lexists to " + "skip OneDrive placeholder hydration." + ) + # And the old isfile call must be gone from that loop (be tolerant + # of other isfile uses elsewhere in the file by checking the local + # window). + idx = src.find("os.path.lexists(rp)") + window = src[max(0, idx - 200): idx + 200] + assert "os.path.isfile(rp)" not in window, ( + "Old isfile check must be removed from the recents loop." + ) + + +# โ”€โ”€ #2 โ€” Brand SVG icon DPR scaling โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_brand_icon_uses_dynamic_dpr(): + """app/window.py brand icon must scale by self.devicePixelRatioF() + rather than the hardcoded 2.0 value. Same pattern PR-J #4 applied + to PasswordDialog.""" + src = _read("app/window.py") + # Look for the brand area construction. + assert "brand_area" in src + # devicePixelRatioF must be queried. + assert "devicePixelRatioF()" in src, ( + "window.py must call devicePixelRatioF() when sizing the brand pixmap." + ) + # The fixed 2.0 magic-number scaling must be gone. + assert "setDevicePixelRatio(2.0)" not in src, ( + "Hardcoded setDevicePixelRatio(2.0) must be replaced by dynamic dpr." + ) + # Defensive guard against dpr <= 0 should exist (PR-J #4 pattern). + assert "dpr <= 0" in src or "dpr = 1.0" in src + + +# โ”€โ”€ #3 โ€” import_pdf txt-to-pdf CJK warning โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_import_pdf_txt_warns_on_non_latin1(): + """app/tools/import_pdf.py _convert_txt must emit the + tool.warn.font_latin_only status when the input txt file contains + codepoints above U+00FF, matching the existing page_numbers.py + warning (PR-I L4).""" + src = _read("app/tools/import_pdf.py") + # The warning key must be referenced inside _convert_txt. + convert_idx = src.find("def _convert_txt(") + assert convert_idx != -1, "Expected _convert_txt method in import_pdf.py" + # Grab a generous slice of that method. + slice_ = src[convert_idx: convert_idx + 2500] + assert "tool.warn.font_latin_only" in slice_, ( + "_convert_txt must surface tool.warn.font_latin_only for non-Latin1 input." + ) + # The threshold guard must use ord(...) > 0xFF (Helvetica Latin-1 cap). + assert "0xFF" in slice_ or "255" in slice_ + + +# โ”€โ”€ #4 โ€” format_size_localized helper + 5 callsites โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_format_size_localized_helper_exists(): + """app/utils.py must export format_size_localized that delegates to + QLocale and falls back gracefully when Qt is unavailable.""" + src = _read("app/utils.py") + assert "def format_size_localized(" in src, ( + "app/utils.py must define format_size_localized helper." + ) + assert "QLocale.system().toString" in src, ( + "format_size_localized must use QLocale.system().toString for i18n." + ) + # Fallback path required for headless tests where Qt may fail to import. + assert "except Exception" in src + # Fallback returns the period-formatted value. + assert "{value:." in src or "{value:.{decimals}f}" in src + + +def test_format_size_localized_callsites_updated(): + """The 5 identified callsites must use format_size_localized rather + than raw f-string with `:.1f`.""" + # compress.py:108 โ€” pages_info call. + compress = _read("app/tools/compress.py") + assert "format_size_localized" in compress + # No more raw `:.1f` for size in compress.py. + assert "size/1024:.1f" not in compress + assert "size / 1024:.1f" not in compress + + # convert.py:174 โ€” pages_info call. + convert = _read("app/tools/convert.py") + assert "format_size_localized" in convert + assert "size/1024:.1f" not in convert + assert "size / 1024:.1f" not in convert + + # info.py:83, 106 โ€” 2 sites. + info = _read("app/tools/info.py") + assert "format_size_localized" in info + assert "size/1024:.1f" not in info + assert "size / 1024:.1f" not in info + + # editor/dialogs.py:120 โ€” _TextEditDialog font_size label. + dlg = _read("app/editor/dialogs.py") + assert "format_size_localized" in dlg + assert "font_size:.1f" not in dlg + + +def test_format_size_localized_runtime_behavior(): + """Calling the helper must yield a string with a decimal separator, + even if QLocale is not available.""" + from app.utils import format_size_localized + result = format_size_localized(1234.5) + assert isinstance(result, str) + # The result must contain either '.' or ',' as the decimal separator. + assert "." in result or "," in result + # Reasonable rounding: should not contain '.1234'-level precision past + # the requested 1 decimal. + # The integer part contains "1234" or a localized grouped form. + digits = [c for c in result if c.isdigit()] + assert len(digits) >= 5, f"expected โ‰ฅ5 digits in {result!r}" + + +def test_format_size_localized_fallback_path(): + """When QLocale import raises, the helper must fall back to f-string.""" + import builtins + import importlib + import sys + + from app import utils + + # Save original import. + real_import = builtins.__import__ + + def blocking_import(name, *args, **kwargs): + if name == "PySide6.QtCore" or name.startswith("PySide6.QtCore."): + raise ImportError("simulated QtCore missing") + return real_import(name, *args, **kwargs) + + builtins.__import__ = blocking_import + try: + # Need to reimport so the inner ``from PySide6.QtCore import QLocale`` + # is reattempted; but the helper imports lazily inside the function, + # so a fresh call is enough. + result = utils.format_size_localized(3.5) + assert result == "3.5", f"fallback path must yield '3.5', got {result!r}" + finally: + builtins.__import__ = real_import + + +# โ”€โ”€ #5 โ€” CONTRIBUTING.md config.json schema documents 5 keys โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_contributing_config_schema_lists_five_keys(): + """CONTRIBUTING.md config.json schema must enumerate all five keys.""" + src = _read("CONTRIBUTING.md") + assert "`config.json` schema" in src or "config.json schema" in src + for key in ( + "`language`", + "`dark_mode`", + "`recent_files`", + "`max_recent_files`", + "`tool_usage`", + ): + assert key in src, f"CONTRIBUTING.md schema missing entry for {key}" + # XDG / Windows path resolution must be mentioned. + assert "XDG_CONFIG_HOME" in src + assert ".pdfapps_config.json" in src + + +# โ”€โ”€ #6 โ€” CONTRIBUTING.md documents environment variables โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + +def test_contributing_documents_environment_variables(): + """CONTRIBUTING.md must contain an Environment Variables section that + covers the platform vars, PyInstaller internals, and TESSDATA_PREFIX.""" + src = _read("CONTRIBUTING.md") + assert "## Environment Variables" in src + # Platform vars. + for var in ("XDG_CONFIG_HOME", "FLATPAK_ID", "SNAP", "APPIMAGE", "APPDIR"): + assert var in src, f"CONTRIBUTING.md env section missing {var}" + # PyInstaller internals. + for var in ( + "_PYI_APPLICATION_HOME_DIR", + "_PYI_PARENT_PROCESS_LEVEL", + "_PYI_ARCHIVE_FILE", + "_MEIPASS", + ): + assert var in src, f"CONTRIBUTING.md env section missing {var}" + # Tesseract. + assert "TESSDATA_PREFIX" in src