From 47b4bf34811c869485de1f1f8eb47d406758a1b2 Mon Sep 17 00:00:00 2001 From: David Collado Date: Mon, 18 May 2026 15:02:24 +0200 Subject: [PATCH] Fix localized asset resolution for region codes --- src/koubou/config.py | 8 ++++--- src/koubou/generator.py | 44 ++++++++++++++++++++++++----------- tests/test_config.py | 27 ++++++++++++++++++++++ tests/test_generator.py | 51 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 16 deletions(-) diff --git a/src/koubou/config.py b/src/koubou/config.py index c9424f6..d30be10 100644 --- a/src/koubou/config.py +++ b/src/koubou/config.py @@ -52,6 +52,7 @@ def resolve_output_size(size: Union[str, Tuple[int, int]]) -> Tuple[int, int]: # Hex color validation pattern: #RGB, #RRGGBB, or #RRGGBBAA HEX_COLOR_PATTERN = re.compile(r"^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$") +LANGUAGE_CODE_PATTERN = re.compile(r"^[A-Za-z]{2,3}(?:[-_][A-Za-z0-9]{2,8})*$") def validate_hex_color(color: str, field_name: str = "Color") -> None: @@ -651,12 +652,13 @@ def validate_asset_format( if not v: raise ValueError("Asset dict cannot be empty") - # Validate keys are valid language codes (2-3 letters or 'default') + # Validate keys are valid language codes or 'default' for key in v.keys(): - if key != "default" and not (2 <= len(key) <= 3 and key.isalpha()): + if key != "default" and not LANGUAGE_CODE_PATTERN.match(key): raise ValueError( f"Invalid language code '{key}'. " - "Use 2-3 letter codes (e.g., 'en', 'es', 'pt') or 'default'" + "Use language codes (e.g., 'en', 'es', 'en-US') " + "or 'default'" ) # Validate all values are non-empty strings diff --git a/src/koubou/generator.py b/src/koubou/generator.py index 0acd4f8..1b9ff95 100644 --- a/src/koubou/generator.py +++ b/src/koubou/generator.py @@ -90,11 +90,24 @@ def resolve_localized_asset( if not asset: return "" + def language_candidates(*codes: str) -> List[str]: + candidates: List[str] = [] + for code in codes: + if not code: + continue + normalized = code.strip() + short = normalized.split("-", 1)[0].split("_", 1)[0] + for candidate in (normalized, short): + if candidate and candidate not in candidates: + candidates.append(candidate) + return candidates + # Case 1: Dict format - Explicit per-language mapping if isinstance(asset, dict): - # Try exact language match - if language in asset: - return asset[language] + # Try exact language match, then language-only fallbacks like en-US -> en + for candidate in language_candidates(language): + if candidate in asset: + return asset[candidate] # Try default fallback if "default" in asset: return asset["default"] @@ -113,16 +126,21 @@ def path_exists(p: Path) -> bool: return (config_dir / p).exists() return p.exists() - # Try {lang}/ convention - lang_path = asset_path.parent / language / asset_path.name - if path_exists(lang_path): - return str(lang_path) - - # Try {base_lang}/ convention (if different from current lang) - if base_language != language: - base_lang_path = asset_path.parent / base_language / asset_path.name - if path_exists(base_lang_path): - return str(base_lang_path) + # Try {lang}/ convention, including language-only fallback en-US -> en + for candidate in language_candidates(language, base_language): + localized_path = asset_path.parent / candidate / asset_path.name + if path_exists(localized_path): + return str(localized_path) + + # Also support a language directory directly under the asset root: + # screenshots/iphone/01.png -> screenshots/{lang}/iphone/01.png + if not asset_path.is_absolute() and len(asset_path.parts) > 2: + asset_root = Path(asset_path.parts[0]) + asset_rest = Path(*asset_path.parts[1:]) + for candidate in language_candidates(language, base_language): + localized_path = asset_root / candidate / asset_rest + if path_exists(localized_path): + return str(localized_path) # Fallback to direct path return asset diff --git a/tests/test_config.py b/tests/test_config.py index a8802bd..7e85b1c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -259,6 +259,33 @@ def test_alignment_rejects_invalid_value(self): ) +class TestContentItemLocalizedAssets: + """Tests for ContentItem localized asset validation.""" + + def test_asset_mapping_accepts_region_language_codes(self): + item = ContentItem( + type="image", + asset={ + "en-US": "screenshots/en/iphone/01.png", + "es-ES": "screenshots/es/iphone/01.png", + "fr": "screenshots/fr/iphone/01.png", + "default": "screenshots/iphone/01.png", + }, + position=("50%", "50%"), + ) + + assert isinstance(item.asset, dict) + assert item.asset["en-US"] == "screenshots/en/iphone/01.png" + + def test_asset_mapping_rejects_invalid_language_code(self): + with pytest.raises(ValidationError, match="Invalid language code"): + ContentItem( + type="image", + asset={"english": "screenshots/en/iphone/01.png"}, + position=("50%", "50%"), + ) + + class TestContentItemHighlight: """Tests for ContentItem with type='highlight'.""" diff --git a/tests/test_generator.py b/tests/test_generator.py index 7f34557..3283f01 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -948,6 +948,35 @@ def test_dict_format_exact_match(self): result = resolve_localized_asset(asset, "es", "en") assert result == "path/to/es.png" + def test_dict_format_region_language_fallback(self): + """Test dict format falls back from region code to language code.""" + from koubou.generator import resolve_localized_asset + + asset = { + "en": "path/to/en.png", + "es": "path/to/es.png", + "default": "path/to/default.png", + } + + result = resolve_localized_asset(asset, "es-ES", "en-US") + assert result == "path/to/es.png" + + result = resolve_localized_asset(asset, "fr-FR", "en-US") + assert result == "path/to/default.png" + + def test_dict_format_region_exact_match_wins(self): + """Test dict format prefers exact region match before language fallback.""" + from koubou.generator import resolve_localized_asset + + asset = { + "en": "path/to/en.png", + "en-US": "path/to/en-us.png", + "es": "path/to/es.png", + } + + result = resolve_localized_asset(asset, "en-US", "en-US") + assert result == "path/to/en-us.png" + def test_dict_format_default_fallback(self): """Test dict format falls back to default when language not found.""" from koubou.generator import resolve_localized_asset @@ -980,6 +1009,28 @@ def test_string_format_lang_convention(self): result = resolve_localized_asset(asset, "es", "en", self.temp_dir) assert result == "screenshots/es/hero.png" + def test_string_format_region_language_fallback(self): + """Test string format falls back from region code to language directory.""" + from koubou.generator import resolve_localized_asset + + asset = "screenshots/hero.png" + + result = resolve_localized_asset(asset, "es-ES", "en-US", self.temp_dir) + assert result == "screenshots/es/hero.png" + + def test_string_format_root_language_subdirectory_fallback(self): + """Test string format supports screenshots/{lang}/iphone/file.png.""" + from koubou.generator import resolve_localized_asset + + iphone_dir = self.screenshots_dir / "es" / "iphone" + iphone_dir.mkdir(parents=True) + Image.new("RGB", (100, 100), (255, 255, 0)).save(iphone_dir / "hero.png") + + asset = "screenshots/iphone/hero.png" + + result = resolve_localized_asset(asset, "es-ES", "en-US", self.temp_dir) + assert result == "screenshots/es/iphone/hero.png" + def test_string_format_base_lang_fallback(self): """Test string format falls back to base_language when lang not found.""" from koubou.generator import resolve_localized_asset