font
This commit is contained in:
BIN
dist/text_texture_generator_v1.0.0.zip
vendored
BIN
dist/text_texture_generator_v1.0.0.zip
vendored
Binary file not shown.
BIN
dist/text_texture_generator_v1.0.0_free.zip
vendored
BIN
dist/text_texture_generator_v1.0.0_free.zip
vendored
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,6 +5,7 @@ Core functionality for generating texture images with text overlays.
|
|||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
from ..utils.constants import has_text_fitting
|
from ..utils.constants import has_text_fitting
|
||||||
|
from ..utils.system import find_default_truetype_font
|
||||||
|
|
||||||
def generate_texture_image(props, width, height):
|
def generate_texture_image(props, width, height):
|
||||||
pass
|
pass
|
||||||
@@ -187,24 +188,48 @@ def generate_texture_image(props, width, height):
|
|||||||
layout_mode = "vertical"
|
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)
|
# Determine font size (with text fitting if enabled)
|
||||||
font_size = props.font_size
|
font_size = props.font_size
|
||||||
if props.enable_text_fitting and has_text_fitting():
|
if props.enable_text_fitting and has_text_fitting():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Try to load font for sizing
|
|
||||||
try:
|
try:
|
||||||
pass
|
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
|
min_size = props.min_font_size
|
||||||
max_size = props.max_font_size
|
max_size = props.max_font_size
|
||||||
target_width = width * (1.0 - props.text_fitting_margin / 100.0)
|
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
|
optimal_size = min_size
|
||||||
for test_size in range(min_size, max_size + 1, 2): # Step by 2 for performance
|
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:
|
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
|
pass
|
||||||
if test_font_path:
|
optimal_size = test_size
|
||||||
pass
|
else:
|
||||||
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):
|
|
||||||
pass
|
pass
|
||||||
break
|
break
|
||||||
|
|
||||||
font_size = optimal_size
|
font_size = optimal_size
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
pass
|
pass
|
||||||
font_size = props.font_size
|
font_size = props.font_size
|
||||||
|
|
||||||
# Load final font
|
font = load_font_for_size(font_size)
|
||||||
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()
|
|
||||||
|
|
||||||
# Calculate text position and draw
|
# Calculate text position and draw
|
||||||
if layout_mode == "vertical" and props.prepend_append_layout == 'VERTICAL':
|
if layout_mode == "vertical" and props.prepend_append_layout == 'VERTICAL':
|
||||||
|
|||||||
Binary file not shown.
@@ -16,39 +16,14 @@ from bpy.props import (
|
|||||||
)
|
)
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# Import update functions from the main module (will need to be adjusted during integration)
|
|
||||||
def update_live_preview(self, context):
|
def update_live_preview(self, context):
|
||||||
pass
|
pass
|
||||||
"""Update preview when properties change"""
|
"""Placeholder property update hook.
|
||||||
if not hasattr(context, 'scene'):
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
|
|
||||||
props = context.scene.text_texture_props
|
Live preview support was removed; keep hook to maintain compatibility
|
||||||
|
with existing property update assignments while doing nothing.
|
||||||
|
"""
|
||||||
# Always update preview unless already updating
|
return
|
||||||
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)
|
|
||||||
|
|
||||||
def get_font_enum_items(self, context):
|
def get_font_enum_items(self, context):
|
||||||
pass
|
pass
|
||||||
|
|||||||
Binary file not shown.
31
src/ui/font_helpers.py
Normal file
31
src/ui/font_helpers.py
Normal 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})"
|
||||||
143
src/ui/panels.py
143
src/ui/panels.py
@@ -14,6 +14,7 @@ from ..utils.constants import (
|
|||||||
has_normal_maps, has_batch_processing, has_advanced_utilities,
|
has_normal_maps, has_batch_processing, has_advanced_utilities,
|
||||||
has_advanced_styling, has_text_fitting
|
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):
|
def draw_font_settings_section(layout, props):
|
||||||
pass
|
pass
|
||||||
"""Draw extended font settings"""
|
"""Draw unified font sizing and fitting controls."""
|
||||||
# Font size moved here from top level
|
content = layout.column(align=True)
|
||||||
layout.prop(props, "font_size")
|
content.use_property_split = True
|
||||||
|
content.use_property_decorate = False
|
||||||
layout.separator()
|
|
||||||
layout.label(text="Font Selection:")
|
sizing_box = content.box()
|
||||||
# Default font dropdown moved here from top level
|
sizing_box.use_property_split = True
|
||||||
row = layout.row(align=True)
|
sizing_box.use_property_decorate = False
|
||||||
row.prop(props, "font_path", text="")
|
sizing_box.label(text="Size & Text Fitting", icon='FULLSCREEN_ENTER')
|
||||||
row.operator("text_texture.refresh_fonts", text="", icon='FILE_REFRESH')
|
|
||||||
|
manual_enabled, status_message = font_size_ui_state(props)
|
||||||
layout.separator()
|
manual_row = sizing_box.row(align=True)
|
||||||
# Custom font controls moved here from top level
|
manual_row.enabled = manual_enabled
|
||||||
layout.prop(props, "use_custom_font")
|
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:
|
if props.use_custom_font:
|
||||||
pass
|
pass
|
||||||
layout.prop(props, "custom_font_path", text="")
|
selection_box.prop(props, "custom_font_path", text="")
|
||||||
|
|
||||||
layout.separator()
|
selection_box.separator(factor=0.5)
|
||||||
row = layout.row(align=True)
|
selection_box.prop(props, "text_align", text="Alignment")
|
||||||
row.prop(props, "padding")
|
|
||||||
row.prop(props, "text_align")
|
|
||||||
|
|
||||||
def draw_colors_section(layout, props):
|
def draw_colors_section(layout, props):
|
||||||
pass
|
pass
|
||||||
@@ -654,55 +690,6 @@ def draw_normal_maps_section(layout, props):
|
|||||||
info_box.label(text="• Blur smooths sharp edges")
|
info_box.label(text="• Blur smooths sharp edges")
|
||||||
info_box.label(text="• Invert changes raised/recessed effect")
|
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):
|
def draw_stroke_section(layout, props):
|
||||||
pass
|
pass
|
||||||
"""Draw stroke and outline controls"""
|
"""Draw stroke and outline controls"""
|
||||||
@@ -916,12 +903,6 @@ class TEXT_TEXTURE_PT_panel(Panel):
|
|||||||
if position_section:
|
if position_section:
|
||||||
draw_positioning_section(position_section, props)
|
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 (collapsed by default)
|
||||||
colors_section = draw_collapsible_header(layout, "expand_colors", "Colors", 'COLOR')
|
colors_section = draw_collapsible_header(layout, "expand_colors", "Colors", 'COLOR')
|
||||||
if colors_section:
|
if colors_section:
|
||||||
@@ -1134,12 +1115,6 @@ class TEXT_TEXTURE_PT_panel_3d(Panel):
|
|||||||
if position_section:
|
if position_section:
|
||||||
draw_positioning_section(position_section, props)
|
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 (collapsed by default)
|
||||||
colors_section = draw_collapsible_header(layout, "expand_colors", "Colors", 'COLOR')
|
colors_section = draw_collapsible_header(layout, "expand_colors", "Colors", 'COLOR')
|
||||||
if colors_section:
|
if colors_section:
|
||||||
@@ -1352,12 +1327,6 @@ class TEXT_TEXTURE_PT_panel_properties(Panel):
|
|||||||
if position_section:
|
if position_section:
|
||||||
draw_positioning_section(position_section, props)
|
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 (collapsed by default)
|
||||||
colors_section = draw_collapsible_header(layout, "expand_colors", "Colors", 'COLOR')
|
colors_section = draw_collapsible_header(layout, "expand_colors", "Colors", 'COLOR')
|
||||||
if colors_section:
|
if colors_section:
|
||||||
|
|||||||
@@ -18,13 +18,14 @@ from .constants import (
|
|||||||
is_free_version,
|
is_free_version,
|
||||||
is_full_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__ = [
|
__all__ = [
|
||||||
'bl_info',
|
'bl_info',
|
||||||
'install_pillow',
|
'install_pillow',
|
||||||
'get_system_fonts',
|
'get_system_fonts',
|
||||||
'get_font_enum_items',
|
'get_font_enum_items',
|
||||||
|
'find_default_truetype_font',
|
||||||
'has_image_overlays',
|
'has_image_overlays',
|
||||||
'has_basic_utilities',
|
'has_basic_utilities',
|
||||||
'has_presets',
|
'has_presets',
|
||||||
@@ -38,4 +39,4 @@ __all__ = [
|
|||||||
'has_shader_generation',
|
'has_shader_generation',
|
||||||
'is_free_version',
|
'is_free_version',
|
||||||
'is_full_version'
|
'is_full_version'
|
||||||
]
|
]
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,6 +7,8 @@ import sys
|
|||||||
import platform
|
import platform
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
_DEFAULT_FONT_CACHE = None
|
||||||
|
|
||||||
def install_pillow():
|
def install_pillow():
|
||||||
pass
|
pass
|
||||||
"""Install Pillow if not available"""
|
"""Install Pillow if not available"""
|
||||||
@@ -98,4 +100,100 @@ def get_anchor_point_matrix():
|
|||||||
['TOP_LEFT', 'TOP_CENTER', 'TOP_RIGHT'],
|
['TOP_LEFT', 'TOP_CENTER', 'TOP_RIGHT'],
|
||||||
['MIDDLE_LEFT', 'MIDDLE_CENTER', 'MIDDLE_RIGHT'],
|
['MIDDLE_LEFT', 'MIDDLE_CENTER', 'MIDDLE_RIGHT'],
|
||||||
['BOTTOM_LEFT', 'BOTTOM_CENTER', 'BOTTOM_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
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -48,6 +48,7 @@ def setup_pil_mocks(mock_pil_image_module, mock_pil_imagedraw_module,
|
|||||||
# Setup ImageFont mock based on font_type
|
# Setup ImageFont mock based on font_type
|
||||||
if font_type == 'default':
|
if font_type == 'default':
|
||||||
mock_font = MagicMock()
|
mock_font = MagicMock()
|
||||||
|
mock_pil_imagefont_module.truetype.return_value = mock_font
|
||||||
mock_pil_imagefont_module.load_default.return_value = mock_font
|
mock_pil_imagefont_module.load_default.return_value = mock_font
|
||||||
elif font_type == 'custom':
|
elif font_type == 'custom':
|
||||||
mock_font = MagicMock()
|
mock_font = MagicMock()
|
||||||
@@ -195,7 +196,47 @@ class TestGenerateTextureImage:
|
|||||||
call_args = mock_pil_image_module.new.call_args
|
call_args = mock_pil_image_module.new.call_args
|
||||||
assert call_args[0][0] == "RGBA"
|
assert call_args[0][0] == "RGBA"
|
||||||
assert call_args[0][2] == (0, 0, 0, 0)
|
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):
|
def test_generate_colored_background(self):
|
||||||
"""Test generation with custom background color."""
|
"""Test generation with custom background color."""
|
||||||
with patch('PIL.Image') as mock_pil_image_module, \
|
with patch('PIL.Image') as mock_pil_image_module, \
|
||||||
|
|||||||
89
tests/unit/properties/test_property_group_updates.py
Normal file
89
tests/unit/properties/test_property_group_updates.py
Normal 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
|
||||||
45
tests/unit/ui/test_font_ui_helpers.py
Normal file
45
tests/unit/ui/test_font_ui_helpers.py
Normal 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 == ""
|
||||||
77
tests/unit/utils/test_system_fonts.py
Normal file
77
tests/unit/utils/test_system_fonts.py
Normal 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"
|
||||||
Reference in New Issue
Block a user