diff --git a/Archive.zip b/Archive.zip index 00aca0d..6b72954 100644 Binary files a/Archive.zip and b/Archive.zip differ diff --git a/__init__.py b/__init__.py index b9cbbdd..50432a1 100644 --- a/__init__.py +++ b/__init__.py @@ -16,7 +16,7 @@ bl_info = { "author": "Marc Mintel ", "version": (2, 3, 2), "blender": (4, 0, 0), - "location": "3D View > Sidebar (N) > Tool Tab | Shader Editor > Sidebar > Tool Tab | Properties > Material > Tool", + "location": "3D View > Sidebar (N) > Tool Tab | Shader Editor > Sidebar > Tool Tab", "description": "Generate image textures from text with accurate dimensions and instant live preview", "category": "Material", } @@ -93,8 +93,51 @@ def get_font_enum_items(self, context): return items +# ============================================================================ +# PRESET STORAGE SYSTEM - V2.0 (PERSISTENT STORAGE) +# ============================================================================ +# +# The Text Texture Generator now uses a THREE-TIER preset storage system +# that ensures preset persistence across addon updates: +# +# 1. BLEND FILE STORAGE (Highest Priority) +# - Presets travel with the .blend file +# - Perfect for project-specific presets +# - Stored in scene custom properties +# +# 2. PERSISTENT FILE STORAGE (Medium Priority) +# - Survives addon updates and Blender upgrades +# - Stored in Blender's user config directory +# - Shared across all blend files +# +# 3. LEGACY FILE STORAGE (Lowest Priority) +# - Old system for backward compatibility +# - Vulnerable to addon updates +# - Will be automatically migrated to persistent storage +# +# MIGRATION SYSTEM: +# - Automatically detects addon version changes +# - Migrates legacy presets to persistent storage +# - Creates backups before migration +# - Provides detailed migration reports +# +# IMPORT/EXPORT SYSTEM: +# - Export all presets for manual backup +# - Import presets with conflict resolution +# - Supports cross-machine preset sharing +# +# ============================================================================ + def get_preset_path(): - """Get path for storing presets (legacy local storage)""" + """Get path for storing presets (legacy local storage) + + LEGACY SYSTEM: This storage location is vulnerable to addon updates. + Presets stored here will be lost when the addon is updated through + Blender's Add-ons preferences. This function is maintained for + backward compatibility only. + + For new installations, use get_persistent_preset_path() instead. + """ addon_dir = os.path.dirname(os.path.realpath(__file__)) presets_dir = os.path.join(addon_dir, "presets") if not os.path.exists(presets_dir): @@ -104,6 +147,190 @@ def get_preset_path(): print(f"Error creating presets directory: {e}") return presets_dir +def get_persistent_preset_path(): + """Get persistent path for storing presets (survives addon updates)""" + import bpy + + # Get Blender's user config directory + config_dir = bpy.utils.user_resource('CONFIG') + if not config_dir: + # Fallback to addon directory if config path unavailable + print("[TTG] Warning: Could not get user config directory, falling back to addon directory") + return get_preset_path() + + # Create addon-specific subdirectory + persistent_dir = os.path.join(config_dir, "text_texture_generator", "presets") + + try: + if not os.path.exists(persistent_dir): + os.makedirs(persistent_dir, exist_ok=True) + return persistent_dir + except OSError as e: + print(f"[TTG] Error creating persistent presets directory: {e}") + # Fallback to addon directory + return get_preset_path() + +def get_addon_version(): + """Get current addon version for migration tracking""" + return bl_info["version"] + +def get_version_file_path(): + """Get path to version tracking file""" + persistent_dir = os.path.dirname(get_persistent_preset_path()) + return os.path.join(persistent_dir, "addon_version.json") + +def get_stored_version(): + """Get previously stored addon version""" + version_file = get_version_file_path() + try: + if os.path.exists(version_file): + with open(version_file, 'r') as f: + data = json.load(f) + return tuple(data.get('version', (0, 0, 0))) + except (json.JSONDecodeError, OSError) as e: + print(f"[TTG] Error reading version file: {e}") + return (0, 0, 0) + +def save_current_version(): + """Save current addon version to persistent storage""" + version_file = get_version_file_path() + try: + version_data = { + 'version': get_addon_version(), + 'timestamp': time.time() + } + with open(version_file, 'w') as f: + json.dump(version_data, f, indent=2) + print(f"[TTG] Saved addon version {get_addon_version()} to persistent storage") + except (OSError, json.JSONEncodeError) as e: + print(f"[TTG] Error saving version file: {e}") + +def migrate_presets_if_needed(): + """Migrate presets from old location to persistent storage if needed""" + current_version = get_addon_version() + stored_version = get_stored_version() + + print(f"[TTG] Version check - Current: {current_version}, Stored: {stored_version}") + + # Check if this is a fresh installation or version change + if stored_version == (0, 0, 0) or current_version != stored_version: + print(f"[TTG] Version change detected, checking for preset migration...") + + # Get paths + old_preset_dir = get_preset_path() # Legacy addon directory + new_preset_dir = get_persistent_preset_path() # Persistent location + + migrated_count = 0 + migration_errors = [] + + # Migrate from old addon directory if it exists and has presets + if os.path.exists(old_preset_dir) and old_preset_dir != new_preset_dir: + print(f"[TTG] Migrating presets from {old_preset_dir} to {new_preset_dir}") + + try: + # Create backup before migration + backup_path = backup_presets() + if backup_path: + print(f"[TTG] Created migration backup at {backup_path}") + + for filename in os.listdir(old_preset_dir): + if filename.endswith('.json'): + preset_name = os.path.splitext(filename)[0] + old_file = os.path.join(old_preset_dir, filename) + new_file = os.path.join(new_preset_dir, filename) + + try: + # Only copy if destination doesn't exist or is older + should_copy = True + if os.path.exists(new_file): + old_mtime = os.path.getmtime(old_file) + new_mtime = os.path.getmtime(new_file) + should_copy = old_mtime > new_mtime + + if should_copy: + import shutil + shutil.copy2(old_file, new_file) + migrated_count += 1 + print(f"[TTG] ✅ Migrated preset: {preset_name}") + else: + print(f"[TTG] ⏭️ Skipped preset (newer exists): {preset_name}") + except Exception as preset_error: + error_msg = f"Failed to migrate '{preset_name}': {preset_error}" + migration_errors.append(error_msg) + print(f"[TTG] ❌ {error_msg}") + + # Store migration results for user notification + migration_data = { + 'migrated_count': migrated_count, + 'total_found': len([f for f in os.listdir(old_preset_dir) if f.endswith('.json')]), + 'errors': migration_errors, + 'backup_path': backup_path, + 'timestamp': time.time() + } + + # Save migration report + try: + migration_report_file = os.path.join(os.path.dirname(new_preset_dir), "migration_report.json") + with open(migration_report_file, 'w') as f: + json.dump(migration_data, f, indent=2) + print(f"[TTG] Migration report saved to {migration_report_file}") + except Exception as e: + print(f"[TTG] Warning: Could not save migration report: {e}") + + if migrated_count > 0: + print(f"[TTG] ✅ Successfully migrated {migrated_count} presets to persistent storage") + print(f"[TTG] 🛡️ Your presets are now safe from addon updates!") + else: + print(f"[TTG] ℹ️ No presets needed migration") + + if migration_errors: + print(f"[TTG] ⚠️ {len(migration_errors)} presets had migration errors") + + except OSError as e: + print(f"[TTG] ❌ Error during preset migration: {e}") + migration_errors.append(f"General migration error: {e}") + + # Save current version + save_current_version() + + # Return migration summary + return { + 'migrated_count': migrated_count, + 'errors': migration_errors, + 'version_updated': True + } + + return { + 'migrated_count': 0, + 'errors': [], + 'version_updated': False + } + +def backup_presets(): + """Create backup of current presets""" + persistent_dir = get_persistent_preset_path() + backup_dir = os.path.join(os.path.dirname(persistent_dir), "preset_backups") + + try: + if not os.path.exists(backup_dir): + os.makedirs(backup_dir, exist_ok=True) + + # Create timestamped backup + import datetime + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + backup_subdir = os.path.join(backup_dir, f"backup_{timestamp}") + + if os.path.exists(persistent_dir): + import shutil + shutil.copytree(persistent_dir, backup_subdir) + print(f"[TTG] Created preset backup at {backup_subdir}") + return backup_subdir + + except OSError as e: + print(f"[TTG] Error creating preset backup: {e}") + + return None + def get_blend_file_presets(): """Get presets stored in the current blend file""" try: @@ -281,11 +508,40 @@ def initialize_presets(): props = bpy.context.scene.text_texture_props if not props: return + + # Check if migration is needed first + migration_result = migrate_presets_if_needed() + + # Show migration notification if there were significant changes + if migration_result.get('migrated_count', 0) > 0 or migration_result.get('errors'): + def show_migration_notification(): + # Use a timer to show notification after UI is ready + try: + import bpy + if hasattr(bpy.context, 'window_manager'): + migrated_count = migration_result.get('migrated_count', 0) + errors = migration_result.get('errors', []) + + if migrated_count > 0: + print(f"[TTG] 🎉 MIGRATION SUCCESS: {migrated_count} presets migrated to persistent storage!") + print(f"[TTG] 🛡️ Your presets are now protected from addon updates.") + + if errors: + print(f"[TTG] ⚠️ WARNING: {len(errors)} presets had migration errors.") + print(f"[TTG] Use 'Migration Report' button in Presets section for details.") + except Exception as e: + print(f"[TTG] Could not show migration notification: {e}") + return None # Stop timer + + # Schedule notification + bpy.app.timers.register(show_migration_notification, first_interval=2.0) # Get existing preset names for optimization existing_presets = {p.name: p for p in props.presets} - # Load presets from blend file first (primary source) + # Three-tier loading priority: Blend File > Persistent File > Legacy File + + # 1. Load presets from blend file first (highest priority) blend_presets = get_blend_file_presets() updated_presets = set() @@ -301,15 +557,38 @@ def initialize_presets(): preset.name = preset_name preset.source = 'BLEND_FILE' - # Load legacy local presets as fallback - preset_dir = get_preset_path() - if os.path.exists(preset_dir): + # 2. Load from persistent storage (survives addon updates) + persistent_dir = get_persistent_preset_path() + if os.path.exists(persistent_dir): try: - for filename in os.listdir(preset_dir): + for filename in os.listdir(persistent_dir): if filename.endswith('.json'): preset_name = os.path.splitext(filename)[0] # Only add if not already present from blend file if preset_name not in blend_presets: + updated_presets.add(preset_name) + if preset_name in existing_presets: + # Update existing preset source if needed + if existing_presets[preset_name].source != 'PERSISTENT_FILE': + existing_presets[preset_name].source = 'PERSISTENT_FILE' + else: + # Add new preset + preset = props.presets.add() + preset.name = preset_name + preset.source = 'PERSISTENT_FILE' + print(f"[TTG] Loaded persistent presets from {persistent_dir}") + except OSError as e: + print(f"[TTG] Error loading persistent presets during initialization: {e}") + + # 3. Load legacy local presets as fallback (lowest priority) + legacy_dir = get_preset_path() + if os.path.exists(legacy_dir) and legacy_dir != persistent_dir: + try: + for filename in os.listdir(legacy_dir): + if filename.endswith('.json'): + preset_name = os.path.splitext(filename)[0] + # Only add if not already present from higher priority sources + if preset_name not in blend_presets and preset_name not in updated_presets: updated_presets.add(preset_name) if preset_name in existing_presets: # Update existing preset source if needed @@ -320,17 +599,23 @@ def initialize_presets(): preset = props.presets.add() preset.name = preset_name preset.source = 'LOCAL_FILE' + print(f"[TTG] Loaded legacy presets from {legacy_dir}") except OSError as e: - print(f"Error loading local presets during initialization: {e}") + print(f"[TTG] Error loading legacy presets during initialization: {e}") # Remove presets that no longer exist for i in range(len(props.presets) - 1, -1, -1): if props.presets[i].name not in updated_presets: props.presets.remove(i) - print(f"Initialized {len(props.presets)} presets ({len(blend_presets)} from blend file)") + # Count presets by source for reporting + blend_count = len([p for p in props.presets if p.source == 'BLEND_FILE']) + persistent_count = len([p for p in props.presets if p.source == 'PERSISTENT_FILE']) + legacy_count = len([p for p in props.presets if p.source == 'LOCAL_FILE']) + + print(f"[TTG] Initialized {len(props.presets)} presets ({blend_count} blend file, {persistent_count} persistent, {legacy_count} legacy)") except Exception as e: - print(f"Error in initialize_presets: {e}") + print(f"[TTG] Error in initialize_presets: {e}") # ============================================================================ # PREVIEW GENERATION FUNCTIONS @@ -705,10 +990,10 @@ def generate_texture_image(props, width, height): # Scale font size proportionally for preview scale_factor = width / props.texture_width - font_size_pixels = max(6, int(props.font_size * scale_factor)) + base_font_size = max(6, int(props.font_size * scale_factor)) padding = max(0, int(props.padding * scale_factor)) - print(f"DEBUG: Scale factor: {scale_factor}, scaled font size: {font_size_pixels}, scaled padding: {padding}") + print(f"DEBUG: Scale factor: {scale_factor}, scaled font size: {base_font_size}, scaled padding: {padding}") # Create base image with background if props.background_transparent: @@ -724,86 +1009,256 @@ def generate_texture_image(props, width, height): # Collect all elements with z-index (text is z-index 0) elements = [] - # Add image overlays - for overlay in props.image_overlays: - if overlay.enabled and overlay.image_path and os.path.exists(overlay.image_path): + # ENHANCEMENT: Filter disabled overlays early to completely exclude them from all calculations + # This ensures disabled overlays don't consume space in APPEND/PREPEND modes and don't render + # When overlays are disabled (enabled=False), they are completely excluded from: + # - Space calculations, text bounds calculations, image positioning, spacing calculations, and rendering + enabled_overlays = [overlay for overlay in props.image_overlays if overlay.enabled] + + # Separate enabled overlays by positioning mode and sort them + absolute_overlays = [] + prepend_overlays = [] + append_overlays = [] + + for overlay in enabled_overlays: + if overlay.image_path and os.path.exists(overlay.image_path): + if overlay.positioning_mode == 'ABSOLUTE': + absolute_overlays.append(overlay) + elif overlay.positioning_mode == 'PREPEND': + prepend_overlays.append(overlay) + elif overlay.positioning_mode == 'APPEND': + append_overlays.append(overlay) + + # Sort prepend and append overlays by sequence_index + prepend_overlays.sort(key=lambda x: x.sequence_index) + append_overlays.sort(key=lambda x: x.sequence_index) + + # PLACEHOLDER: text_bbox will be calculated after text setup + text_bbox = None + + # PERFORMANCE FIX: Cache loaded overlay images to prevent multiple loading + overlay_image_cache = {} + + def load_overlay_image_cached(overlay_path, scale, scale_factor): + """Load and cache overlay images to improve performance""" + cache_key = (overlay_path, scale, scale_factor) + if cache_key in overlay_image_cache: + return overlay_image_cache[cache_key], None # Return cached image, no error + + try: + # ERROR HANDLING: Check file existence and validate path + if not overlay_path or not os.path.exists(overlay_path): + return None, f"Image file not found: {overlay_path}" + + # EDGE CASE: Check file size to prevent memory issues try: - overlay_img = Image.open(overlay.image_path).convert('RGBA') + file_size = os.path.getsize(overlay_path) + if file_size > 50 * 1024 * 1024: # 50MB limit + return None, f"Image file too large: {file_size} bytes" + except OSError: + return None, f"Cannot access image file: {overlay_path}" + + # Load and convert image + overlay_img = Image.open(overlay_path).convert("RGBA") + + # EDGE CASE: Check for zero dimensions + if overlay_img.width == 0 or overlay_img.height == 0: + return None, f"Image has zero dimensions: {overlay_img.width}x{overlay_img.height}" + + # Apply scaling + if scale != 1.0: + # EDGE CASE: Prevent extreme scaling that could cause memory issues + final_scale = max(0.01, min(10.0, scale * scale_factor)) # Limit scale between 1% and 1000% + new_width = max(1, int(overlay_img.width * final_scale)) + new_height = max(1, int(overlay_img.height * final_scale)) - # Scale overlay - if overlay.scale != 1.0: - new_size = (int(overlay_img.width * overlay.scale * scale_factor), - int(overlay_img.height * overlay.scale * scale_factor)) - overlay_img = overlay_img.resize(new_size, Image.LANCZOS) + # EDGE CASE: Prevent extremely large scaled images + if new_width > 4096 or new_height > 4096: + return None, f"Scaled image too large: {new_width}x{new_height}" - # Rotate overlay if needed - if overlay.rotation != 0: - overlay_img = overlay_img.rotate(overlay.rotation, expand=True) + overlay_img = overlay_img.resize((new_width, new_height), Image.LANCZOS) + + # Cache the processed image + overlay_image_cache[cache_key] = overlay_img + return overlay_img, None + + except Exception as e: + error_msg = f"Error loading overlay image '{overlay_path}': {str(e)}" + return None, error_msg + + # Apply overlays in z-index order, grouped by positioning mode + all_overlays_with_mode = [] + + # Add absolute overlays (maintain backward compatibility) + for overlay in absolute_overlays: + all_overlays_with_mode.append((overlay, 'ABSOLUTE')) + + # Add prepend overlays (positioned to the left of text) + prepend_x_offset = 0 + for i, overlay in enumerate(prepend_overlays): + all_overlays_with_mode.append((overlay, 'PREPEND', prepend_x_offset)) + if text_bbox: + # Calculate spacing for next overlay using cached loading + temp_overlay_img, error = load_overlay_image_cached(overlay.image_path, overlay.scale, scale_factor) + if temp_overlay_img: + # EDGE CASE: Validate spacing values to prevent extreme spacing + text_spacing = max(0, min(1000, (overlay.text_spacing / 100.0) * base_font_size)) + image_spacing = max(0, min(1000, (overlay.image_spacing / 100.0) * base_font_size)) - # Calculate position - x_pos = int(overlay.x_position * width - overlay_img.width / 2) - y_pos = int((1.0 - overlay.y_position) * height - overlay_img.height / 2) # Flip Y for intuitive top-to-bottom + # Add image width plus spacing for next position + prepend_x_offset += temp_overlay_img.width + (text_spacing if i == 0 else image_spacing) + else: + print(f"Warning: {error}") + # Fallback spacing if image can't be loaded + prepend_x_offset += base_font_size + + # Add append overlays (positioned to the right of text) + append_x_offset = 0 + for i, overlay in enumerate(append_overlays): + all_overlays_with_mode.append((overlay, 'APPEND', append_x_offset)) + if text_bbox: + # Calculate spacing for next overlay using cached loading + temp_overlay_img, error = load_overlay_image_cached(overlay.image_path, overlay.scale, scale_factor) + if temp_overlay_img: + # EDGE CASE: Validate spacing values to prevent extreme spacing + text_spacing = max(0, min(1000, (overlay.text_spacing / 100.0) * base_font_size)) + image_spacing = max(0, min(1000, (overlay.image_spacing / 100.0) * base_font_size)) - elements.append((overlay.z_index, 'image', overlay_img, x_pos, y_pos)) - print(f"Added overlay {overlay.name} at z-index {overlay.z_index}, pos ({x_pos}, {y_pos})") + # Calculate position for this overlay, then update offset for next + if i > 0: + append_x_offset += image_spacing + else: + append_x_offset += text_spacing - except Exception as e: - print(f"Error loading overlay {overlay.name}: {e}") + # Update offset for next overlay + if i < len(append_overlays) - 1: # Don't add for last overlay + append_x_offset += temp_overlay_img.width + else: + print(f"Warning: {error}") + # Fallback spacing if image can't be loaded + append_x_offset += base_font_size if i == 0 else base_font_size * 0.5 + + # Sort all overlays by z-index + all_overlays_with_mode.sort(key=lambda x: x[0].z_index) + + # Apply overlays using cached loading with comprehensive error handling + for overlay_data in all_overlays_with_mode: + overlay = overlay_data[0] + mode = overlay_data[1] + + # Use cached loading for better performance and error handling + overlay_img, error = load_overlay_image_cached(overlay.image_path, overlay.scale, scale_factor) + + if overlay_img is None: + print(f"Skipping overlay {overlay.name}: {error}") + continue + + try: + # Apply rotation with error handling + if overlay.rotation != 0: + # EDGE CASE: Validate rotation range + rotation_angle = overlay.rotation % 360 # Normalize to 0-360 + if abs(rotation_angle) > 0.1: # Only rotate if meaningful angle + overlay_img = overlay_img.rotate(rotation_angle, expand=True) + + # Calculate position based on positioning mode + if mode == 'ABSOLUTE': + # Use existing absolute positioning (backward compatibility) + # EDGE CASE: Ensure position values are valid + x_position = max(0.0, min(1.0, overlay.x_position)) + y_position = max(0.0, min(1.0, overlay.y_position)) + x_pos = int(x_position * width - overlay_img.width / 2) + y_pos = int((1.0 - y_position) * height - overlay_img.height / 2) + elif mode == 'PREPEND' and text_bbox: + # Position to the left of text bounds + x_offset = overlay_data[2] if len(overlay_data) > 2 else 0 + x_pos = int(text_bbox[0] - x_offset - overlay_img.width) + y_pos = int(text_bbox[1] + (text_bbox[3] - text_bbox[1] - overlay_img.height) / 2) + elif mode == 'APPEND' and text_bbox: + # Position to the right of text bounds + x_offset = overlay_data[2] if len(overlay_data) > 2 else 0 + x_pos = int(text_bbox[2] + x_offset) + y_pos = int(text_bbox[1] + (text_bbox[3] - text_bbox[1] - overlay_img.height) / 2) + else: + # Fallback to absolute positioning + x_position = max(0.0, min(1.0, overlay.x_position)) + y_position = max(0.0, min(1.0, overlay.y_position)) + x_pos = int(x_position * width - overlay_img.width / 2) + y_pos = int((1.0 - y_position) * height - overlay_img.height / 2) + + # EDGE CASE: Ensure positions are within bounds and handle negative dimensions gracefully + if overlay_img.width <= 0 or overlay_img.height <= 0: + print(f"Warning: Overlay {overlay.name} has invalid dimensions: {overlay_img.width}x{overlay_img.height}") + continue + + x_pos = max(0, min(x_pos, width - overlay_img.width)) + y_pos = max(0, min(y_pos, height - overlay_img.height)) + + # EDGE CASE: Skip overlay if it's completely outside the canvas + if x_pos >= width or y_pos >= height or x_pos + overlay_img.width <= 0 or y_pos + overlay_img.height <= 0: + print(f"Warning: Overlay {overlay.name} is positioned outside canvas bounds, skipping") + continue + + elements.append((overlay.z_index, 'image', overlay_img, x_pos, y_pos)) + print(f"Successfully added overlay {overlay.name} ({mode}) at z-index {overlay.z_index}, pos ({x_pos}, {y_pos})") + + except Exception as e: + print(f"Error processing overlay {overlay.name}: {e}") + import traceback + traceback.print_exc() # Create text element (z-index 0) text_img = Image.new('RGBA', (width, height), (0, 0, 0, 0)) draw = ImageDraw.Draw(text_img) # Load font with enhanced mixed-case validation - font = None - font_loaded = False - - print("DEBUG UPPERCASE FIX: Starting enhanced font loading with mixed-case validation") - - # Try custom font first with validation - if props.use_custom_font and props.custom_font_path: - print(f"DEBUG UPPERCASE FIX: Testing custom font: {props.custom_font_path}") - font, error_msg = get_validated_font(props.custom_font_path, font_size_pixels) - if font: - font_loaded = True - print(f"SUCCESS: Loaded and validated custom font: {props.custom_font_path}") - else: - print(f"FAILED: Custom font validation failed: {error_msg}") - - # Try selected system font with validation - if not font_loaded and props.font_path != "default": - print(f"DEBUG UPPERCASE FIX: Testing system font: {props.font_path}") - font, error_msg = get_validated_font(props.font_path, font_size_pixels) - if font: - font_loaded = True - print(f"SUCCESS: Loaded and validated system font: {props.font_path}") - else: - print(f"FAILED: System font validation failed: {error_msg}") - - # Enhanced fallback with mixed-case validation - if not font_loaded: - print("DEBUG UPPERCASE FIX: Starting validated fallback font search...") - fallback_fonts = get_system_fallback_fonts() + def load_font_at_size(size): + """Helper function to load font at specific size""" + font = None + font_loaded = False - for fallback_path in fallback_fonts: - print(f"DEBUG UPPERCASE FIX: Testing fallback font: {fallback_path}") - if os.path.exists(fallback_path): - font, error_msg = get_validated_font(fallback_path, font_size_pixels) - if font: - font_loaded = True - print(f"SUCCESS: Using validated fallback font: {fallback_path}") - break - else: - print(f"FAILED: Fallback font validation failed: {error_msg}") + # Try custom font first with validation + if props.use_custom_font and props.custom_font_path: + font, error_msg = get_validated_font(props.custom_font_path, size) + if font: + font_loaded = True else: - print(f"SKIP: Fallback font not found: {fallback_path}") + print(f"FAILED: Custom font validation failed: {error_msg}") + + # Try selected system font with validation + if not font_loaded and props.font_path != "default": + font, error_msg = get_validated_font(props.font_path, size) + if font: + font_loaded = True + else: + print(f"FAILED: System font validation failed: {error_msg}") + + # Enhanced fallback with mixed-case validation + if not font_loaded: + fallback_fonts = get_system_fallback_fonts() + + for fallback_path in fallback_fonts: + if os.path.exists(fallback_path): + font, error_msg = get_validated_font(fallback_path, size) + if font: + font_loaded = True + break + else: + print(f"FAILED: Fallback font validation failed: {error_msg}") + + # Last resort - use default font but warn about mixed-case limitations + if not font_loaded: + font = ImageFont.load_default() + font_loaded = True + print("WARNING: Using default font - this may not support mixed-case properly!") + print("WARNING: Consider installing additional fonts for proper mixed-case support") + + return font, font_loaded - # Last resort - use default font but warn about mixed-case limitations - if not font_loaded: - font = ImageFont.load_default() - font_loaded = True - print("WARNING: Using default font - this may not support mixed-case properly!") - print("WARNING: Consider installing additional fonts for proper mixed-case support") + # Initial font loading + print("DEBUG UPPERCASE FIX: Starting enhanced font loading with mixed-case validation") + font_size_pixels = base_font_size + font, font_loaded = load_font_at_size(font_size_pixels) # ============================================================================ # PREPEND/APPEND TEXT PROCESSING @@ -871,6 +1326,121 @@ def generate_texture_image(props, width, height): print("WARNING: Current font may not support mixed-case rendering properly!") print("This could be the cause of uppercase-only text rendering") + # ============================================================================ + # TEXT FITTING SYSTEM + # ============================================================================ + + # ============================================================================ + # PREPEND/APPEND TEXT PROCESSING + # ============================================================================ + + # REMOVED: Duplicate text processing code block - already handled above + + # Apply text fitting if enabled + if props.enable_text_fitting: + print(f"DEBUG TEXT FITTING: Starting text fitting process") + print(f"DEBUG TEXT FITTING: Canvas size: {width}x{height}") + print(f"DEBUG TEXT FITTING: Initial font size: {font_size_pixels}") + print(f"DEBUG TEXT FITTING: Fitting mode: {props.text_fitting_mode}") + + # Calculate available space with margin + margin_px_x = int((props.text_fitting_margin / 100.0) * width) + margin_px_y = int((props.text_fitting_margin / 100.0) * height) + available_width = width - (2 * margin_px_x) + available_height = height - (2 * margin_px_y) + + print(f"DEBUG TEXT FITTING: Available space: {available_width}x{available_height} (margin: {margin_px_x}x{margin_px_y})") + + # Apply scale factor to min/max font sizes + min_font_size_scaled = max(4, int(props.min_font_size * scale_factor)) + max_font_size_scaled = max(min_font_size_scaled, int(props.max_font_size * scale_factor)) + + # Binary search for optimal font size + def calculate_text_dimensions(test_font_size): + """Calculate text dimensions for a given font size""" + try: + test_font, _ = load_font_at_size(test_font_size) + temp_draw = ImageDraw.Draw(Image.new('RGBA', (100, 100))) + + if props.prepend_append_layout == 'VERTICAL' and (prepend_text or append_text): + # Calculate vertical layout dimensions + total_width = 0 + total_height = 0 + + if prepend_text: + prepend_bbox = temp_draw.textbbox((0, 0), prepend_text, font=test_font) + prepend_width = prepend_bbox[2] - prepend_bbox[0] + prepend_height = prepend_bbox[3] - prepend_bbox[1] + total_width = max(total_width, prepend_width) + total_height += prepend_height + int((props.prepend_margin / 100.0) * test_font_size) + + main_bbox = temp_draw.textbbox((0, 0), main_text, font=test_font) + main_width = main_bbox[2] - main_bbox[0] + main_height = main_bbox[3] - main_bbox[1] + total_width = max(total_width, main_width) + total_height += main_height + + if append_text: + append_bbox = temp_draw.textbbox((0, 0), append_text, font=test_font) + append_width = append_bbox[2] - append_bbox[0] + append_height = append_bbox[3] - append_bbox[1] + total_width = max(total_width, append_width) + total_height += int((props.append_margin / 100.0) * test_font_size) + append_height + + return total_width, total_height + else: + # Calculate horizontal layout dimensions + bbox = temp_draw.textbbox((0, 0), text, font=test_font) + return bbox[2] - bbox[0], bbox[3] - bbox[1] + except Exception as e: + print(f"DEBUG TEXT FITTING: Error calculating dimensions: {e}") + # Return fallback dimensions + return int(len(text) * test_font_size * 0.6), test_font_size + + # Binary search for optimal font size + low_size = min_font_size_scaled + high_size = max_font_size_scaled + optimal_size = font_size_pixels + + print(f"DEBUG TEXT FITTING: Binary search range: {low_size} to {high_size}") + + try: + for iteration in range(20): # Limit iterations to prevent infinite loops + mid_size = (low_size + high_size) // 2 + if mid_size == low_size or mid_size == high_size: + break + + test_width, test_height = calculate_text_dimensions(mid_size) + + fits_criteria = False + if props.text_fitting_mode == 'FIT_WIDTH': + fits_criteria = test_width <= available_width + elif props.text_fitting_mode == 'FIT_HEIGHT': + fits_criteria = test_height <= available_height + elif props.text_fitting_mode == 'FIT_BOTH': + fits_criteria = test_width <= available_width and test_height <= available_height + elif props.text_fitting_mode == 'FIT_CONTAIN': + fits_criteria = test_width <= available_width and test_height <= available_height + + print(f"DEBUG TEXT FITTING: Iteration {iteration}: size={mid_size}, dims={test_width}x{test_height}, fits={fits_criteria}") + + if fits_criteria: + optimal_size = mid_size + low_size = mid_size + else: + high_size = mid_size + + # Apply the optimal font size + if optimal_size != font_size_pixels: + font_size_pixels = optimal_size + font, font_loaded = load_font_at_size(font_size_pixels) + print(f"DEBUG TEXT FITTING: Applied optimal font size: {font_size_pixels}") + else: + print(f"DEBUG TEXT FITTING: No adjustment needed, keeping original size: {font_size_pixels}") + except Exception as e: + print(f"DEBUG TEXT FITTING: Error in binary search: {e}") + print("DEBUG TEXT FITTING: Continuing with original font size") + # Handle vertical layout separately if needed if props.prepend_append_layout == 'VERTICAL' and (prepend_text or append_text): # For vertical layout, we need to render each text part separately @@ -938,6 +1508,30 @@ def generate_texture_image(props, width, height): x += props.offset_x y += props.offset_y + # Calculate text bounds for positioning relative overlays (FIXED BUG #1) + # Now that font, x, y, and text variables are properly defined, we can safely calculate text_bbox + if prepend_overlays or append_overlays: + if props.prepend_append_layout == 'VERTICAL' and (prepend_text or append_text): + # For vertical layout, use the total calculated dimensions + text_bbox = (x, y, x + text_width, y + text_height) + else: + # For horizontal layout, calculate bbox using the combined text + try: + # Try new textbbox method (PIL 8.0+) + temp_draw = ImageDraw.Draw(Image.new('RGBA', (100, 100))) + bbox = temp_draw.textbbox((0, 0), text, font=font) + actual_text_width = bbox[2] - bbox[0] + actual_text_height = bbox[3] - bbox[1] + text_bbox = (x, y, x + actual_text_width, y + actual_text_height) + except AttributeError: + # Fallback to deprecated textsize method + temp_draw = ImageDraw.Draw(Image.new('RGBA', (100, 100))) + actual_text_width, actual_text_height = temp_draw.textsize(text, font=font) + text_bbox = (x, y, x + actual_text_width, y + actual_text_height) + else: + # No relative overlays, set bbox to None + text_bbox = None + # Draw text text_color = tuple(int(c * 255) for c in props.text_color[:3]) if len(props.text_color) > 3: @@ -1247,6 +1841,45 @@ class TEXT_TEXTURE_ImageOverlay(PropertyGroup): update=update_live_preview ) + positioning_mode: EnumProperty( + name="Positioning Mode", + description="How the image overlay is positioned relative to the text", + items=[ + ('ABSOLUTE', "Absolute", "Position overlay at absolute coordinates (legacy mode)"), + ('APPEND', "Append", "Position overlay after the text content"), + ('PREPEND', "Prepend", "Position overlay before the text content") + ], + default='ABSOLUTE', + update=update_live_preview + ) + + text_spacing: FloatProperty( + name="Text Spacing", + description="Spacing between text and image as percentage of font size (0-200)", + default=50.0, + min=0.0, + max=200.0, + update=update_live_preview + ) + + image_spacing: FloatProperty( + name="Image Spacing", + description="Spacing between consecutive images as percentage of font size (0-200)", + default=25.0, + min=0.0, + max=200.0, + update=update_live_preview + ) + + sequence_index: IntProperty( + name="Sequence Index", + description="Order index for positioning within append/prepend modes", + default=0, + min=0, + max=99, + update=update_live_preview + ) + enabled: BoolProperty( name="Enabled", description="Enable this image overlay", @@ -1267,6 +1900,7 @@ class TEXT_TEXTURE_Preset(PropertyGroup): description="Where this preset is stored", items=[ ('BLEND_FILE', 'Blend File', 'Preset stored in the blend file (travels with the file)'), + ('PERSISTENT_FILE', 'Persistent File', 'Preset stored in persistent location (survives addon updates)'), ('LOCAL_FILE', 'Local File', 'Preset stored locally (legacy system)') ], default='BLEND_FILE' @@ -1821,6 +2455,65 @@ class TEXT_TEXTURE_Properties(PropertyGroup): default=False, update=update_live_preview ) + + # ============================================================================ + # TEXT FITTING PROPERTIES + # ============================================================================ + + expand_text_fitting: BoolProperty( + name="Text Fitting", + description="Show/hide Text Fitting section", + default=False + ) + + enable_text_fitting: BoolProperty( + name="Enable Text Fitting", + description="Automatically adjust text size to fit within the canvas dimensions", + default=False, + update=update_live_preview + ) + + text_fitting_mode: EnumProperty( + name="Fitting Mode", + description="How to fit text within the canvas", + items=[ + ('FIT_WIDTH', 'Fit Width', 'Scale text to fit canvas width'), + ('FIT_HEIGHT', 'Fit Height', 'Scale text to fit canvas height'), + ('FIT_BOTH', 'Fit Both', 'Scale text to fit both width and height (may cause distortion)'), + ('FIT_CONTAIN', 'Fit Contain', 'Scale text to fit entirely within canvas (preserves aspect ratio)'), + ], + default='FIT_CONTAIN', + update=update_live_preview + ) + + text_fitting_margin: FloatProperty( + name="Fitting Margin", + description="Margin to leave around text when fitting (as percentage of canvas)", + default=10.0, + min=0.0, + max=50.0, + precision=1, + subtype='PERCENTAGE', + update=update_live_preview + ) + + min_font_size: IntProperty( + name="Minimum Font Size", + description="Minimum font size when fitting text (prevents text from becoming too small)", + default=8, + min=4, + max=200, + update=update_live_preview + ) + + max_font_size: IntProperty( + name="Maximum Font Size", + description="Maximum font size when fitting text (prevents text from becoming too large)", + default=500, + min=20, + max=1000, + update=update_live_preview + ) # ============================================================================ # OPERATORS @@ -2680,29 +3373,41 @@ class TEXT_TEXTURE_OT_save_preset(Operator): blend_success = save_blend_file_preset(preset_name, preset_data) print(f"[PRESET SAVE DEBUG] Blend file save result: {blend_success}") - # ALWAYS save to local file for cross-blend-file sharing - print(f"[PRESET SAVE DEBUG] Attempting to save to local file: {preset_file}") - local_success = True + # ALWAYS save to persistent storage for cross-blend-file sharing and addon update survival + persistent_file = os.path.join(get_persistent_preset_path(), f"{preset_name}.json") + print(f"[PRESET SAVE DEBUG] Attempting to save to persistent file: {persistent_file}") + persistent_success = True try: - with open(preset_file, 'w') as f: + with open(persistent_file, 'w') as f: json.dump(preset_data, f, indent=2) - print(f"[PRESET SAVE DEBUG] Successfully saved preset '{preset_name}' to local storage for cross-file sharing") + print(f"[PRESET SAVE DEBUG] Successfully saved preset '{preset_name}' to persistent storage") - # VERIFY: Read back the local file to confirm shader properties were saved - print(f"[PRESET SAVE DEBUG] Verifying local file contents...") - with open(preset_file, 'r') as f: + # VERIFY: Read back the persistent file to confirm shader properties were saved + print(f"[PRESET SAVE DEBUG] Verifying persistent file contents...") + with open(persistent_file, 'r') as f: saved_data = json.load(f) - print(f"[PRESET SAVE DEBUG] Local file shader properties verification:") + print(f"[PRESET SAVE DEBUG] Persistent file shader properties verification:") print(f"[PRESET SAVE DEBUG] disable_shadows: {saved_data.get('disable_shadows', 'NOT FOUND')}") print(f"[PRESET SAVE DEBUG] disable_reflections: {saved_data.get('disable_reflections', 'NOT FOUND')}") print(f"[PRESET SAVE DEBUG] disable_backfacing: {saved_data.get('disable_backfacing', 'NOT FOUND')}") - except Exception as local_error: - print(f"[PRESET SAVE DEBUG] Warning: Failed to save local preset file: {local_error}") - local_success = False + except Exception as persistent_error: + print(f"[PRESET SAVE DEBUG] Warning: Failed to save persistent preset file: {persistent_error}") + persistent_success = False - if not blend_success and not local_success: - self.report({'ERROR'}, f"Failed to save preset to both blend file and local storage") + # Also save to legacy location for backward compatibility (optional) + legacy_success = True + try: + legacy_file = os.path.join(get_preset_path(), f"{preset_name}.json") + with open(legacy_file, 'w') as f: + json.dump(preset_data, f, indent=2) + print(f"[PRESET SAVE DEBUG] Also saved to legacy location for backward compatibility") + except Exception as legacy_error: + print(f"[PRESET SAVE DEBUG] Note: Could not save to legacy location: {legacy_error}") + legacy_success = False + + if not blend_success and not persistent_success: + self.report({'ERROR'}, f"Failed to save preset to both blend file and persistent storage") return {'CANCELLED'} # Check if preset already exists in the collection and update it @@ -2715,22 +3420,23 @@ class TEXT_TEXTURE_OT_save_preset(Operator): if not existing_preset: preset = props.presets.add() preset.name = preset_name - preset.source = 'BLEND_FILE' if blend_success else 'LOCAL_FILE' + # Priority: Blend file > Persistent file + preset.source = 'BLEND_FILE' if blend_success else 'PERSISTENT_FILE' else: # Update source based on where it was successfully saved - existing_preset.source = 'BLEND_FILE' if blend_success else 'LOCAL_FILE' + existing_preset.source = 'BLEND_FILE' if blend_success else 'PERSISTENT_FILE' # Don't clear the input field - keep the name for easy re-saving # props.new_preset_name = "New Preset" # Provide clear feedback storage_info = "" - if blend_success and local_success: - storage_info = " (saved to blend file + local backup)" + if blend_success and persistent_success: + storage_info = " (saved to blend file + persistent storage)" elif blend_success: storage_info = " (saved to blend file)" - elif local_success: - storage_info = " (saved locally only)" + elif persistent_success: + storage_info = " (saved to persistent storage)" if self.overwrite: self.report({'INFO'}, f"✅ Preset '{preset_name}' updated successfully{storage_info}") @@ -2797,7 +3503,7 @@ class TEXT_TEXTURE_OT_load_preset(Operator): print(f"[PRESET LOAD DEBUG] shader_connection: {props.shader_connection}") try: - # First try to load from blend file + # Three-tier loading priority: Blend File > Persistent File > Legacy File blend_presets = get_blend_file_presets() preset_data = None source = 'BLEND_FILE' @@ -2806,28 +3512,42 @@ class TEXT_TEXTURE_OT_load_preset(Operator): if self.preset_name in blend_presets: preset_data = blend_presets[self.preset_name] + source = 'BLEND_FILE' print(f"[PRESET LOAD DEBUG] Loading preset '{self.preset_name}' from blend file") print(f"[PRESET LOAD DEBUG] Blend file preset shader properties:") print(f"[PRESET LOAD DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}") print(f"[PRESET LOAD DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}") print(f"[PRESET LOAD DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}") else: - # Fallback to local file - preset_file = os.path.join(get_preset_path(), f"{self.preset_name}.json") - print(f"[PRESET LOAD DEBUG] Preset not in blend file, trying local file: {preset_file}") - if os.path.exists(preset_file): - with open(preset_file, 'r') as f: + # Try persistent storage next + persistent_file = os.path.join(get_persistent_preset_path(), f"{self.preset_name}.json") + print(f"[PRESET LOAD DEBUG] Preset not in blend file, trying persistent file: {persistent_file}") + if os.path.exists(persistent_file): + with open(persistent_file, 'r') as f: preset_data = json.load(f) - source = 'LOCAL_FILE' - print(f"[PRESET LOAD DEBUG] Loading preset '{self.preset_name}' from local file") - print(f"[PRESET LOAD DEBUG] Local file preset shader properties:") + source = 'PERSISTENT_FILE' + print(f"[PRESET LOAD DEBUG] Loading preset '{self.preset_name}' from persistent file") + print(f"[PRESET LOAD DEBUG] Persistent file preset shader properties:") print(f"[PRESET LOAD DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}") print(f"[PRESET LOAD DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}") print(f"[PRESET LOAD DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}") else: - print(f"[PRESET LOAD DEBUG] Local file does not exist: {preset_file}") - self.report({'ERROR'}, f"Preset not found in blend file or local storage: {self.preset_name}") - return {'CANCELLED'} + # Fallback to legacy local file + legacy_file = os.path.join(get_preset_path(), f"{self.preset_name}.json") + print(f"[PRESET LOAD DEBUG] Preset not in persistent storage, trying legacy file: {legacy_file}") + if os.path.exists(legacy_file): + with open(legacy_file, 'r') as f: + preset_data = json.load(f) + source = 'LOCAL_FILE' + print(f"[PRESET LOAD DEBUG] Loading preset '{self.preset_name}' from legacy file") + print(f"[PRESET LOAD DEBUG] Legacy file preset shader properties:") + print(f"[PRESET LOAD DEBUG] disable_shadows: {preset_data.get('disable_shadows', 'NOT FOUND')}") + print(f"[PRESET LOAD DEBUG] disable_reflections: {preset_data.get('disable_reflections', 'NOT FOUND')}") + print(f"[PRESET LOAD DEBUG] disable_backfacing: {preset_data.get('disable_backfacing', 'NOT FOUND')}") + else: + print(f"[PRESET LOAD DEBUG] Legacy file does not exist: {legacy_file}") + self.report({'ERROR'}, f"Preset not found in any storage location: {self.preset_name}") + return {'CANCELLED'} if not preset_data: print(f"[PRESET LOAD DEBUG] No preset data found!") @@ -2912,6 +3632,13 @@ class TEXT_TEXTURE_OT_load_preset(Operator): props.normal_map_blur_radius = preset_data.get('normal_map_blur_radius', props.normal_map_blur_radius) props.normal_map_invert = preset_data.get('normal_map_invert', props.normal_map_invert) + # Load text fitting settings + props.enable_text_fitting = preset_data.get('enable_text_fitting', props.enable_text_fitting) + props.text_fitting_mode = preset_data.get('text_fitting_mode', props.text_fitting_mode) + props.text_fitting_margin = preset_data.get('text_fitting_margin', props.text_fitting_margin) + props.min_font_size = preset_data.get('min_font_size', props.min_font_size) + props.max_font_size = preset_data.get('max_font_size', props.max_font_size) + # Load overlay data props.image_overlays.clear() overlay_data_list = preset_data.get('image_overlays', []) @@ -2939,7 +3666,11 @@ class TEXT_TEXTURE_OT_load_preset(Operator): # Always generate preview after loading preset generate_preview(context) - source_info = "from blend file" if source == 'BLEND_FILE' else "from local storage" + source_info = { + 'BLEND_FILE': "from blend file", + 'PERSISTENT_FILE': "from persistent storage", + 'LOCAL_FILE': "from legacy storage" + }.get(source, "from unknown source") self.report({'INFO'}, f"✅ Loaded preset '{self.preset_name}' {source_info}") except (OSError, IOError) as e: @@ -3165,6 +3896,289 @@ class TEXT_TEXTURE_OT_sync_margins(Operator): sync_margin_values(props, self.margin_type) return {'FINISHED'} +class TEXT_TEXTURE_OT_export_presets(Operator): + """Export all presets to a file for backup""" + bl_idname = "text_texture.export_presets" + bl_label = "Export Presets" + bl_description = "Export all presets to a file for backup" + bl_options = {'REGISTER', 'UNDO'} + + filepath: StringProperty( + name="File Path", + description="Path to save preset backup file", + subtype='FILE_PATH', + default="text_texture_presets_backup.json" + ) + + def execute(self, context): + try: + props = context.scene.text_texture_props + + # Collect all presets from all sources + all_presets = {} + + # Get blend file presets + blend_presets = get_blend_file_presets() + for name, data in blend_presets.items(): + all_presets[name] = { + "data": data, + "source": "BLEND_FILE" + } + + # Get persistent presets + persistent_dir = get_persistent_preset_path() + if os.path.exists(persistent_dir): + for filename in os.listdir(persistent_dir): + if filename.endswith('.json'): + preset_name = os.path.splitext(filename)[0] + if preset_name not in all_presets: # Don't override blend file presets + try: + with open(os.path.join(persistent_dir, filename), 'r') as f: + preset_data = json.load(f) + all_presets[preset_name] = { + "data": preset_data, + "source": "PERSISTENT_FILE" + } + except (json.JSONDecodeError, OSError) as e: + print(f"[TTG] Error reading persistent preset {preset_name}: {e}") + + # Get legacy presets + legacy_dir = get_preset_path() + if os.path.exists(legacy_dir) and legacy_dir != persistent_dir: + for filename in os.listdir(legacy_dir): + if filename.endswith('.json'): + preset_name = os.path.splitext(filename)[0] + if preset_name not in all_presets: # Don't override higher priority presets + try: + with open(os.path.join(legacy_dir, filename), 'r') as f: + preset_data = json.load(f) + all_presets[preset_name] = { + "data": preset_data, + "source": "LOCAL_FILE" + } + except (json.JSONDecodeError, OSError) as e: + print(f"[TTG] Error reading legacy preset {preset_name}: {e}") + + if not all_presets: + self.report({'WARNING'}, "No presets found to export") + return {'CANCELLED'} + + # Create export data with metadata + export_data = { + "format_version": "1.0", + "addon_version": bl_info["version"], + "export_timestamp": time.time(), + "presets": all_presets + } + + # Write to file + with open(self.filepath, 'w') as f: + json.dump(export_data, f, indent=2) + + self.report({'INFO'}, f"✅ Exported {len(all_presets)} presets to {os.path.basename(self.filepath)}") + return {'FINISHED'} + + except Exception as e: + self.report({'ERROR'}, f"Failed to export presets: {str(e)}") + return {'CANCELLED'} + + def invoke(self, context, event): + # Set default filename with timestamp + import datetime + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + self.filepath = f"text_texture_presets_backup_{timestamp}.json" + + context.window_manager.fileselect_add(self) + return {'RUNNING_MODAL'} + +class TEXT_TEXTURE_OT_import_presets(Operator): + """Import presets from a backup file""" + bl_idname = "text_texture.import_presets" + bl_label = "Import Presets" + bl_description = "Import presets from a backup file" + bl_options = {'REGISTER', 'UNDO'} + + filepath: StringProperty( + name="File Path", + description="Path to preset backup file", + subtype='FILE_PATH' + ) + + import_mode: EnumProperty( + name="Import Mode", + description="How to handle existing presets with same names", + items=[ + ('SKIP', 'Skip Existing', 'Skip presets that already exist'), + ('OVERWRITE', 'Overwrite', 'Overwrite existing presets'), + ('RENAME', 'Rename New', 'Rename imported presets if they conflict'), + ], + default='SKIP' + ) + + import_to_blend: BoolProperty( + name="Import to Blend File", + description="Import presets to blend file (recommended for portability)", + default=True + ) + + def execute(self, context): + try: + if not os.path.exists(self.filepath): + self.report({'ERROR'}, "Backup file not found") + return {'CANCELLED'} + + props = context.scene.text_texture_props + + # Read backup file + with open(self.filepath, 'r') as f: + import_data = json.load(f) + + # Validate format + if "presets" not in import_data: + self.report({'ERROR'}, "Invalid backup file format") + return {'CANCELLED'} + + imported_presets = import_data["presets"] + if not imported_presets: + self.report({'WARNING'}, "No presets found in backup file") + return {'CANCELLED'} + + # Get existing presets to check for conflicts + existing_blend_presets = get_blend_file_presets() + existing_preset_names = set(existing_blend_presets.keys()) + + # Also check persistent storage + persistent_dir = get_persistent_preset_path() + if os.path.exists(persistent_dir): + for filename in os.listdir(persistent_dir): + if filename.endswith('.json'): + existing_preset_names.add(os.path.splitext(filename)[0]) + + imported_count = 0 + skipped_count = 0 + errors = [] + + for preset_name, preset_info in imported_presets.items(): + preset_data = preset_info.get("data", {}) + + # Handle conflicts + final_name = preset_name + if preset_name in existing_preset_names: + if self.import_mode == 'SKIP': + skipped_count += 1 + continue + elif self.import_mode == 'RENAME': + counter = 1 + base_name = preset_name + while final_name in existing_preset_names: + final_name = f"{base_name}_imported_{counter}" + counter += 1 + # OVERWRITE mode uses original name + + try: + # Import to blend file or persistent storage based on setting + if self.import_to_blend: + success = save_blend_file_preset(final_name, preset_data) + if not success: + raise Exception("Failed to save to blend file") + else: + # Save to persistent storage + persistent_file = os.path.join(get_persistent_preset_path(), f"{final_name}.json") + with open(persistent_file, 'w') as f: + json.dump(preset_data, f, indent=2) + + imported_count += 1 + + # Add to UI list if not already present + existing_preset = None + for preset in props.presets: + if preset.name == final_name: + existing_preset = preset + break + + if not existing_preset: + new_preset = props.presets.add() + new_preset.name = final_name + new_preset.source = 'BLEND_FILE' if self.import_to_blend else 'PERSISTENT_FILE' + else: + existing_preset.source = 'BLEND_FILE' if self.import_to_blend else 'PERSISTENT_FILE' + + except Exception as e: + errors.append(f"{preset_name}: {str(e)}") + + # Report results + if imported_count > 0: + storage_location = "blend file" if self.import_to_blend else "persistent storage" + self.report({'INFO'}, f"✅ Imported {imported_count} presets to {storage_location}") + if skipped_count > 0: + self.report({'INFO'}, f"⏭️ Skipped {skipped_count} existing presets") + else: + self.report({'WARNING'}, "No presets were imported") + + if errors: + self.report({'WARNING'}, f"Errors importing {len(errors)} presets - check console") + for error in errors[:5]: # Show first 5 errors + print(f"[TTG] Import error: {error}") + + return {'FINISHED'} + + except Exception as e: + self.report({'ERROR'}, f"Failed to import presets: {str(e)}") + return {'CANCELLED'} + + def invoke(self, context, event): + context.window_manager.fileselect_add(self) + return {'RUNNING_MODAL'} + + def draw(self, context): + layout = self.layout + layout.prop(self, "import_mode") + layout.prop(self, "import_to_blend") + +class TEXT_TEXTURE_OT_show_migration_report(Operator): + """Show migration report""" + bl_idname = "text_texture.show_migration_report" + bl_label = "Show Migration Report" + bl_description = "Display migration report with details about preset migration" + bl_options = {'REGISTER', 'UNDO'} + + def execute(self, context): + try: + import json + import os + persistent_dir = os.path.dirname(get_persistent_preset_path()) + migration_report_file = os.path.join(persistent_dir, "migration_report.json") + + if os.path.exists(migration_report_file): + with open(migration_report_file, 'r') as f: + migration_data = json.load(f) + + migrated_count = migration_data.get('migrated_count', 0) + total_found = migration_data.get('total_found', 0) + errors = migration_data.get('errors', []) + backup_path = migration_data.get('backup_path', 'Unknown') + + self.report({'INFO'}, f"Migration Report: {migrated_count}/{total_found} presets migrated successfully") + + if errors: + error_summary = f"{len(errors)} errors occurred during migration" + self.report({'WARNING'}, error_summary) + print(f"[TTG] Migration errors:") + for error in errors[:10]: # Show first 10 errors + print(f"[TTG] - {error}") + else: + self.report({'INFO'}, "No migration errors") + + if backup_path != 'Unknown': + print(f"[TTG] Migration backup created at: {backup_path}") + else: + self.report({'INFO'}, "No migration report found - migration may not have been needed") + + return {'FINISHED'} + except Exception as e: + self.report({'ERROR'}, f"Error reading migration report: {e}") + return {'CANCELLED'} + # ============================================================================ # SHARED UI COMPONENTS @@ -3228,6 +4242,16 @@ def draw_image_overlays_section(layout, props): trans_row.prop(overlay, "scale", text="Scale") trans_row.prop(overlay, "rotation", text="Rotation") trans_row.prop(overlay, "z_index", text="Z-Level") + + # Add new positioning controls + layout.separator() + layout.prop(overlay, "positioning_mode") + + # Show spacing controls only for APPEND/PREPEND modes + if overlay.positioning_mode in ['APPEND', 'PREPEND']: + layout.prop(overlay, "text_spacing") + layout.prop(overlay, "image_spacing") + layout.prop(overlay, "sequence_index") def draw_positioning_section(layout, props): """Draw positioning and alignment controls""" @@ -3357,9 +4381,14 @@ def draw_presets_section(layout, props): layout.separator() - # Add refresh button at the top of presets section - refresh_row = layout.row(align=True) - refresh_row.operator("text_texture.refresh_presets", text="Refresh Presets", icon='FILE_REFRESH') + # Import/Export and refresh buttons + backup_row = layout.row(align=True) + backup_row.operator("text_texture.export_presets", text="Export Backup", icon='EXPORT') + backup_row.operator("text_texture.import_presets", text="Import Backup", icon='IMPORT') + + utility_row = layout.row(align=True) + utility_row.operator("text_texture.refresh_presets", text="Refresh", icon='FILE_REFRESH') + utility_row.operator("text_texture.show_migration_report", text="Migration Report", icon='INFO') layout.separator() @@ -3508,6 +4537,49 @@ 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""" + # 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: + 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': + 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") + # ============================================================================ # MENUS # ============================================================================ @@ -3616,6 +4688,11 @@ class TEXT_TEXTURE_PT_panel(Panel): if font_section: draw_font_settings_section(font_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: + draw_text_fitting_section(text_fitting_section, props) + # Normal Maps Section (collapsed by default) normal_maps_section = draw_collapsible_header(layout, "expand_normal_maps", "Normal Maps", 'TEXTURE') if normal_maps_section: @@ -3692,6 +4769,11 @@ class TEXT_TEXTURE_PT_panel_3d(Panel): if font_section: draw_font_settings_section(font_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: + draw_text_fitting_section(text_fitting_section, props) + # Normal Maps Section (collapsed by default) normal_maps_section = draw_collapsible_header(layout, "expand_normal_maps", "Normal Maps", 'TEXTURE') if normal_maps_section: @@ -3757,6 +4839,11 @@ class TEXT_TEXTURE_PT_panel_properties(Panel): if font_section: draw_font_settings_section(font_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: + draw_text_fitting_section(text_fitting_section, props) + # Normal Maps Section (collapsed by default) normal_maps_section = draw_collapsible_header(layout, "expand_normal_maps", "Normal Maps", 'TEXTURE') if normal_maps_section: @@ -3795,6 +4882,9 @@ classes = ( TEXT_TEXTURE_OT_delete_preset, TEXT_TEXTURE_OT_refresh_fonts, TEXT_TEXTURE_OT_refresh_presets, + TEXT_TEXTURE_OT_export_presets, + TEXT_TEXTURE_OT_import_presets, + TEXT_TEXTURE_OT_show_migration_report, TEXT_TEXTURE_PT_panel, TEXT_TEXTURE_PT_panel_3d, TEXT_TEXTURE_PT_panel_properties,