From 11b26a8a546d64404a208c4367da3d69edd21c4f Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Sun, 7 Sep 2025 09:56:10 +0300 Subject: [PATCH] image options --- Archive.zip | Bin 33396 -> 35309 bytes __init__.py | 1316 ++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 1203 insertions(+), 113 deletions(-) diff --git a/Archive.zip b/Archive.zip index 00aca0d2e4f39c4cf8d5c60b7b722e025238812a..6b72954f059852d97d34a1f0845bd451060a2162 100644 GIT binary patch delta 32764 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", "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,