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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user