diff --git a/src/element_style.py b/src/element_style.py index d4893362..22aa4a8f 100644 --- a/src/element_style.py +++ b/src/element_style.py @@ -244,7 +244,7 @@ def _load_font_sized(font_name: str, size: int) -> Tuple[Any, int]: path = resolve_font_path(font_name) if path is None: logger.warning("Font file not found: %s, using fallback", font_name) - return _load_fallback_font(size), size + return _load_fallback_font(size) cache_key = (path, size) cached = _font_cache.get(cache_key) diff --git a/test/test_element_style.py b/test/test_element_style.py index 4131a94b..3352b47a 100644 --- a/test/test_element_style.py +++ b/test/test_element_style.py @@ -359,6 +359,19 @@ class TestDegradation: # The override IS honored as forced, but the face degrades safely. assert style.user_forced assert style.font is not None + assert not isinstance(style.font, tuple) + + def test_missing_font_load_returns_a_font_not_a_nested_tuple(self): + # Regression: the missing-font path in _load_font_sized used to wrap + # _load_fallback_font's own (font, size) tuple a second time, so + # load_font() returned a (font, size) tuple where callers (draw.text, + # layout math) expect a font object. + font, size = _load_font_sized("definitely-not-a-real-font.ttf", 8) + assert not isinstance(font, tuple) + assert size == 8 + + single = load_font("definitely-not-a-real-font.ttf", 8) + assert not isinstance(single, tuple) def test_empty_defaults_treats_config_as_reference_to_classic(self): # No schema defaults at all: a config value equal to the classic diff --git a/test/test_web_api.py b/test/test_web_api.py index fb6ae1d3..9a1bd1f7 100644 --- a/test/test_web_api.py +++ b/test/test_web_api.py @@ -825,10 +825,10 @@ class TestFontsAPI: # 405, not 404: the path still matches DELETE /fonts/, # which now reads "overrides" as a font name. Nothing is routed to a # handler for GET or POST, which is what matters here. - assert client.get('/api/v3/fonts/overrides').status_code != 200 + assert client.get('/api/v3/fonts/overrides').status_code == 405 assert client.post('/api/v3/fonts/overrides', data=json.dumps({}), - content_type='application/json').status_code != 200 + content_type='application/json').status_code == 405 class TestAPIErrorHandling: diff --git a/test/web_interface/test_api_v3_helpers.py b/test/web_interface/test_api_v3_helpers.py index dc21864b..fbd142bf 100644 --- a/test/web_interface/test_api_v3_helpers.py +++ b/test/web_interface/test_api_v3_helpers.py @@ -25,6 +25,7 @@ from web_interface.blueprints.api_v3 import ( # noqa: E402 _parse_form_value, _get_schema_property, _set_nested_value, + _SKIP_FIELD, ) @@ -221,12 +222,21 @@ class TestSetNestedValue: _set_nested_value(config, "fifa.world.enabled", True) assert config == {"fifa.world": {"enabled": True}} - def test_none_does_not_overwrite_existing(self): + def test_none_overwrites_existing(self): + # Regression: None is a real value here (the per-mode "inherit the + # base" override for a nullable field, or a blank indexed color + # channel) and must replace whatever was already stored. Only the + # _SKIP_FIELD sentinel means "leave it alone" -- see the next test. config = {"a": 1} _set_nested_value(config, "a", None) - assert config == {"a": 1} + assert config == {"a": None} def test_none_sets_missing_key(self): config = {} _set_nested_value(config, "a", None) assert config == {"a": None} + + def test_skip_field_does_not_overwrite_existing(self): + config = {"a": 1} + _set_nested_value(config, "a", _SKIP_FIELD) + assert config == {"a": 1} diff --git a/web_interface/blueprints/api_v3/__init__.py b/web_interface/blueprints/api_v3/__init__.py index ea6da893..c1786667 100644 --- a/web_interface/blueprints/api_v3/__init__.py +++ b/web_interface/blueprints/api_v3/__init__.py @@ -1026,9 +1026,11 @@ def _set_nested_value(config, key_path, value): current[seg] = {} current = current[seg] - # Set the final value (don't overwrite with empty dict if value is None and we want to preserve structure) - if value is not None or segments[-1] not in current: - current[segments[-1]] = value + # Set the final value. _SKIP_FIELD (checked above) is the only sentinel + # for "leave the existing value alone" -- an explicit None here is a real + # value (e.g. the per-mode "inherit the base" override) and must overwrite + # whatever was already stored. + current[segments[-1]] = value def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', config_node=None): """Walk schema and set missing boolean form fields to False. diff --git a/web_interface/static/v3/js/widgets/font-selector.js b/web_interface/static/v3/js/widgets/font-selector.js index cd0ee139..c8c84d1d 100644 --- a/web_interface/static/v3/js/widgets/font-selector.js +++ b/web_interface/static/v3/js/widgets/font-selector.js @@ -121,7 +121,9 @@ family: family, display_name: info.display_name || generateDisplayName(info.filename || family), path: info.path, - type: info.type || 'unknown' + type: info.type || 'unknown', + scalable: info.scalable, + native_size: info.native_size })); } else if (Array.isArray(data)) { // Direct array format diff --git a/web_interface/static/v3/js/widgets/style-editor.js b/web_interface/static/v3/js/widgets/style-editor.js index f095f234..dbf3b613 100644 --- a/web_interface/static/v3/js/widgets/style-editor.js +++ b/web_interface/static/v3/js/widgets/style-editor.js @@ -44,7 +44,10 @@ if (FONT_CACHE) { return Promise.resolve(FONT_CACHE); } if (FONT_INFLIGHT) { return FONT_INFLIGHT; } FONT_INFLIGHT = fetch('/api/v3/fonts/catalog') - .then(function (r) { return r.json(); }) + .then(function (r) { + if (!r.ok) { throw new Error('font catalog fetch failed: ' + r.status); } + return r.json(); + }) .then(function (payload) { var catalog = (payload && payload.data && payload.data.catalog) || {}; FONT_CACHE = Object.keys(catalog).map(function (key) { @@ -60,8 +63,10 @@ }) .catch(function (e) { console.warn('[StyleEditor] could not load the font catalog', e); - FONT_CACHE = []; - return FONT_CACHE; + // Leave FONT_CACHE unset and clear FONT_INFLIGHT so the next + // call retries instead of being stuck on an empty result. + FONT_INFLIGHT = null; + return []; }); return FONT_INFLIGHT; } @@ -138,9 +143,13 @@ * rather than a list curated by hand, which is what these fields used * to carry and why an uploaded font could never appear in one. */ - function usableFonts(fonts, maxFixedSize) { + function usableFonts(fonts, maxFixedSize, current) { if (!maxFixedSize) { return fonts; } return fonts.filter(function (f) { + // Keep the font already saved on this element even if it no + // longer fits the cap -- dropping it would leave the select + // with nothing chosen and silently misrepresent the config. + if (current && f.filename === current) { return true; } if (f.scalable !== false) { return true; } // An unknown native size is not evidence it is too big. if (!f.nativeSize) { return true; } @@ -358,7 +367,7 @@ var xOptions = prop['x-options'] || prop['x_options'] || {}; var cap = Number(xOptions.maxFixedSize) || opts.maxFixedSize || null; return fontControl(opts.name, opts.current, - usableFonts(opts.fonts, cap), + usableFonts(opts.fonts, cap, opts.current), opts.optional, opts.onFontChange); } if (types.indexOf('boolean') !== -1) { diff --git a/web_interface/templates/v3/partials/plugin_config.html b/web_interface/templates/v3/partials/plugin_config.html index 4cf94efe..699e4081 100644 --- a/web_interface/templates/v3/partials/plugin_config.html +++ b/web_interface/templates/v3/partials/plugin_config.html @@ -88,6 +88,23 @@