This commit is contained in:
2025-10-13 11:15:21 +02:00
parent 9a6543c530
commit 2519b55f08
24 changed files with 498 additions and 169 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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,30 +237,21 @@ 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
@@ -244,20 +260,7 @@ def generate_texture_image(props, width, height):
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':

View File

@@ -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

31
src/ui/font_helpers.py Normal file
View File

@@ -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})"

View File

@@ -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")
"""Draw unified font sizing and fitting controls."""
content = layout.column(align=True)
content.use_property_split = True
content.use_property_decorate = False
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')
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')
layout.separator()
# Custom font controls moved here from top level
layout.prop(props, "use_custom_font")
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="")
selection_box.prop(props, "custom_font_path", text="")
layout.separator()
row = layout.row(align=True)
row.prop(props, "padding")
row.prop(props, "text_align")
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:

View File

@@ -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',

View File

@@ -7,6 +7,8 @@ import sys
import platform
import os
_DEFAULT_FONT_CACHE = None
def install_pillow():
pass
"""Install Pillow if not available"""
@@ -99,3 +101,99 @@ def get_anchor_point_matrix():
['MIDDLE_LEFT', 'MIDDLE_CENTER', 'MIDDLE_RIGHT'],
['BOTTOM_LEFT', 'BOTTOM_CENTER', 'BOTTOM_RIGHT']
]
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

View File

@@ -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()
@@ -196,6 +197,46 @@ class TestGenerateTextureImage:
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, \

View File

@@ -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

View File

@@ -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 == ""

View File

@@ -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"