diff --git a/dist/text_texture_generator_v1.0.0.zip b/dist/text_texture_generator_v1.0.0.zip index b9ae28e..c84f6eb 100644 Binary files a/dist/text_texture_generator_v1.0.0.zip and b/dist/text_texture_generator_v1.0.0.zip differ diff --git a/dist/text_texture_generator_v1.0.0_free.zip b/dist/text_texture_generator_v1.0.0_free.zip index 697a519..9dad90b 100644 Binary files a/dist/text_texture_generator_v1.0.0_free.zip and b/dist/text_texture_generator_v1.0.0_free.zip differ diff --git a/marketing/demo.blend b/marketing/demo.blend index db0dfbe..840c2f6 100644 Binary files a/marketing/demo.blend and b/marketing/demo.blend differ diff --git a/marketing/demo.blend1 b/marketing/demo.blend1 index 44516cc..db0dfbe 100644 Binary files a/marketing/demo.blend1 and b/marketing/demo.blend1 differ diff --git a/src/__pycache__/__init__.cpython-311.pyc b/src/__pycache__/__init__.cpython-311.pyc index ba30c5d..66b24bc 100644 Binary files a/src/__pycache__/__init__.cpython-311.pyc and b/src/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/core/__pycache__/generation_engine.cpython-311.pyc b/src/core/__pycache__/generation_engine.cpython-311.pyc index 010d8e5..8ff9ced 100644 Binary files a/src/core/__pycache__/generation_engine.cpython-311.pyc and b/src/core/__pycache__/generation_engine.cpython-311.pyc differ diff --git a/src/core/generation_engine.py b/src/core/generation_engine.py index 4122803..4a1bf4e 100644 --- a/src/core/generation_engine.py +++ b/src/core/generation_engine.py @@ -5,6 +5,7 @@ Core functionality for generating texture images with text overlays. import bpy from ..utils.constants import has_text_fitting +from ..utils.system import find_default_truetype_font def generate_texture_image(props, width, height): pass @@ -187,24 +188,48 @@ def generate_texture_image(props, width, height): layout_mode = "vertical" + # Determine candidate font paths based on user configuration + candidate_paths = [] + seen_candidates = set() + for candidate in ( + getattr(props, 'custom_font_path', '') if getattr(props, 'use_custom_font', False) else '', + getattr(props, 'font_path', '') if getattr(props, 'font_path', 'default') != "default" else '', + find_default_truetype_font() + ): + if candidate and candidate not in seen_candidates: + candidate_paths.append(candidate) + seen_candidates.add(candidate) + + failed_candidates = set() + active_font_path = None + + def load_font_for_size(size): + pass + nonlocal active_font_path + for path in candidate_paths: + pass + if path in failed_candidates: + pass + continue + try: + pass + font_obj = ImageFont.truetype(path, size) + active_font_path = path + return font_obj + except (OSError, IOError, AttributeError, TypeError) as font_error: + pass + failed_candidates.add(path) + continue + active_font_path = None + return ImageFont.load_default() + # Determine font size (with text fitting if enabled) font_size = props.font_size if props.enable_text_fitting and has_text_fitting(): pass - # Try to load font for sizing try: pass - if props.use_custom_font and props.custom_font_path: - pass - test_font_path = props.custom_font_path - elif props.font_path != "default": - test_font_path = props.font_path - else: - pass - test_font_path = None - - # Binary search for optimal font size min_size = props.min_font_size max_size = props.max_font_size target_width = width * (1.0 - props.text_fitting_margin / 100.0) @@ -212,52 +237,30 @@ def generate_texture_image(props, width, height): optimal_size = min_size for test_size in range(min_size, max_size + 1, 2): # Step by 2 for performance + pass + test_font = load_font_for_size(test_size) + try: + bbox = draw.textbbox((0, 0), full_text, font=test_font) + test_width = bbox[2] - bbox[0] + test_height = bbox[3] - bbox[1] + except (ValueError, AttributeError): + # Fallback for bitmap fonts + test_width, test_height = draw.textsize(full_text, font=test_font) + + if test_width <= target_width and test_height <= target_height: pass - if test_font_path: - pass - test_font = ImageFont.truetype(test_font_path, test_size) - else: - pass - test_font = ImageFont.load_default() - - try: - bbox = draw.textbbox((0, 0), full_text, font=test_font) - test_width = bbox[2] - bbox[0] - test_height = bbox[3] - bbox[1] - except (ValueError, AttributeError): - # Fallback for bitmap fonts - test_width, test_height = draw.textsize(full_text, font=test_font) - - if test_width <= target_width and test_height <= target_height: - pass - optimal_size = test_size - else: - pass - break - except (OSError, IOError): + optimal_size = test_size + else: pass break - + font_size = optimal_size except Exception as e: pass font_size = props.font_size - # Load final font - try: - pass - if props.use_custom_font and props.custom_font_path: - pass - font = ImageFont.truetype(props.custom_font_path, font_size) - elif props.font_path != "default": - font = ImageFont.truetype(props.font_path, font_size) - else: - pass - font = ImageFont.load_default() - except (OSError, IOError) as e: - pass - font = ImageFont.load_default() + font = load_font_for_size(font_size) # Calculate text position and draw if layout_mode == "vertical" and props.prepend_append_layout == 'VERTICAL': diff --git a/src/properties/__pycache__/property_groups.cpython-311.pyc b/src/properties/__pycache__/property_groups.cpython-311.pyc index 9419f73..0bc47d9 100644 Binary files a/src/properties/__pycache__/property_groups.cpython-311.pyc and b/src/properties/__pycache__/property_groups.cpython-311.pyc differ diff --git a/src/properties/property_groups.py b/src/properties/property_groups.py index 71586f1..9fbe664 100644 --- a/src/properties/property_groups.py +++ b/src/properties/property_groups.py @@ -16,39 +16,14 @@ from bpy.props import ( ) import os -# Import update functions from the main module (will need to be adjusted during integration) def update_live_preview(self, context): pass - """Update preview when properties change""" - if not hasattr(context, 'scene'): - pass - return + """Placeholder property update hook. - props = context.scene.text_texture_props - - - # Always update preview unless already updating - if not props.is_updating: - pass - # Use a timer to debounce rapid property changes for better performance - if hasattr(update_live_preview, '_timer'): - pass - bpy.app.timers.unregister(update_live_preview._timer) - - def delayed_preview(): - pass - # Import generate_preview function (will need to be adjusted during integration) - try: - pass - from . import generate_preview - generate_preview(context) - except ImportError: - pass - # Fallback during development - return None # Stop timer - - update_live_preview._timer = delayed_preview - bpy.app.timers.register(delayed_preview, first_interval=0.1) + Live preview support was removed; keep hook to maintain compatibility + with existing property update assignments while doing nothing. + """ + return def get_font_enum_items(self, context): pass diff --git a/src/ui/__pycache__/panels.cpython-311.pyc b/src/ui/__pycache__/panels.cpython-311.pyc index 0826de7..df9ab04 100644 Binary files a/src/ui/__pycache__/panels.cpython-311.pyc and b/src/ui/__pycache__/panels.cpython-311.pyc differ diff --git a/src/ui/font_helpers.py b/src/ui/font_helpers.py new file mode 100644 index 0000000..7533784 --- /dev/null +++ b/src/ui/font_helpers.py @@ -0,0 +1,31 @@ +""" +Helper utilities for font sizing and text fitting presentation logic. +""" + +from ..utils.constants import has_text_fitting + +FONT_FITTING_MODE_LABELS = { + 'FIT_WIDTH': "Fit Width", + 'FIT_HEIGHT': "Fit Height", + 'FIT_BOTH': "Fit Width & Height", + 'FIT_CONTAIN': "Fit Contain", +} + + +def font_size_ui_state(props): + """ + Determine whether the manual font size control should be enabled and what + status message to show based on the current text fitting configuration. + + Returns: + tuple(bool, str): (is_manual_enabled, status_message) + """ + fitting_available = bool(has_text_fitting()) + fitting_enabled = fitting_available and bool(getattr(props, "enable_text_fitting", False)) + + if not fitting_enabled: + return True, "" + + mode = getattr(props, "text_fitting_mode", "") + mode_label = FONT_FITTING_MODE_LABELS.get(mode, mode.replace("_", " ").title() if mode else "Auto") + return False, f"Auto-sized via Text Fitting ({mode_label})" diff --git a/src/ui/panels.py b/src/ui/panels.py index ab92be9..8945a5a 100644 --- a/src/ui/panels.py +++ b/src/ui/panels.py @@ -14,6 +14,7 @@ from ..utils.constants import ( has_normal_maps, has_batch_processing, has_advanced_utilities, has_advanced_styling, has_text_fitting ) +from .font_helpers import font_size_ui_state # ============================================================================ @@ -403,28 +404,63 @@ def draw_positioning_section(layout, props): def draw_font_settings_section(layout, props): pass - """Draw extended font settings""" - # Font size moved here from top level - layout.prop(props, "font_size") - - layout.separator() - layout.label(text="Font Selection:") - # Default font dropdown moved here from top level - row = layout.row(align=True) - row.prop(props, "font_path", text="") - row.operator("text_texture.refresh_fonts", text="", icon='FILE_REFRESH') - - layout.separator() - # Custom font controls moved here from top level - layout.prop(props, "use_custom_font") + """Draw unified font sizing and fitting controls.""" + content = layout.column(align=True) + content.use_property_split = True + content.use_property_decorate = False + + sizing_box = content.box() + sizing_box.use_property_split = True + sizing_box.use_property_decorate = False + sizing_box.label(text="Size & Text Fitting", icon='FULLSCREEN_ENTER') + + manual_enabled, status_message = font_size_ui_state(props) + manual_row = sizing_box.row(align=True) + manual_row.enabled = manual_enabled + manual_row.prop(props, "font_size", text="Manual Size") + + if status_message: + info_row = sizing_box.row() + info_row.label(text=status_message, icon='INFO') + + if has_text_fitting(): + toggle_row = sizing_box.row(align=True) + toggle_row.prop(props, "enable_text_fitting", text="Enable Text Fitting", toggle=True) + + if props.enable_text_fitting: + sizing_box.separator(factor=0.5) + + mode_row = sizing_box.row(align=True) + mode_row.prop(props, "text_fitting_mode", text="Mode") + + margin_row = sizing_box.row(align=True) + margin_row.prop(props, "text_fitting_margin", text="Margin", slider=True) + + limit_row = sizing_box.row(align=True) + limit_row.prop(props, "min_font_size", text="Min Size") + limit_row.prop(props, "max_font_size", text="Max Size") + else: + sizing_box.separator(factor=0.5) + draw_feature_lock_indicator(sizing_box, 'text_fitting', show_hint=False) + + content.separator(factor=0.8) + + selection_box = content.box() + selection_box.use_property_split = True + selection_box.use_property_decorate = False + selection_box.label(text="Font Selection", icon='FONT_DATA') + + font_row = selection_box.row(align=True) + font_row.prop(props, "font_path", text="") + font_row.operator("text_texture.refresh_fonts", text="", icon='FILE_REFRESH') + + selection_box.prop(props, "use_custom_font") if props.use_custom_font: pass - layout.prop(props, "custom_font_path", text="") - - layout.separator() - row = layout.row(align=True) - row.prop(props, "padding") - row.prop(props, "text_align") + selection_box.prop(props, "custom_font_path", text="") + + selection_box.separator(factor=0.5) + selection_box.prop(props, "text_align", text="Alignment") def draw_colors_section(layout, props): pass @@ -654,55 +690,6 @@ def draw_normal_maps_section(layout, props): info_box.label(text="• Blur smooths sharp edges") info_box.label(text="• Invert changes raised/recessed effect") -def draw_text_fitting_section(layout, props): - """Draw text fitting controls""" - # Check if text fitting is available - if not has_text_fitting(): - draw_feature_lock_indicator(layout, 'text_fitting') - return - # Main toggle for text fitting - layout.prop(props, "enable_text_fitting", text="Enable Text Fitting", icon='FULLSCREEN_ENTER') - - # Show controls only if text fitting is enabled - if props.enable_text_fitting: - pass - layout.separator() - - # Fitting mode selection - layout.prop(props, "text_fitting_mode", text="Fitting Mode") - - layout.separator() - - # Margin setting - layout.prop(props, "text_fitting_margin", text="Margin", slider=True) - - layout.separator() - - # Font size limits - col = layout.column(align=True) - col.label(text="Font Size Limits:", icon='RESTRICT_SELECT_ON') - row = col.row(align=True) - row.prop(props, "min_font_size", text="Min") - row.prop(props, "max_font_size", text="Max") - - # Info box with usage tips - layout.separator() - info_box = layout.box() - info_box.scale_y = 0.8 - info_box.label(text="💡 Text fitting automatically sizes text", icon='INFO') - - if props.text_fitting_mode == 'FIT_WIDTH': - pass - info_box.label(text="• Fits text width to canvas width") - elif props.text_fitting_mode == 'FIT_HEIGHT': - info_box.label(text="• Fits text height to canvas height") - elif props.text_fitting_mode == 'FIT_BOTH': - info_box.label(text="• Fits both width and height (may distort)") - elif props.text_fitting_mode == 'FIT_CONTAIN': - info_box.label(text="• Fits entirely within canvas (preserves ratio)") - - info_box.label(text="• Respects min/max font size limits") - def draw_stroke_section(layout, props): pass """Draw stroke and outline controls""" @@ -916,12 +903,6 @@ class TEXT_TEXTURE_PT_panel(Panel): if position_section: draw_positioning_section(position_section, props) - # Text Fitting Section (collapsed by default) - text_fitting_section = draw_collapsible_header(layout, "expand_text_fitting", "Text Fitting", 'FULLSCREEN_ENTER') - if text_fitting_section: - pass - draw_text_fitting_section(text_fitting_section, props) - # Colors Section (collapsed by default) colors_section = draw_collapsible_header(layout, "expand_colors", "Colors", 'COLOR') if colors_section: @@ -1134,12 +1115,6 @@ class TEXT_TEXTURE_PT_panel_3d(Panel): if position_section: draw_positioning_section(position_section, props) - # Text Fitting Section (collapsed by default) - text_fitting_section = draw_collapsible_header(layout, "expand_text_fitting", "Text Fitting", 'FULLSCREEN_ENTER') - if text_fitting_section: - pass - draw_text_fitting_section(text_fitting_section, props) - # Colors Section (collapsed by default) colors_section = draw_collapsible_header(layout, "expand_colors", "Colors", 'COLOR') if colors_section: @@ -1352,12 +1327,6 @@ class TEXT_TEXTURE_PT_panel_properties(Panel): if position_section: draw_positioning_section(position_section, props) - # Text Fitting Section (collapsed by default) - text_fitting_section = draw_collapsible_header(layout, "expand_text_fitting", "Text Fitting", 'FULLSCREEN_ENTER') - if text_fitting_section: - pass - draw_text_fitting_section(text_fitting_section, props) - # Colors Section (collapsed by default) colors_section = draw_collapsible_header(layout, "expand_colors", "Colors", 'COLOR') if colors_section: diff --git a/src/utils/__init__.py b/src/utils/__init__.py index bce60b0..4967617 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -18,13 +18,14 @@ from .constants import ( is_free_version, is_full_version ) -from .system import install_pillow, get_system_fonts, get_font_enum_items +from .system import install_pillow, get_system_fonts, get_font_enum_items, find_default_truetype_font __all__ = [ 'bl_info', 'install_pillow', 'get_system_fonts', 'get_font_enum_items', + 'find_default_truetype_font', 'has_image_overlays', 'has_basic_utilities', 'has_presets', @@ -38,4 +39,4 @@ __all__ = [ 'has_shader_generation', 'is_free_version', 'is_full_version' -] \ No newline at end of file +] diff --git a/src/utils/__pycache__/__init__.cpython-311.pyc b/src/utils/__pycache__/__init__.cpython-311.pyc index 74b0bf0..b567240 100644 Binary files a/src/utils/__pycache__/__init__.cpython-311.pyc and b/src/utils/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/utils/__pycache__/constants.cpython-311.pyc b/src/utils/__pycache__/constants.cpython-311.pyc index 4396ae6..f4ea3ec 100644 Binary files a/src/utils/__pycache__/constants.cpython-311.pyc and b/src/utils/__pycache__/constants.cpython-311.pyc differ diff --git a/src/utils/__pycache__/system.cpython-311.pyc b/src/utils/__pycache__/system.cpython-311.pyc index e5bfb02..b4a9e7d 100644 Binary files a/src/utils/__pycache__/system.cpython-311.pyc and b/src/utils/__pycache__/system.cpython-311.pyc differ diff --git a/src/utils/system.py b/src/utils/system.py index cbe1d82..6d981d5 100644 --- a/src/utils/system.py +++ b/src/utils/system.py @@ -7,6 +7,8 @@ import sys import platform import os +_DEFAULT_FONT_CACHE = None + def install_pillow(): pass """Install Pillow if not available""" @@ -98,4 +100,100 @@ def get_anchor_point_matrix(): ['TOP_LEFT', 'TOP_CENTER', 'TOP_RIGHT'], ['MIDDLE_LEFT', 'MIDDLE_CENTER', 'MIDDLE_RIGHT'], ['BOTTOM_LEFT', 'BOTTOM_CENTER', 'BOTTOM_RIGHT'] - ] \ No newline at end of file + ] + +def find_default_truetype_font(): + pass + """Return a system font path that can act as scalable default.""" + global _DEFAULT_FONT_CACHE + if _DEFAULT_FONT_CACHE is not None: + pass + return _DEFAULT_FONT_CACHE + + system = platform.system().lower() + preferred_fonts = [] + if system == "windows": + preferred_fonts = [ + "C:/Windows/Fonts/arial.ttf", + "C:/Windows/Fonts/segoeui.ttf", + "C:/Windows/Fonts/calibri.ttf", + "C:/Windows/Fonts/tahoma.ttf", + ] + elif system == "darwin": + preferred_fonts = [ + "/System/Library/Fonts/SFNS.ttf", + "/System/Library/Fonts/SFNSDisplay.ttf", + "/System/Library/Fonts/Helvetica.ttc", + "/Library/Fonts/Arial.ttf", + "/Library/Fonts/HelveticaNeue.ttc", + ] + else: + preferred_fonts = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/truetype/freefont/FreeSans.ttf", + ] + + def _font_supports_basic_latin(font_path): + pass + try: + from PIL import ImageFont + except ImportError: + return False + + try: + font = ImageFont.truetype(font_path, 48) + except (OSError, IOError, TypeError, ValueError): + return False + + try: + bbox_upper = font.getbbox("H") + bbox_lower = font.getbbox("e") + except Exception: + return False + + if not bbox_upper or not bbox_lower: + return False + + try: + upper_width = float(bbox_upper[2] - bbox_upper[0]) + upper_height = float(bbox_upper[3] - bbox_upper[1]) + lower_width = float(bbox_lower[2] - bbox_lower[0]) + lower_height = float(bbox_lower[3] - bbox_lower[1]) + except (TypeError, ValueError): + return False + + if min(upper_width, upper_height, lower_width, lower_height) <= 0: + return False + + if lower_height > upper_height * 1.3: + return False + + if lower_width > upper_width * 1.5: + return False + + return True + + for candidate in preferred_fonts: + if candidate and os.path.exists(candidate) and _font_supports_basic_latin(candidate): + _DEFAULT_FONT_CACHE = candidate + return _DEFAULT_FONT_CACHE + + fonts = get_system_fonts() + for _, font_path in sorted(fonts.items()): + if os.path.exists(font_path) and _font_supports_basic_latin(font_path): + _DEFAULT_FONT_CACHE = font_path + return _DEFAULT_FONT_CACHE + + try: + from ..ui.preview import get_system_fallback_fonts + fallback_fonts = get_system_fallback_fonts() + for font_path in fallback_fonts: + if os.path.exists(font_path) and _font_supports_basic_latin(font_path): + _DEFAULT_FONT_CACHE = font_path + return _DEFAULT_FONT_CACHE + except Exception: + pass + + _DEFAULT_FONT_CACHE = None + return _DEFAULT_FONT_CACHE diff --git a/tests/unit/__pycache__/test_text_processor.cpython-311-pytest-8.3.5.pyc b/tests/unit/__pycache__/test_text_processor.cpython-311-pytest-8.3.5.pyc index 99b2bd1..3227e0d 100644 Binary files a/tests/unit/__pycache__/test_text_processor.cpython-311-pytest-8.3.5.pyc and b/tests/unit/__pycache__/test_text_processor.cpython-311-pytest-8.3.5.pyc differ diff --git a/tests/unit/__pycache__/test_version_differentiation.cpython-311-pytest-8.3.5.pyc b/tests/unit/__pycache__/test_version_differentiation.cpython-311-pytest-8.3.5.pyc index 03f03e4..8c12a76 100644 Binary files a/tests/unit/__pycache__/test_version_differentiation.cpython-311-pytest-8.3.5.pyc and b/tests/unit/__pycache__/test_version_differentiation.cpython-311-pytest-8.3.5.pyc differ diff --git a/tests/unit/core/__pycache__/test_generation_engine.cpython-311-pytest-8.3.5.pyc b/tests/unit/core/__pycache__/test_generation_engine.cpython-311-pytest-8.3.5.pyc index 9d9ba3f..b726b6d 100644 Binary files a/tests/unit/core/__pycache__/test_generation_engine.cpython-311-pytest-8.3.5.pyc and b/tests/unit/core/__pycache__/test_generation_engine.cpython-311-pytest-8.3.5.pyc differ diff --git a/tests/unit/core/test_generation_engine.py b/tests/unit/core/test_generation_engine.py index 501beab..e50e021 100644 --- a/tests/unit/core/test_generation_engine.py +++ b/tests/unit/core/test_generation_engine.py @@ -48,6 +48,7 @@ def setup_pil_mocks(mock_pil_image_module, mock_pil_imagedraw_module, # Setup ImageFont mock based on font_type if font_type == 'default': mock_font = MagicMock() + mock_pil_imagefont_module.truetype.return_value = mock_font mock_pil_imagefont_module.load_default.return_value = mock_font elif font_type == 'custom': mock_font = MagicMock() @@ -195,7 +196,47 @@ class TestGenerateTextureImage: call_args = mock_pil_image_module.new.call_args assert call_args[0][0] == "RGBA" assert call_args[0][2] == (0, 0, 0, 0) - + + def test_default_font_uses_scalable_size(self): + """Ensure default font configuration respects requested font size.""" + with patch('PIL.Image') as mock_pil_image_module, \ + patch('PIL.ImageDraw') as mock_pil_imagedraw_module, \ + patch('PIL.ImageFont') as mock_pil_imagefont_module, \ + patch('src.core.generation_engine.bpy') as mock_bpy, \ + patch('src.core.generation_engine.find_default_truetype_font', return_value="/fonts/fallback.ttf", create=True): + + setup_pil_mocks( + mock_pil_image_module, + mock_pil_imagedraw_module, + mock_pil_imagefont_module, + DEFAULT_WIDTH, + DEFAULT_HEIGHT, + font_type='default' + ) + setup_bpy_mocks(mock_bpy, DEFAULT_WIDTH, DEFAULT_HEIGHT) + + fallback_font = MagicMock() + + def truetype_side_effect(font_path_arg, size_arg): + if font_path_arg is None: + raise TypeError("file must be a path, not None") + return fallback_font + + mock_pil_imagefont_module.truetype.side_effect = truetype_side_effect + mock_pil_imagefont_module.load_default.return_value = MagicMock() + + props = create_mock_props( + text="Size Probe", + font_path='default', + use_custom_font=False, + font_size=42 + ) + + generate_texture_image(props, DEFAULT_WIDTH, DEFAULT_HEIGHT) + + mock_pil_imagefont_module.truetype.assert_called_with("/fonts/fallback.ttf", 42) + mock_pil_imagefont_module.load_default.assert_not_called() + def test_generate_colored_background(self): """Test generation with custom background color.""" with patch('PIL.Image') as mock_pil_image_module, \ diff --git a/tests/unit/properties/test_property_group_updates.py b/tests/unit/properties/test_property_group_updates.py new file mode 100644 index 0000000..c56c84f --- /dev/null +++ b/tests/unit/properties/test_property_group_updates.py @@ -0,0 +1,89 @@ +"""Tests for property group update hooks.""" + +from types import ModuleType, SimpleNamespace +import importlib.util +import pathlib +import sys + +import pytest + + +@pytest.fixture +def property_groups_module(): + """Import property_groups with minimal bpy stubs.""" + module_name = "src.properties.property_groups" + original_modules = { + name: sys.modules.get(name) + for name in (module_name, "bpy", "bpy.props", "bpy.types", "src", "src.properties") + } + + class TypesStub(SimpleNamespace): + def __getattr__(self, name): + value = type(name, (), {}) + setattr(self, name, value) + return value + + bpy_stub = SimpleNamespace( + props=SimpleNamespace( + StringProperty=lambda **kwargs: None, + IntProperty=lambda **kwargs: None, + FloatVectorProperty=lambda **kwargs: None, + FloatProperty=lambda **kwargs: None, + BoolProperty=lambda **kwargs: None, + EnumProperty=lambda **kwargs: None, + CollectionProperty=lambda **kwargs: None, + PointerProperty=lambda **kwargs: None, + ), + types=TypesStub(PropertyGroup=object), + ) + + sys.modules["bpy"] = bpy_stub + sys.modules["bpy.props"] = bpy_stub.props + sys.modules["bpy.types"] = bpy_stub.types + + src_module = sys.modules.get("src") + if src_module is None: + src_module = ModuleType("src") + src_module.__path__ = [str(pathlib.Path(__file__).resolve().parents[3] / "src")] + sys.modules["src"] = src_module + + props_package = sys.modules.get("src.properties") + if props_package is None: + props_package = ModuleType("src.properties") + props_package.__path__ = [str(pathlib.Path(__file__).resolve().parents[3] / "src" / "properties")] + sys.modules["src.properties"] = props_package + + spec = importlib.util.spec_from_file_location( + module_name, + pathlib.Path(__file__).resolve().parents[3] / "src" / "properties" / "property_groups.py", + ) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[module_name] = module + spec.loader.exec_module(module) + + try: + yield module + finally: + for name, value in original_modules.items(): + if value is None: + sys.modules.pop(name, None) + else: + sys.modules[name] = value + + +def test_update_live_preview_no_scene_is_noop(property_groups_module): + """Function should safely return when context has no scene.""" + result = property_groups_module.update_live_preview(SimpleNamespace(), SimpleNamespace()) + assert result is None + + +def test_update_live_preview_with_scene_noop(property_groups_module): + """Even with props present, function should not modify state.""" + props = SimpleNamespace(is_updating=False) + context = SimpleNamespace(scene=SimpleNamespace(text_texture_props=props)) + + result = property_groups_module.update_live_preview(SimpleNamespace(), context) + + assert result is None + assert props.is_updating is False diff --git a/tests/unit/ui/test_font_ui_helpers.py b/tests/unit/ui/test_font_ui_helpers.py new file mode 100644 index 0000000..d05666e --- /dev/null +++ b/tests/unit/ui/test_font_ui_helpers.py @@ -0,0 +1,45 @@ +"""Unit tests for font sizing UI helpers.""" + +from types import SimpleNamespace +import importlib + +import pytest + + +@pytest.fixture +def font_helpers_module(): + """Import the font helper utilities.""" + return importlib.import_module("src.ui.font_helpers") + + +def test_font_size_ui_state_manual_when_fitting_disabled(monkeypatch, font_helpers_module): + """Manual font size should remain enabled when fitting is off.""" + monkeypatch.setattr(font_helpers_module, "has_text_fitting", lambda: True) + props = SimpleNamespace(enable_text_fitting=False, text_fitting_mode="FIT_CONTAIN") + + enabled, message = font_helpers_module.font_size_ui_state(props) + + assert enabled is True + assert message == "" + + +def test_font_size_ui_state_auto_when_fitting_enabled(monkeypatch, font_helpers_module): + """Font size control shows auto-sizing message when fitting is active.""" + monkeypatch.setattr(font_helpers_module, "has_text_fitting", lambda: True) + props = SimpleNamespace(enable_text_fitting=True, text_fitting_mode="FIT_WIDTH") + + enabled, message = font_helpers_module.font_size_ui_state(props) + + assert enabled is False + assert "Fit Width" in message + + +def test_font_size_ui_state_without_text_fitting_feature(monkeypatch, font_helpers_module): + """Without text fitting support, manual font sizing stays enabled.""" + monkeypatch.setattr(font_helpers_module, "has_text_fitting", lambda: False) + props = SimpleNamespace(enable_text_fitting=True, text_fitting_mode="FIT_WIDTH") + + enabled, message = font_helpers_module.font_size_ui_state(props) + + assert enabled is True + assert message == "" diff --git a/tests/unit/utils/test_system_fonts.py b/tests/unit/utils/test_system_fonts.py new file mode 100644 index 0000000..f7b25fa --- /dev/null +++ b/tests/unit/utils/test_system_fonts.py @@ -0,0 +1,77 @@ +"""Tests for system font utilities.""" + +from types import ModuleType +import importlib.util +import pathlib +import sys + +import pytest + + +@pytest.fixture +def system_module(): + """Load the system utilities module without importing the addon __init__.""" + module_name = "src.utils.system" + original = sys.modules.pop(module_name, None) + + spec = importlib.util.spec_from_file_location( + module_name, + pathlib.Path(__file__).resolve().parents[3] / "src" / "utils" / "system.py", + ) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[module_name] = module + spec.loader.exec_module(module) + try: + yield module + finally: + if original is None: + sys.modules.pop(module_name, None) + else: + sys.modules[module_name] = original + + +def test_find_default_truetype_font_skips_fonts_without_latin(monkeypatch, system_module): + """Ensure selection skips fonts that produce unusable glyph metrics.""" + try: + from PIL import ImageFont + except ImportError: + pytest.skip("Pillow not available in test environment") + + def fake_system(): + return "Darwin" + + monkeypatch.setattr(system_module.platform, "system", fake_system) + + def fake_exists(path): + return path in {"/bad/font.ttf", "/good/font.ttf"} + + monkeypatch.setattr(system_module.os.path, "exists", fake_exists) + + monkeypatch.setattr( + system_module, + "get_system_fonts", + lambda: {"Bad": "/bad/font.ttf", "Good": "/good/font.ttf"}, + ) + + class FontStub: + def __init__(self, path): + self.path = path + + def getbbox(self, text): + if self.path == "/bad/font.ttf": + if text == "H": + return (0, 10, 40, 50) + return (0, 0, 100, 200) + if text == "H": + return (0, 12, 36, 52) + return (0, 20, 30, 48) + + def fake_truetype(path, size): + return FontStub(path) + + monkeypatch.setattr(ImageFont, "truetype", fake_truetype) + system_module._DEFAULT_FONT_CACHE = None + + selected = system_module.find_default_truetype_font() + assert selected == "/good/font.ttf"