feat: add translation files and i18n loader

Create TOML-based localisation resources under app/lang and introduce a Translator/I18nMixin that reads them. Update config handling to recognise available languages, switch UI strings to translation lookups, and bundle language files with the package.
This commit is contained in:
lm
2025-10-17 16:43:25 +02:00
parent 76073ab0b5
commit 91fad62808
13 changed files with 442 additions and 100 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
"""Logic utilities and mixins for processing and configuration."""
from .constants import BASE_DIR, DEFAULTS, IMAGES_DIR, PREVIEW_MAX_SIZE, SUPPORTED_IMAGE_EXTENSIONS
from .constants import BASE_DIR, DEFAULTS, IMAGES_DIR, LANGUAGE, PREVIEW_MAX_SIZE, SUPPORTED_IMAGE_EXTENSIONS
from .image_processing import ImageProcessingMixin
from .reset import ResetMixin
@@ -8,6 +8,7 @@ __all__ = [
"BASE_DIR",
"DEFAULTS",
"IMAGES_DIR",
"LANGUAGE",
"PREVIEW_MAX_SIZE",
"SUPPORTED_IMAGE_EXTENSIONS",
"ImageProcessingMixin",
+33 -3
View File
@@ -19,6 +19,7 @@ PREVIEW_MAX_SIZE = (900, 660)
BASE_DIR = Path(__file__).resolve().parents[2]
IMAGES_DIR = BASE_DIR / "images"
CONFIG_FILE = BASE_DIR / "config.toml"
LANG_DIR = BASE_DIR / "app" / "lang"
_DEFAULTS_BASE = {
"hue_min": 250.0,
@@ -30,6 +31,7 @@ _DEFAULTS_BASE = {
}
SUPPORTED_IMAGE_EXTENSIONS = (".webp", ".png", ".jpg", ".jpeg", ".bmp")
LANGUAGE_DEFAULT = "en"
_DEFAULT_TYPES: dict[str, Callable[[Any], Any]] = {
"hue_min": float,
@@ -41,8 +43,8 @@ _DEFAULT_TYPES: dict[str, Callable[[Any], Any]] = {
}
def _load_default_overrides() -> dict[str, Any]:
"""Load default slider overrides from config.toml if available."""
def _load_config_data() -> dict[str, Any]:
"""Read the optional config file once and return its parsed data."""
if tomllib is None or not CONFIG_FILE.exists():
return {}
decode_error = getattr(tomllib, "TOMLDecodeError", ValueError) # type: ignore[attr-defined]
@@ -51,6 +53,12 @@ def _load_default_overrides() -> dict[str, Any]:
data = tomllib.load(handle)
except (OSError, AttributeError, decode_error, TypeError): # type: ignore[arg-type]
return {}
if not isinstance(data, dict):
return {}
return data
def _extract_default_overrides(data: dict[str, Any]) -> dict[str, Any]:
settings = data.get("defaults")
if not isinstance(settings, dict):
return {}
@@ -65,4 +73,26 @@ def _load_default_overrides() -> dict[str, Any]:
return overrides
DEFAULTS = {**_DEFAULTS_BASE, **_load_default_overrides()}
def _available_languages() -> set[str]:
languages = {path.stem.lower() for path in LANG_DIR.glob("*.toml")}
if not languages:
languages.add(LANGUAGE_DEFAULT)
return languages
def _extract_language(data: dict[str, Any]) -> str:
value = data.get("language")
supported = _available_languages()
if isinstance(value, str):
normalised = value.strip().lower()
if normalised in supported:
return normalised
if LANGUAGE_DEFAULT in supported:
return LANGUAGE_DEFAULT
return sorted(supported)[0]
_CONFIG_DATA = _load_config_data()
DEFAULTS = {**_DEFAULTS_BASE, **_extract_default_overrides(_CONFIG_DATA)}
LANGUAGE = _extract_language(_CONFIG_DATA)
+55 -20
View File
@@ -28,8 +28,8 @@ class ImageProcessingMixin:
def load_image(self) -> None:
default_dir = IMAGES_DIR if IMAGES_DIR.exists() else Path.cwd()
path = filedialog.askopenfilename(
title="Bild wählen",
filetypes=[("Images", "*.webp *.png *.jpg *.jpeg *.bmp")],
title=self._t("dialog.open_image_title"),
filetypes=[(self._t("dialog.images_filter"), "*.webp *.png *.jpg *.jpeg *.bmp")],
initialdir=str(default_dir),
)
if not path:
@@ -39,14 +39,17 @@ class ImageProcessingMixin:
def load_folder(self) -> None:
default_dir = IMAGES_DIR if IMAGES_DIR.exists() else Path.cwd()
directory = filedialog.askdirectory(
title="Ordner mit Bildern wählen",
title=self._t("dialog.open_folder_title"),
initialdir=str(default_dir),
)
if not directory:
return
folder = Path(directory)
if not folder.exists():
messagebox.showerror("Fehler", "Der Ordner wurde nicht gefunden.")
messagebox.showerror(
self._t("dialog.error_title"),
self._t("dialog.folder_not_found"),
)
return
image_files = sorted(
(
@@ -57,7 +60,10 @@ class ImageProcessingMixin:
key=lambda item: item.name.lower(),
)
if not image_files:
messagebox.showinfo("Info", "Keine unterstützten Bilder im Ordner gefunden.")
messagebox.showinfo(
self._t("dialog.info_title"),
self._t("dialog.folder_empty"),
)
return
self._set_image_collection(image_files, 0)
@@ -93,12 +99,18 @@ class ImageProcessingMixin:
return
path = self.image_paths[index]
if not path.exists():
messagebox.showerror("Fehler", f"Datei nicht gefunden: {path}")
messagebox.showerror(
self._t("dialog.error_title"),
self._t("dialog.file_missing", path=path),
)
return
try:
image = Image.open(path).convert("RGBA")
except Exception as exc:
messagebox.showerror("Fehler", f"Bild konnte nicht geladen werden: {exc}")
messagebox.showerror(
self._t("dialog.error_title"),
self._t("dialog.image_open_failed", error=exc),
)
return
self.image_path = path
@@ -113,20 +125,32 @@ class ImageProcessingMixin:
dimensions = f"{self.orig_img.width}x{self.orig_img.height}"
suffix = f" [{index + 1}/{len(self.image_paths)}]" if len(self.image_paths) > 1 else ""
status_text = f"Geladen: {path.name}{dimensions}{suffix}"
status_text = self._t("status.loaded", name=path.name, dimensions=dimensions, position=suffix)
self.status.config(text=status_text)
self.status_default_text = status_text
if hasattr(self, "filename_label"):
self.filename_label.config(text=f"{path.name}{dimensions}{suffix}")
filename_text = self._t(
"status.filename_label",
name=path.name,
dimensions=dimensions,
position=suffix,
)
self.filename_label.config(text=filename_text)
self.current_image_index = index
def save_overlay(self) -> None:
if self.orig_img is None:
messagebox.showinfo("Info", "Kein Bild geladen.")
messagebox.showinfo(
self._t("dialog.info_title"),
self._t("dialog.no_image_loaded"),
)
return
if self.preview_img is None:
messagebox.showerror("Fehler", "Keine Preview vorhanden.")
messagebox.showerror(
self._t("dialog.error_title"),
self._t("dialog.no_preview_available"),
)
return
overlay = self._build_overlay_image(
@@ -139,12 +163,17 @@ class ImageProcessingMixin:
merged = Image.alpha_composite(self.orig_img.convert("RGBA"), overlay)
out_path = filedialog.asksaveasfilename(
defaultextension=".png", filetypes=[("PNG", "*.png")], title="Overlay speichern als"
defaultextension=".png",
filetypes=[("PNG", "*.png")],
title=self._t("dialog.save_overlay_title"),
)
if not out_path:
return
merged.save(out_path)
messagebox.showinfo("Gespeichert", f"Overlay gespeichert: {out_path}")
messagebox.showinfo(
self._t("dialog.saved_title"),
self._t("dialog.overlay_saved", path=out_path),
)
def prepare_preview(self) -> None:
if self.orig_img is None:
@@ -185,16 +214,22 @@ class ImageProcessingMixin:
excl_share = (total_ex / total_all * 100) if total_all else 0.0
excl_match = (matches_ex / total_ex * 100) if total_ex else 0.0
self.ratio_label.config(
text=(
f"Markierungen (mit Ausschlüssen): {r_with:.2f}% | "
f"Markierungen (ohne Ausschlüsse): {r_no:.2f}% | "
f"Ausgeschlossen: {excl_share:.2f}% der Pixel, davon {excl_match:.2f}% markiert"
text=self._t(
"stats.summary",
with_pct=r_with,
without_pct=r_no,
excluded_pct=excl_share,
excluded_match_pct=excl_match,
)
)
bg = "#0f0f10" if self.theme == "dark" else "#1e1e1e"
self.canvas_orig.configure(bg=bg)
self.canvas_overlay.configure(bg=bg)
refresher = getattr(self, "_refresh_canvas_backgrounds", None)
if callable(refresher):
refresher()
else:
bg = "#0f0f10" if self.theme == "dark" else "#ffffff"
self.canvas_orig.configure(bg=bg)
self.canvas_overlay.configure(bg=bg)
def create_overlay_preview(self) -> Image.Image | None:
if self.preview_img is None:
+3 -1
View File
@@ -12,7 +12,9 @@ class ResetMixin:
self.val_max.set(self.DEFAULTS["val_max"])
self.alpha.set(self.DEFAULTS["alpha"])
self.update_preview()
default_text = getattr(self, "status_default_text", "Standardwerte aktiv.")
default_text = getattr(self, "status_default_text", None)
if default_text is None:
default_text = self._t("status.defaults_restored") if hasattr(self, "_t") else "Defaults restored."
if hasattr(self, "status"):
self.status.config(text=default_text)