first commit

This commit is contained in:
ilia gurielidze 2025-05-16 18:15:31 +04:00
commit 7ee1469b2e
32 changed files with 5271 additions and 0 deletions

34
.cursorignore Normal file
View File

@ -0,0 +1,34 @@
# Virtual environment
venv/
test_venv/
# Build directories
build/
dist/
test_build/
# Python cache
__pycache__/
*.pyc
*.pyo
*.pyd
# Temporary files
*.tmp
*.bak
*.swp
# Operating system files
.DS_Store
Thumbs.db
# IDE specific files
.vscode/
.idea/
# Project specific files
app_config.json
*.svg
# Test cache
.pytest_cache/

87
.gitignore vendored Normal file
View File

@ -0,0 +1,87 @@
# Build artifacts
dist/
build/
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.spec
SVG*
# Virtual environments
venv/
env/
ENV/
.env
.venv/
env.bak/
venv.bak/
# Test artifacts
.pytest_cache/
.coverage
htmlcov/
coverage.xml
.tox/
nosetests.xml
.hypothesis/
.nox/
# IDE specific files
.idea/
.vscode/
*.swp
*.swo
*~
.spyderproject
.spyproject
.ropeproject
# Project specific files
app_config.json
MTN6_SCADA_*
MTN6_SCADAa_*
# Image files (generally ignored)
*.jpg
*.png
*.gif
*.bmp
*.tiff
*.jpeg
# Exception for app assets
!automation_standard_logo.jpg
!autStand_ic0n.ico
# OS specific
.DS_Store
Thumbs.db
desktop.ini
._*
.Spotlight-V100
.Trashes
# Rotation test files
rotation_test_*.svg
rotation_test_*.json
*.zip
*.exe
*.json
*.spec
*.jpg
*.png
*.gif
*.bmp
*.tiff
*.jpeg
*.ico
*.svg
*.json
*.spec
*.jpg
*.png
*.gif
*.DS_Store

346
README.md Normal file
View File

@ -0,0 +1,346 @@
# SVG Processor Tool
## Overview
A desktop application built with Python and Tkinter for extracting elements from SVG files and converting them into a JSON format suitable for automation systems, specifically Ignition SCADA. It allows users to process SVGs, map elements based on types and labels, and export the configuration as a structured Ignition project (zip file).
## Features
- **Graphical User Interface (GUI):** Easy-to-use interface built with Tkinter.
- **SVG Parsing:** Supports various SVG elements (`rect`, `circle`, `ellipse`, `line`, `polyline`, `polygon`, `path`, `g`).
- **Transformation Handling:** Processes complex SVG transformations (translate, scale, rotate, matrix).
- **Element Mapping:**
- Configure mappings based on SVG element type (`rect`, `path`, etc.).
- Use **Label Prefixes** for specific element configurations.
- Define target **Element Type** and **Props Path** for Ignition components.
- Specify **Size (WxH)** and **Offset (X,Y)** for positioning.
- Add **Final Prefix** and **Final Suffix** for consistent naming.
- **Configuration Management:** Saves and loads settings (SVG path, mappings, project details) to a `config.json` file.
- **SCADA Export:**
- Exports the processed configuration as an Ignition SCADA project.
- Allows specifying **Project Title**, **View Name**, **Ignition Base Directory**, and optional **View Path**.
- Supports setting a **Background SVG URL** for the view.
- Provides an option to choose a **Custom Export Location** for the generated files.
- Packages the output (`view.json`, `resource.json`, `project.json`) into a **ZIP archive** ready for Ignition import.
- **Logging:** Displays processing logs and errors within the GUI.
- **Packaging:** Includes a `SVG_Processor.spec` file for building a distributable executable using PyInstaller.
## Dependencies
The SVG Processor Tool relies on several key Python libraries to function. These are listed in `requirements.txt` and should be installed in your Python environment (preferably a virtual environment) before running from source.
- **`lxml` (>=4.9.0):** A powerful and Pythonic library for XML and HTML processing. It is used extensively for parsing SVG files, traversing the SVG DOM tree, and extracting element attributes.
*Role: Core SVG parsing and XML manipulation.*
- **`numpy` (>=1.21.0):** A fundamental package for scientific computing with Python. It provides support for large, multi-dimensional arrays and matrices, along with a collection of mathematical functions to operate on these arrays.
*Role: Handling matrix transformations (translation, rotation, scaling) of SVG elements efficiently.*
- **`Pillow` (PIL Fork) (>=9.0.0):** The Python Imaging Library (Fork). It adds image processing capabilities to your Python interpreter.
*Role: Primarily used for loading and handling the application icon (`.png` format) to display in the Tkinter window's title bar and potentially for other image-related tasks if features are expanded.
- **`pyinstaller` (>=6.0.0):** A tool used to package Python applications and all their dependencies into a single standalone executable.
*Role: Not a runtime dependency for the source code, but a development dependency for creating distributable versions of the SVG Processor Tool.
- **`svg.path` (Implicit or to be added to requirements.txt):** A library for parsing SVG path data strings (the `d` attribute of `<path>` elements). It can break down complex path definitions into individual segments like lines, arcs, and curves, making them easier to process and transform.
*Role: Parsing and interpreting complex SVG `<path>` element data for accurate geometric processing.*
*(Note: Ensure you install these using `pip install -r requirements.txt` when setting up to run from source.)*
## Installation
### Prerequisites
- Python 3.6+
- Required Python packages (see `requirements.txt`):
- `lxml` (for SVG parsing)
- `numpy` (for matrix transformations)
- `Pillow` (for icon handling, optional but recommended)
- `svg.path` (for path parsing)
### Running from Source
1. **Clone the repository:**
```bash
git clone <repository-url>
cd svgprocessor
```
2. **Set up a virtual environment (recommended):**
```bash
python -m venv venv
# Activate (Windows)
.\venv\Scripts\activate
# Activate (macOS/Linux)
source venv/bin/activate
```
3. **Install dependencies:**
```bash
pip install -r requirements.txt
```
4. **Run the application:**
```bash
python app_runner.py
```
### Building Executable with PyInstaller
1. Ensure PyInstaller is installed (`pip install pyinstaller`).
2. Run PyInstaller using the provided spec file:
```bash
pyinstaller SVG_Processor.spec
```
3. The executable will be located in the `dist/SVG_Processor` directory.
*(Note: The `.spec` file is configured for Windows. You may need adjustments for macOS or Linux, particularly regarding icon formats and library paths.)*
## Usage
1. **Launch the application** (`python app_runner.py` or run the executable).
2. **Select SVG File:** Click "Browse" to choose the SVG file you want to process.
3. **Configure Project Settings:**
- Enter `Project Title` and `View Name`.
- Specify the `Ignition Base Dir` (path to your Ignition projects folder).
- Optionally, set `View Path` (e.g., `Folder/Subfolder`) and `Background SVG URL`.
- Adjust `Image Width/Height` and `Default View Width/Height` as needed.
4. **Configure Element Mappings (Element Mapping Tab):**
- Click "Add New Mapping" to create rules for converting SVG elements.
- For each mapping:
- `SVG Type`: The type of SVG element (e.g., `rect`, `path`).
- `Label Prefix`: (Optional) A prefix found in the SVG element's ID or label to trigger this specific mapping.
- `Element Type`: The target Ignition component type (e.g., `ia.display.view`, `ia.display.label`).
- `Props Path`: The path to the Ignition component definition (e.g., `Symbol-Views/Equipment-Views/Status`).
- `Size (WxH)`: Default width and height for the created component.
- `Offset (X,Y)`: Adjust the component's position relative to the SVG element's calculated position.
- `Final Prefix/Suffix`: Text added to the beginning/end of the generated component name.
- Use the `X` button to remove mappings.
5. **Process SVG:** Click the "Process SVG" button.
- Check the `Results` and `Logs` tabs for output and any errors.
6. **Export SCADA Project:** Click the "Export SCADA Project" button.
- A file dialog will appear, allowing you to choose where to save the generated `.zip` file containing the Ignition project structure.
## Configuration File (`config.json`)
The application saves user settings (last used SVG path, project details, element mappings) to `config.json` in the application's directory. This file is loaded automatically on startup.
## Technical Details
### Project Structure
- `app_runner.py`: Main entry point, initializes the Tkinter application and handles setup.
- `gui/`: Contains modules related to the graphical user interface.
- `svg_processor_gui.py`: The main application window class.
- `element_mapping_frame.py`: Manages the element mapping configuration grid.
- `notebook_manager.py`: Handles the tabbed interface (Element Mapping, Results, Logs).
- `theme_manager.py`: Manages basic application styling.
- ... (other potential GUI components)
- `processing/`: Contains modules for SVG parsing and transformation logic.
- `processor.py`: Orchestrates the SVG processing workflow.
- `element_processor.py`: Handles individual element extraction and mapping.
- `inkscape_transform.py`: Parses SVG transform attributes and applies matrix math.
- `element_mapper.py`: Finds the correct mapping rule for an element.
- `json_builder.py`: Constructs the final JSON output for elements.
- `scada_exporter.py`: Creates the Ignition project structure and zip file.
- `config_manager.py`: Handles loading and saving the `config.json` file.
- `utils.py`: Contains utility functions (e.g., resource path handling, stdout redirection, icon loading).
- `requirements.txt`: Lists Python dependencies.
- `SVG_Processor.spec`: Configuration file for PyInstaller.
- `README.md`: This file.
- `USER_MANUAL.md`: Detailed user guide.
### SVG Processing Workflow
1. Load SVG file using `lxml`.
2. Traverse the SVG DOM tree.
3. For each relevant element (`rect`, `path`, etc.):
- Parse its attributes (ID, transform, position, size).
- Calculate the final transformation matrix by accumulating transforms from parent groups (`g` elements).
- Determine the element's bounding box or key points.
- Apply the accumulated transformation matrix to find the final position and size in the view coordinate system.
- Match the element (based on type and label prefix) to a rule in the `element_mappings` configuration.
- Generate a JSON object representing the corresponding Ignition component using `json_builder.py`.
4. Aggregate the JSON objects.
### SCADA Export Process (`scada_exporter.py`)
1. Takes the processed element data and configuration settings.
2. Creates a temporary directory structure mimicking an Ignition project:
```
<temp_dir>/
├── project.json
└── com.inductiveautomation.perspective/
└── views/
└── [Optional View Path]/
└── [View Name]/
├── view.json
├── resource.json
```
3. Generates `project.json` (basic project metadata).
4. Generates `view.json` containing the array of processed element JSON objects.
5. Generates `resource.json` (metadata for the view resource).
6. Packages the contents of the temporary directory into a `.zip` archive.
7. Cleans up the temporary directory.
## License
This project is open source.
**[It is recommended to replace this placeholder with a specific open-source license. For example, the MIT License is a popular choice.]**
If you choose to use a specific license like the MIT License, the text might look something like this:
---
**MIT License**
Copyright (c) [Year] [Your Name/Organization Name]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
Please ensure you select and include the full text of the license you intend to use.
## Acknowledgments
- Built using Python and the Tkinter standard library.
- Leverages libraries like `lxml`, `numpy`, `Pillow`, and `svg.path`.
## Testing
Automated tests are crucial for maintaining code quality and ensuring new changes don't break existing functionality. This project uses `pytest` for its testing framework.
### Setting Up for Testing
1. **Install Testing Dependencies:**
If you haven't already, install `pytest` and `pytest-cov` (for coverage reports). It's best to install them within your project's virtual environment:
```bash
pip install pytest pytest-cov
```
2. **Test File Location and Naming:**
* Tests are typically located in a dedicated `tests/` directory at the root of the project.
* Test files should be named starting with `test_` (e.g., `test_element_processor.py`).
* Test functions within these files should also start with `test_` (e.g., `def test_specific_transformation():`).
### Running Tests
1. **Navigate to Project Root:**
Open your terminal or command prompt and ensure you are in the root directory of the SVG Processor Tool project (the directory containing `app_runner.py` and the `tests/` folder).
2. **Activate Virtual Environment:**
If you are using a virtual environment, make sure it's activated.
3. **Execute Pytest:**
* To run all tests:
```bash
pytest
```
`pytest` will automatically discover and run test files and functions matching the naming conventions.
* To run tests with a coverage report (shows which parts of your code are exercised by tests):
```bash
pytest --cov=.
```
This command tells `pytest-cov` to measure coverage for the current directory (`.`).
* For a more detailed coverage report in the terminal, including missing line numbers:
```bash
pytest --cov=. --cov-report=term-missing
```
* To generate an HTML coverage report (which is often easier to navigate):
```bash
pytest --cov=. --cov-report=html
```
This will create an `htmlcov/` directory. Open `htmlcov/index.html` in a web browser to view the interactive report.
### Writing Tests
- When adding new features or fixing bugs, try to accompany your code changes with corresponding tests.
- Tests should be independent and not rely on the state left by previous tests.
- Use `assert` statements to check for expected outcomes.
- `pytest` offers powerful features like fixtures (for setting up test preconditions) and parameterization (for running a test with multiple inputs). Refer to the [pytest documentation](https://docs.pytest.org/) for more advanced usage.
## Troubleshooting
If you encounter issues while using the SVG Processor Tool, this section provides guidance on common problems and how to resolve them.
- **Application Fails to Start or Crashes Immediately:**
- **Check Python Installation:** Ensure you have a compatible version of Python (3.6+) installed and correctly configured in your system's PATH.
- **Dependencies Not Installed:** If running from source, make sure all dependencies listed in `requirements.txt` are installed in your active Python environment (preferably a virtual environment). Run `pip install -r requirements.txt` again.
- **Corrupted Configuration:** A corrupted `config.json` file could sometimes prevent startup. Try renaming or deleting `config.json` (the application will create a new default one). You will lose your saved settings by doing this.
- **Console Logs:** If running `python app_runner.py` from a terminal/command prompt, check the console output for any error messages or tracebacks. These are invaluable for diagnosing startup issues.
- **SVG File Not Loading or Parsing Errors:**
- **Invalid SVG Format:** Ensure your SVG file is well-formed XML and adheres to SVG standards. You can try opening it in a web browser (like Chrome or Firefox) or a dedicated SVG editor (like Inkscape or Adobe Illustrator) to check its validity. Some SVG features from advanced editors might not be fully supported if they use non-standard XML namespaces or structures.
- **File Path Issues:** Double-check that the file path selected is correct and that the application has read permissions for the file and its directory.
- **Application Logs:** The "Logs" tab within the application is the primary source for diagnosing SVG processing issues. It will often indicate which element caused a problem or if there were parsing errors.
- **Elements Not Appearing or Incorrectly Positioned/Sized in Export:**
- **Transformation Complexity:** Highly complex or unusual `transform` attributes in the SVG (e.g., deeply nested transforms, unsupported SVG 2.0 transform functions if any) might not be interpreted as expected by all parsers. Simplify transformations in your SVG source if possible.
- **Mapping Rules:**
- Verify that your "Element Mapping" rules are correctly configured for the `SVG Type` and any `Label Prefix` of the problematic elements.
- Ensure the target `Element Type` and `Props Path` (if used) in the mapping are valid for your Ignition SCADA system.
- Check the `Offset (X,Y)` and `Size (WxH)` values in your mappings, as these directly affect the output.
- **Application Logs:** Again, the "Logs" tab may contain warnings or information about how specific elements were processed, including their calculated transformations and applied mappings.
- **Coordinate Systems:** Be mindful of SVG's coordinate system (y-axis typically points downwards). While the tool handles this, complex SVG structures might have internal coordinate systems that could be tricky.
- **Issues with PyInstaller Executable (after building):**
- **Missing DLLs/Libraries (Windows):** If the executable fails to run with errors like "VCRUNTIME140.dll was not found," ensure the target machine has the latest Microsoft Visual C++ Redistributable installed for the version of Python used to build the executable.
- **macOS/Linux Specifics:** On macOS, you might encounter permission issues or problems related to how libraries are bundled. Ensure the executable has execute permissions. Check for console output if running the executable from a terminal.
- **Data Files Not Bundled:** If your application relies on external data files (e.g., custom icons not handled by `utils.py`'s `resource_path`, default templates) that are not Python modules, ensure they are correctly specified in the `SVG_Processor.spec` file to be included in the bundle. The current setup with `resource_path` in `utils.py` should handle the provided `automation_standard_logo.png`.
- **Anti-virus Software:** Occasionally, anti-virus software might incorrectly flag a newly built PyInstaller executable as suspicious. Try temporarily disabling it for testing or adding an exception.
- **Console Window (or lack thereof):** The `.spec` file controls whether a console window appears when the executable runs (using the `-w` or `--windowed` flag for no console, or `-c` or `--console` for a console). If you need to see console output from the executable for debugging, ensure it's built with the console enabled.
- **General GUI Issues (e.g., distorted elements, unresponsiveness):**
- **Tkinter/Tcl Version:** While less common with modern Python, very old or mismatched Tk/Tcl versions (which Tkinter relies on) could theoretically cause display issues. Ensure your Python installation is standard.
- **Application Logs:** Check for any Python tracebacks or error messages in the "Logs" tab that might indicate an internal GUI error.
**General Troubleshooting Tip:** When in doubt, always check the **"Logs" tab** within the application first. It is designed to provide detailed feedback on the application's operations and is the most valuable resource for understanding what went wrong.
## Contributing
Contributions to the SVG Processor Tool are welcome! Whether you're looking to report a bug, suggest an enhancement, or contribute code, your input is valued.
### How to Contribute
1. **Reporting Bugs:**
* If you encounter a bug, please check the existing issues on the project's issue tracker (if available) to see if it has already been reported.
* If not, create a new issue, providing as much detail as possible:
* Steps to reproduce the bug.
* Expected behavior vs. actual behavior.
* SVG Processor Tool version (if applicable), your OS, and Python version.
* Relevant snippets from the "Logs" tab or console output.
* A sample SVG file that triggers the bug (if possible and not proprietary).
2. **Suggesting Enhancements or Features:**
* If you have an idea for a new feature or an improvement to an existing one, feel free to open an issue to discuss it.
* Clearly describe the proposed enhancement and its benefits.
3. **Submitting Code (Pull Requests):**
* If you'd like to contribute code, it's generally a good idea to first discuss your proposed changes by opening an issue, especially for larger contributions.
* Fork the repository (if applicable), create a new branch for your feature or bugfix, and then submit a pull request.
* Ensure your code is well-commented, particularly for complex logic.
* If possible, add or update tests that cover your changes.
* Follow the existing code style and architectural patterns (as outlined in `project_config.md` and observable in the codebase) to maintain consistency.
### Coding Style and Conventions
- While no strict style guide (like PEP 8 enforcement tool) is explicitly mandated by this document, aim for clear, readable, and maintainable Python code.
- Follow the architectural principles of modular design with separation between GUI (`gui/`) and processing logic (`processing/`) as established in the project.
- Use descriptive names for variables, functions, and classes.
- Comment code where the logic is not immediately obvious.
We appreciate your help in making the SVG Processor Tool better!

73
app_runner.py Normal file
View File

@ -0,0 +1,73 @@
import tkinter as tk
from tkinter import messagebox
import sys
import traceback
# Import necessary components
from gui.svg_processor_gui import SVGProcessorApp
from config_manager import ConfigManager
def run_application():
"""
Initializes and runs the SVGProcessorApp.
Handles Tkinter setup, window centering, and global exception handling.
"""
root = None # Initialize root to None
try:
# Create ConfigManager first
config_manager = ConfigManager()
root = tk.Tk()
# Pass the config manager to the app
app = SVGProcessorApp(root, config_manager=config_manager)
# Add confirmation dialog on close
def confirm_exit():
# Ensure app has the on_closing method before calling
if hasattr(app, 'on_closing') and callable(app.on_closing):
if messagebox.askokcancel("Exit", "Save configuration and exit?"):
app.on_closing() # This should handle saving and root.destroy()
else:
# Fallback if on_closing doesn't exist or app failed init
if messagebox.askokcancel("Exit", "Exit Application?"):
if root and root.winfo_exists():
root.destroy()
root.protocol("WM_DELETE_WINDOW", confirm_exit)
# Center the window
window_width = 1200
window_height = 800
screen_width = root.winfo_screenwidth()
screen_height = root.winfo_screenheight()
# Calculate coordinates ensuring they are integers
x_coordinate = int((screen_width / 2) - (window_width / 2))
y_coordinate = int((screen_height / 2) - (window_height / 2))
# Prevent negative coordinates if screen is smaller than window
x_coordinate = max(0, x_coordinate)
y_coordinate = max(0, y_coordinate)
root.geometry(f"{window_width}x{window_height}+{x_coordinate}+{y_coordinate}")
root.mainloop()
except Exception as e:
# Global exception handler
print("--- APPLICATION CRITICAL ERROR ---", file=sys.stderr)
traceback.print_exc() # Print detailed traceback to console/stderr
error_message = f"An unexpected critical error occurred:\n{str(e)}"
try:
# Try showing messagebox even if Tk might be unstable
messagebox.showerror("Application Error", error_message)
except Exception as msg_e:
# Fallback to printing if messagebox fails
print(f"Failed to show error messagebox: {msg_e}", file=sys.stderr)
print(f"APPLICATION CRITICAL ERROR: {error_message}", file=sys.stderr)
return 1 # Indicate error exit code
return 0 # Indicate successful exit (or normal closure)
if __name__ == "__main__":
exit_code = run_application()
sys.exit(exit_code)

350
config_manager.py Normal file
View File

@ -0,0 +1,350 @@
import json
import os
import time
# Moved get_application_path here temporarily to avoid circular import
# Ideally, this would be passed in or the config path logic revisited.
def get_application_path():
"""Get the base path for the application, works for both dev and PyInstaller"""
import sys # Local import to avoid being at top level if utils is preferred
if getattr(sys, 'frozen', False):
return os.path.dirname(sys.executable)
else:
# In dev mode, assume config manager is in the same dir as the main script
# This might need adjustment depending on final structure
return os.path.dirname(os.path.abspath(__file__))
# Default configuration values
# Keep default mappings simple here, complex defaults handled in GUI if needed
DEFAULT_CONFIG = {
'file_path': '',
'project_title': 'MyScadaProject',
'parent_project': '', # Often empty for top-level projects
'view_name': 'MyView',
'view_path': '', # Optional path within views, e.g., 'Folder/Subfolder'
'svg_url': '', # Optional background image URL/Path for the view
'image_width': 800,
'image_height': 600,
'default_width': 100, # Default component width
'default_height': 50, # Default component height
'ignition_base_dir': 'C:/Program Files/Inductive Automation/Ignition/data/projects', # Added
'custom_export_dir': '', # Custom directory for SCADA export (empty to use ignition_base_dir)
'element_mappings': [] # List of {svg_id: 'id', scada_type: 'type', scada_path: 'path'}
}
class ConfigManager:
"""
Configuration Manager class for handling configuration persistence.
Loads and saves configuration options to a JSON file.
Auto-creates the configuration file with defaults if it doesn't exist.
Includes logic for backward compatibility with older formats.
"""
def __init__(self, config_file="config.json"):
"""
Initialize the Configuration Manager.
Args:
config_file (str): The name of the configuration file.
It will be stored in the application's directory.
"""
# Store only the filename, calculate full path dynamically
self.config_filename = config_file
self.config_file_path = os.path.join(get_application_path(), self.config_filename)
print(f"DEBUG: Config file path: {self.config_file_path}") # Debug output
self.initialize_config_file() # Ensure file/dir exists
def _get_config_path(self):
"""Returns the full path to the config file."""
# Recalculate in case the application path logic changes
return os.path.join(get_application_path(), self.config_filename)
def initialize_config_file(self):
"""
Create the configuration file with default values if it doesn't exist.
Ensures the containing directory exists.
"""
config_path = self._get_config_path()
config_dir = os.path.dirname(config_path)
try:
# Create directory if it doesn't exist
if config_dir and not os.path.exists(config_dir):
os.makedirs(config_dir)
print(f"Created config directory: {config_dir}")
# Create default config file if it doesn't exist
if not os.path.exists(config_path):
print(f"Config file not found at {config_path}. Creating default config...")
# Use the save method with default config to create the file
self.save_config(DEFAULT_CONFIG)
except Exception as e:
print(f"Error initializing config file/directory: {e}")
def get_config(self):
"""
Load configuration from the file.
Returns:
dict: The loaded (and potentially updated for compatibility) configuration dictionary.
Returns DEFAULT_CONFIG if loading fails.
"""
config_path = self._get_config_path()
try:
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
# Ensure compatibility and update format
config = self._ensure_backward_compatibility(config)
config = self._update_config_format(config)
# Merge with defaults to ensure all keys exist
# Loaded values take precedence over defaults
merged_config = DEFAULT_CONFIG.copy()
merged_config.update(config) # Overwrite defaults with loaded values
return merged_config
else:
print(f"Configuration file not found: {config_path}. Returning default config.")
return DEFAULT_CONFIG.copy() # Return a copy of defaults
except json.JSONDecodeError as e:
print(f"Error decoding JSON from config file {config_path}: {e}")
print("Using default configuration.")
# Optionally back up corrupted file
# os.rename(config_path, config_path + ".corrupted")
return DEFAULT_CONFIG.copy()
except Exception as e:
print(f"Error loading configuration from {config_path}: {e}")
return DEFAULT_CONFIG.copy()
def save_config(self, config):
"""
Save configuration to the file.
Args:
config (dict): The configuration dictionary to save.
Returns:
bool: True if saved successfully, False otherwise.
"""
config_path = self._get_config_path()
try:
# Ensure the directory exists before writing
config_dir = os.path.dirname(config_path)
if config_dir and not os.path.exists(config_dir):
os.makedirs(config_dir)
# Write the config file
with open(config_path, 'w', encoding='utf-8') as f:
json.dump(config, f, indent=4)
# print(f"Configuration saved to: {config_path}") # Less verbose logging
return True
except TypeError as e:
print(f"Error saving configuration: Data not JSON serializable - {e}")
print(f"Problematic config data (partial): {str(config)[:500]}")
return False
except Exception as e:
print(f"Error saving configuration to {config_path}: {e}")
return False
def _ensure_backward_compatibility(self, config):
"""Ensure backward compatibility with older config formats."""
# Check if the new 'element_mappings' structure exists and is populated.
# If not, try to convert from older, separate mapping dictionaries.
if 'element_mappings' not in config or not isinstance(config['element_mappings'], list) or not config['element_mappings']:
print("Attempting conversion from older config format...")
element_mappings = []
# Define default values used during conversion
default_type = config.get('type', 'ia.display.view') # Old general type
default_props_path = config.get('path', 'Symbol-Views/Equipment-Views/Status') # Old general path
default_width = config.get('width', 14)
default_height = config.get('height', 14)
# Get old mapping dictionaries safely (default to empty dict)
element_type_mapping = config.get('element_type_mapping', {})
element_props_mapping = config.get('element_props_mapping', {})
element_label_prefix_mapping = config.get('element_label_prefix_mapping', {})
element_size_mapping = config.get('element_size_mapping', {})
# Keep track of SVG types processed to avoid duplicates from different old sources
processed_svg_types = set()
# Combine all known SVG types from the old mappings
all_svg_types = (set(element_type_mapping.keys()) |
set(element_props_mapping.keys()) |
set(element_label_prefix_mapping.keys()) |
set(element_size_mapping.keys()))
for svg_type in all_svg_types:
if not svg_type: continue # Skip empty keys
# Create entries for specific label prefixes if they exist
if svg_type in element_label_prefix_mapping:
prefix = element_label_prefix_mapping[svg_type]
if prefix: # Only if prefix is non-empty
mapping_key = (svg_type, prefix)
if mapping_key not in processed_svg_types:
width, height = self._get_size_from_old_mapping(element_size_mapping, svg_type, default_width, default_height)
mapping = {
'svg_type': svg_type,
'element_type': element_type_mapping.get(svg_type, default_type),
'label_prefix': prefix,
'props_path': element_props_mapping.get(svg_type, default_props_path),
'width': width,
'height': height,
'x_offset': 0, # Assume 0 offset in old format
'y_offset': 0,
'final_prefix': '', # No equivalent in old format
'final_suffix': ''
}
element_mappings.append(mapping)
processed_svg_types.add(mapping_key)
# Create entry for the SVG type without a label prefix (default)
mapping_key = (svg_type, "")
if mapping_key not in processed_svg_types:
width, height = self._get_size_from_old_mapping(element_size_mapping, svg_type, default_width, default_height)
mapping = {
'svg_type': svg_type,
'element_type': element_type_mapping.get(svg_type, default_type),
'label_prefix': "", # Empty prefix for the general case
'props_path': element_props_mapping.get(svg_type, default_props_path),
'width': width,
'height': height,
'x_offset': 0,
'y_offset': 0,
'final_prefix': '',
'final_suffix': ''
}
element_mappings.append(mapping)
processed_svg_types.add(mapping_key)
# Update the config with the new list format
config['element_mappings'] = element_mappings
print(f"Converted {len(element_mappings)} mappings to new format.")
# Optionally remove old keys after conversion (or keep for safety)
# keys_to_remove = ['type', 'path', 'width', 'height', 'element_type_mapping', ...]
# for key in keys_to_remove:
# config.pop(key, None)
return config
def _get_size_from_old_mapping(self, size_mapping, svg_type, default_w, default_h):
"""Helper to extract size from the old element_size_mapping format."""
if svg_type in size_mapping and isinstance(size_mapping[svg_type], dict):
width = size_mapping[svg_type].get('width', default_w)
height = size_mapping[svg_type].get('height', default_h)
# Basic validation
try: width = int(width)
except: width = default_w
try: height = int(height)
except: height = default_h
return width, height
return default_w, default_h
def _update_config_format(self, config):
"""
Update config format to ensure all required fields exist in element mappings.
Also ensures top-level keys from DEFAULT_CONFIG exist.
"""
# Ensure all top-level keys from DEFAULT_CONFIG exist
for key, default_value in DEFAULT_CONFIG.items():
if key not in config:
print(f"Adding missing config key: '{key}'")
config[key] = default_value
# Ensure 'element_mappings' exists and is a list
if 'element_mappings' not in config or not isinstance(config['element_mappings'], list):
print("Reinitializing 'element_mappings' list.")
config['element_mappings'] = DEFAULT_CONFIG['element_mappings'] # Use default
# Check and update each mapping dictionary within the list
updated_mappings = []
default_mapping = DEFAULT_CONFIG['element_mappings'][0] if DEFAULT_CONFIG['element_mappings'] else {}
for i, mapping in enumerate(config['element_mappings']):
if not isinstance(mapping, dict):
print(f"Warning: Skipping non-dictionary item in element_mappings at index {i}")
continue
updated_mapping = mapping.copy() # Work on a copy
missing_fields = False
# Check for essential fields and add defaults if missing
for field, default_val in default_mapping.items():
if field not in updated_mapping:
print(f"Adding missing field '{field}' to mapping for SVG type '{updated_mapping.get('svg_type', 'UNKNOWN')}'")
updated_mapping[field] = default_val
missing_fields = True
# Optionally, perform type validation/conversion here (e.g., ensure width/height are numbers)
for field in ['width', 'height', 'x_offset', 'y_offset']:
try:
if field in updated_mapping:
# Convert to int, handle potential errors
updated_mapping[field] = int(updated_mapping[field])
except (ValueError, TypeError):
print(f"Warning: Invalid value for '{field}' in mapping '{updated_mapping.get('svg_type')}'. Setting default.")
updated_mapping[field] = default_mapping.get(field, 0) # Default numeric to 0 or from DEFAULT_CONFIG
updated_mappings.append(updated_mapping)
# if missing_fields:
# print(f"Updated mapping: {updated_mapping}")
# Replace the old list with the updated one
config['element_mappings'] = updated_mappings
return config
# Example usage (optional, for testing)
# if __name__ == "__main__":
# manager = ConfigManager("test_config.json")
# print("--- Initializing ---")
# initial_config = manager.get_config()
# print("Initial config:", json.dumps(initial_config, indent=2))
#
# # Modify config
# initial_config['project_title'] = "Updated Project Title"
# initial_config['element_mappings'].append({
# "svg_type": "circle",
# "element_type": "ia.display.led_display",
# "label_prefix": "LED_",
# "props_path": "Symbols/LED",
# "width": 20,
# "height": 20,
# "x_offset": -10,
# "y_offset": -10,
# "final_prefix": "PFX_",
# "final_suffix": "_SFX"
# })
#
# print("\n--- Saving Modified Config ---")
# save_success = manager.save_config(initial_config)
# print(f"Save successful: {save_success}")
#
# print("\n--- Reloading Config ---")
# reloaded_config = manager.get_config()
# print("Reloaded config:", json.dumps(reloaded_config, indent=2))
#
# # Test backward compatibility (create a dummy old config)
# old_config_path = manager._get_config_path() + ".old"
# old_config_data = {
# "file_path": "/path/to/old.svg",
# "project_title": "Old Project",
# "element_type_mapping": {"rect": "ia.display.rect", "path": "ia.display.path"},
# "element_label_prefix_mapping": {"rect": "R_"}
# }
# with open(old_config_path, 'w') as f:
# json.dump(old_config_data, f, indent=4)
#
# print("\n--- Testing Backward Compatibility --- ")
# old_manager = ConfigManager(os.path.basename(old_config_path))
# loaded_old_config = old_manager.get_config()
# print("Loaded and converted old config:", json.dumps(loaded_old_config, indent=2))
#
# # Clean up test files
# # os.remove(manager._get_config_path())
# # os.remove(old_config_path)

79
gui/button_frame.py Normal file
View File

@ -0,0 +1,79 @@
import tkinter as tk
from tkinter import ttk
class ButtonFrame(ttk.Frame):
"""Frame for holding the main action buttons."""
def __init__(self, parent, process_command, quit_command, **kwargs):
"""
Initialize the Button Frame.
Args:
parent: The parent widget.
process_command: Callback function for the Process button.
# theme_command: Callback function for the Toggle Theme button.
quit_command: Callback function for the Quit button.
**kwargs: Additional arguments for ttk.Frame.
"""
super().__init__(parent, padding=10, **kwargs)
self._process_command = process_command
# self._theme_command = theme_command
self._quit_command = quit_command
self._create_widgets()
def _create_widgets(self):
"""Create the buttons."""
self.process_button = ttk.Button(self, text="Process SVG", command=self._process_command)
self.process_button.pack(side=tk.LEFT, padx=5)
# self.theme_button = ttk.Button(self, text="Toggle Theme", command=self._theme_command)
# self.theme_button.pack(side=tk.LEFT, padx=5)
self.quit_button = ttk.Button(self, text="Quit", command=self._quit_command)
self.quit_button.pack(side=tk.RIGHT, padx=5) # Changed back to tk.RIGHT
def add_center_mode_checkbox(self, variable):
"""Adds the checkbox for selecting path center calculation mode."""
self.center_mode_checkbox = ttk.Checkbutton(
self,
text="Use Centroid for Paths",
variable=variable,
onvalue='centroid',
offvalue='bbox'
)
# Pack it to the left, after the Process button
self.center_mode_checkbox.pack(side=tk.LEFT, padx=10)
def set_process_button_state(self, state):
"""Enable or disable the Process button."""
self.process_button.config(state=state)
# Example usage
if __name__ == '__main__':
root = tk.Tk()
root.title("Button Frame Test")
root.geometry("400x100")
style = ttk.Style()
style.theme_use('clam')
def mock_process():
print("Process button clicked")
def mock_quit():
print("Quit button clicked")
root.quit()
button_frame = ButtonFrame(
root,
process_command=mock_process,
quit_command=mock_quit
)
button_frame.pack(pady=20)
# Example of disabling/enabling the process button
ttk.Button(root, text="Disable Process", command=lambda: button_frame.set_process_button_state(tk.DISABLED)).pack(side=tk.LEFT, padx=10)
ttk.Button(root, text="Enable Process", command=lambda: button_frame.set_process_button_state(tk.NORMAL)).pack(side=tk.LEFT, padx=10)
root.mainloop()

View File

@ -0,0 +1,461 @@
import tkinter as tk
from tkinter import ttk
import time # For debouncing
DEFAULT_MAPPING = {
'svg_type': "rect", 'label_prefix': "", 'element_type': "ia.display.view",
'props_path': "Symbol-Views/Equipment-Views/Status", 'width': "14", 'height': "14",
'x_offset': "0", 'y_offset': "0", 'final_prefix': "", 'final_suffix': ""
}
class ElementMappingFrame(ttk.Frame):
"""Frame for managing SVG element mappings, including add/remove and saving."""
def __init__(self, parent, save_config_callback=None, default_mappings=None, **kwargs):
"""
Initialize the Element Mapping Frame.
Args:
parent: The parent widget.
save_config_callback (callable, optional): Function to call (debounced) when mappings change.
default_mappings (list, optional): Default mappings to load if none exist.
**kwargs: Additional arguments for ttk.Frame.
"""
super().__init__(parent, **kwargs)
self._save_config_callback = save_config_callback
self._default_mappings = default_mappings if default_mappings is not None else [DEFAULT_MAPPING]
# Internal state
self.mapping_rows = [] # List storing dicts: {'vars': {}, 'widgets': {}}
self._initialized = False # Flag to prevent saving during initial load
self._save_timer_id = None
self._canvas = None
self._scrollable_frame = None
self._create_widgets()
self._setup_bindings()
def _create_widgets(self):
"""Create the main widgets: Canvas, Scrollbar, inner frame, header, add button."""
# --- Container for Add Button + Scrollable Area ---
main_container = ttk.Frame(self)
main_container.pack(fill=tk.BOTH, expand=True)
main_container.rowconfigure(1, weight=1)
main_container.columnconfigure(0, weight=1)
# --- Add Button ---
add_button_frame = ttk.Frame(main_container, padding=(0, 5))
add_button_frame.grid(row=0, column=0, sticky=tk.EW)
add_button = ttk.Button(add_button_frame, text="Add New Mapping", command=self._handle_add_new_mapping_click)
add_button.pack(side=tk.LEFT)
# Add more buttons here later if needed (e.g., Load Defaults)
# --- Scrollable Area ---
scroll_container = ttk.Frame(main_container)
scroll_container.grid(row=1, column=0, sticky="nsew")
scroll_container.rowconfigure(0, weight=1)
scroll_container.columnconfigure(0, weight=1)
self._canvas = tk.Canvas(scroll_container, borderwidth=0, highlightthickness=0)
scrollbar = ttk.Scrollbar(scroll_container, orient="vertical", command=self._canvas.yview)
self._scrollable_frame = ttk.Frame(self._canvas) # The frame holding the mapping grid
self._scrollable_frame.bind("<Configure>", self._on_frame_configure)
self._canvas.create_window((0, 0), window=self._scrollable_frame, anchor="nw", tags="self.frame")
self._canvas.configure(yscrollcommand=scrollbar.set)
self._canvas.grid(row=0, column=0, sticky="nsew")
scrollbar.grid(row=0, column=1, sticky="ns")
# --- Header Row (in scrollable frame) ---
self._create_header_row(self._scrollable_frame)
def _on_frame_configure(self, event=None):
"""Reset the scroll region to encompass the inner frame."""
if self._canvas:
self._canvas.configure(scrollregion=self._canvas.bbox("all"))
def _create_header_row(self, parent):
"""Create the header row with column labels."""
headers = [
("SVG Type", 10), ("Label Prefix", 10), ("Element Type", 20), ("Props Path", 40),
("Size (WxH)", 10), ("Offset (X,Y)", 10), ("Final Prefix", 15), ("Final Suffix", 15), ("", 3) # Action
]
for col, (header, width) in enumerate(headers):
lbl = ttk.Label(parent, text=header, font=('TkDefaultFont', 9, 'bold'))
lbl.grid(row=0, column=col, padx=5, pady=(2,5), sticky="w")
parent.columnconfigure(col, weight=0, minsize=width*5) # Basic width estimate
parent.columnconfigure(3, weight=2) # Allow Props Path to expand most (increased weight)
parent.columnconfigure(2, weight=1)
def _setup_bindings(self):
"""Set up mouse wheel scrolling for the canvas."""
# Bind to canvas and scrollable frame to catch events inside
for widget in [self._canvas, self._scrollable_frame]:
# Unix-like
widget.bind("<Button-4>", self._on_mousewheel, add='+')
widget.bind("<Button-5>", self._on_mousewheel, add='+')
# Windows
widget.bind("<MouseWheel>", self._on_mousewheel, add='+')
def _on_mousewheel(self, event):
"""Handle mouse wheel scrolling only when mouse is over the canvas."""
# Check if the mouse is within the canvas bounds
canvas_x = self._canvas.winfo_rootx()
canvas_y = self._canvas.winfo_rooty()
canvas_w = self._canvas.winfo_width()
canvas_h = self._canvas.winfo_height()
if not (canvas_x <= event.x_root < canvas_x + canvas_w and
canvas_y <= event.y_root < canvas_y + canvas_h):
return # Mouse not over canvas
if event.num == 5 or event.delta < 0:
self._canvas.yview_scroll(1, "units")
elif event.num == 4 or event.delta > 0:
self._canvas.yview_scroll(-1, "units")
# Optional: return "break" to prevent event propagation further?
# return "break"
def _add_mapping_row(self, data=None):
"""Adds a new row for element mapping to the UI and internal list."""
if data is None: data = {}
row_index_in_grid = len(self.mapping_rows) + 1 # Grid row (1-based below header)
list_index = len(self.mapping_rows) # Index in self.mapping_rows list
row_vars = {
'svg_type': tk.StringVar(value=data.get('svg_type', '')),
'label_prefix': tk.StringVar(value=data.get('label_prefix', '')),
'element_type': tk.StringVar(value=data.get('element_type', '')),
'props_path': tk.StringVar(value=data.get('props_path', '')),
'width': tk.StringVar(value=str(data.get('width', ''))),
'height': tk.StringVar(value=str(data.get('height', ''))),
'x_offset': tk.StringVar(value=str(data.get('x_offset', ''))),
'y_offset': tk.StringVar(value=str(data.get('y_offset', ''))),
'final_prefix': tk.StringVar(value=data.get('final_prefix', '')),
'final_suffix': tk.StringVar(value=data.get('final_suffix', '')),
}
widgets = {}
pad_options = {'pady': 1, 'padx': 5}
entry_width = 10 # Default, will be overridden by column config mostly
# Create Entry Widgets
widgets['svg_entry'] = ttk.Entry(self._scrollable_frame, textvariable=row_vars['svg_type'], width=entry_width)
widgets['svg_entry'].grid(row=row_index_in_grid, column=0, sticky=tk.EW, **pad_options)
widgets['label_prefix_entry'] = ttk.Entry(self._scrollable_frame, textvariable=row_vars['label_prefix'], width=entry_width)
widgets['label_prefix_entry'].grid(row=row_index_in_grid, column=1, sticky=tk.EW, **pad_options)
widgets['element_entry'] = ttk.Entry(self._scrollable_frame, textvariable=row_vars['element_type'], width=entry_width*2)
widgets['element_entry'].grid(row=row_index_in_grid, column=2, sticky=tk.EW, **pad_options)
widgets['props_entry'] = ttk.Entry(self._scrollable_frame, textvariable=row_vars['props_path'], width=entry_width*6)
widgets['props_entry'].grid(row=row_index_in_grid, column=3, sticky=tk.EW, **pad_options)
# --- Size Frame (WxH) ---
size_frame = ttk.Frame(self._scrollable_frame)
size_frame.grid(row=row_index_in_grid, column=4, sticky=tk.EW, **pad_options)
widgets['width_entry'] = ttk.Entry(size_frame, textvariable=row_vars['width'], width=5)
widgets['width_entry'].pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(0,1))
ttk.Label(size_frame, text="×").pack(side=tk.LEFT)
widgets['height_entry'] = ttk.Entry(size_frame, textvariable=row_vars['height'], width=5)
widgets['height_entry'].pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(1,0))
widgets['size_frame'] = size_frame
# --- Offset Frame (X,Y) ---
offset_frame = ttk.Frame(self._scrollable_frame)
offset_frame.grid(row=row_index_in_grid, column=5, sticky=tk.EW, **pad_options)
widgets['x_offset_entry'] = ttk.Entry(offset_frame, textvariable=row_vars['x_offset'], width=5)
widgets['x_offset_entry'].pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(0,1))
ttk.Label(offset_frame, text=",").pack(side=tk.LEFT)
widgets['y_offset_entry'] = ttk.Entry(offset_frame, textvariable=row_vars['y_offset'], width=5)
widgets['y_offset_entry'].pack(side=tk.LEFT, expand=True, fill=tk.X, padx=(1,0))
widgets['offset_frame'] = offset_frame
# --- Final Prefix/Suffix ---
widgets['final_prefix_entry'] = ttk.Entry(self._scrollable_frame, textvariable=row_vars['final_prefix'], width=entry_width+5)
widgets['final_prefix_entry'].grid(row=row_index_in_grid, column=6, sticky=tk.EW, **pad_options)
widgets['final_suffix_entry'] = ttk.Entry(self._scrollable_frame, textvariable=row_vars['final_suffix'], width=entry_width+5)
widgets['final_suffix_entry'].grid(row=row_index_in_grid, column=7, sticky=tk.EW, **pad_options)
# --- Remove Button ---
remove_button = ttk.Button(self._scrollable_frame, text="X", width=3, style="Danger.TButton",
command=lambda idx=list_index: self._handle_remove_mapping_click(idx))
remove_button.grid(row=row_index_in_grid, column=8, sticky=tk.W, pady=1, padx=(5, 15))
widgets['remove_button'] = remove_button
# Store row information
self.mapping_rows.append({'vars': row_vars, 'widgets': widgets})
# Add trace to trigger save when any value changes (AFTER adding to list)
for var in row_vars.values():
var.trace_add("write", self._schedule_config_save)
# Update scroll region after adding row
self._on_frame_configure()
# Scroll to bottom if added by button click (heuristic)
# if self._initialized: self._scroll_to_bottom()
return list_index # Return the list index
def _handle_add_new_mapping_click(self):
"""Callback for the 'Add New Mapping' button."""
new_index = self._add_mapping_row() # Add blank row
# Focus on the first entry of the new row and scroll
if new_index is not None and new_index < len(self.mapping_rows):
try:
self.mapping_rows[new_index]['widgets']['svg_entry'].focus_set()
self._scroll_to_show_row(new_index)
except tk.TclError: pass # Ignore if widget destroyed
self._schedule_config_save() # Trigger save after adding
def _scroll_to_show_row(self, list_index):
"""Scrolls the canvas so the specified row (by list index) is visible."""
try:
widget_to_show = self.mapping_rows[list_index]['widgets']['svg_entry']
self.update_idletasks() # Ensure layout calculated
self._canvas.yview_scroll(0, "pages") # Go to top first for better bbox calc?
bbox = self._canvas.bbox(widget_to_show) # Get bbox relative to canvas
if bbox:
canvas_height = self._canvas.winfo_height()
# Check if bottom of widget is below the visible area
if bbox[3] > self._canvas.canvasy(0) + canvas_height:
self._canvas.yview_moveto(bbox[1] / self._canvas.bbox("all")[3]) # Scroll based on top edge
# Check if top of widget is above the visible area
elif bbox[1] < self._canvas.canvasy(0):
self._canvas.yview_moveto(bbox[1] / self._canvas.bbox("all")[3])
except (IndexError, KeyError, tk.TclError) as e:
print(f"Error scrolling to row {list_index}: {e}")
def _handle_remove_mapping_click(self, list_index):
"""Removes the row and schedules a save."""
self._remove_mapping_row(list_index)
self._schedule_config_save() # Trigger save after removal
def _remove_mapping_row(self, list_index):
"""Removes a mapping row from the list and destroys its widgets."""
if not (0 <= list_index < len(self.mapping_rows)):
print(f"Error: Invalid index {list_index} for removing mapping row.")
return
row_to_remove = self.mapping_rows.pop(list_index)
# Destroy Widgets
for widget in row_to_remove['widgets'].values():
if widget and isinstance(widget, tk.Widget):
try:
widget.destroy()
except tk.TclError: pass # Ignore if already destroyed
# Re-grid the remaining rows below the removed one
self._re_grid_rows(start_list_index=list_index)
self._on_frame_configure() # Update scroll region
# If list is now empty, add a default row (but don't save it yet)
if not self.mapping_rows:
# Temporarily disable save callback during this add
original_callback = self._save_config_callback
self._save_config_callback = None
self._add_mapping_row(DEFAULT_MAPPING)
self._save_config_callback = original_callback
def _re_grid_rows(self, start_list_index=0):
"""Re-grids rows in the UI from the given list index onwards."""
for idx in range(start_list_index, len(self.mapping_rows)):
row_data = self.mapping_rows[idx]
new_grid_row = idx + 1 # Grid row is 1-based
widgets = row_data['widgets']
pad_options = {'pady': 1, 'padx': 5}
# --- Re-Grid all widgets ---
widgets['svg_entry'].grid(row=new_grid_row, column=0, sticky=tk.EW, **pad_options)
widgets['label_prefix_entry'].grid(row=new_grid_row, column=1, sticky=tk.EW, **pad_options)
widgets['element_entry'].grid(row=new_grid_row, column=2, sticky=tk.EW, **pad_options)
widgets['props_entry'].grid(row=new_grid_row, column=3, sticky=tk.EW, **pad_options)
widgets['size_frame'].grid(row=new_grid_row, column=4, sticky=tk.EW, **pad_options)
widgets['offset_frame'].grid(row=new_grid_row, column=5, sticky=tk.EW, **pad_options)
widgets['final_prefix_entry'].grid(row=new_grid_row, column=6, sticky=tk.EW, **pad_options)
widgets['final_suffix_entry'].grid(row=new_grid_row, column=7, sticky=tk.EW, **pad_options)
# --- Update Remove Button Command ---
remove_button = widgets['remove_button']
remove_button.grid(row=new_grid_row, column=8, sticky=tk.W, pady=1, padx=(5, 15))
remove_button.configure(command=lambda current_idx=idx: self._handle_remove_mapping_click(current_idx))
def _schedule_config_save(self, *args, delay=1500):
"""Debounces calls to the save configuration callback."""
if not self._initialized or not self._save_config_callback:
return # Don't save during init or if no callback provided
if self._save_timer_id is not None:
self.after_cancel(self._save_timer_id)
# print(f"DEBUG: Scheduling save in {delay}ms") # Debug
self._save_timer_id = self.after(delay, self._save_config_callback)
def get_mappings(self, include_incomplete=False):
"""Get all current mappings, optionally filtering incomplete ones."""
mappings = []
for row_data in self.mapping_rows:
try:
mapping = {
'svg_type': row_data['vars']['svg_type'].get().strip(),
'label_prefix': row_data['vars']['label_prefix'].get().strip(),
'element_type': row_data['vars']['element_type'].get().strip(),
'props_path': row_data['vars']['props_path'].get().strip(),
'width': row_data['vars']['width'].get().strip(),
'height': row_data['vars']['height'].get().strip(),
'x_offset': row_data['vars']['x_offset'].get().strip(),
'y_offset': row_data['vars']['y_offset'].get().strip(),
'final_prefix': row_data['vars']['final_prefix'].get().strip(),
'final_suffix': row_data['vars']['final_suffix'].get().strip()
}
# Basic Validation: Only include if essential info exists, unless forced
if include_incomplete or (mapping['svg_type'] and mapping['element_type']):
# Convert numeric fields safely
for key in ['width', 'height', 'x_offset', 'y_offset']:
# Try converting to int, keep as string if error or empty
try:
if mapping[key]: mapping[key] = int(mapping[key])
# else: keep empty string '' or handle as 0? Decide based on need.
# For saving, empty might mean use default. Keeping string allows flexibility.
except ValueError: pass # Keep original string if not valid int
mappings.append(mapping)
except (KeyError, tk.TclError) as e:
print(f"Warning: Skipping row during get_mappings due to error: {e}")
continue
return mappings
def load_mappings(self, mappings_data):
"""Load mappings into the frame, replacing existing ones."""
# --- Clear Existing Rows ---
# Cancel pending saves first
if self._save_timer_id is not None:
self.after_cancel(self._save_timer_id)
self._save_timer_id = None
# Iterate backwards through the list to remove rows
for i in range(len(self.mapping_rows) - 1, -1, -1):
self._remove_mapping_row(i)
# At this point, self.mapping_rows should be empty,
# and _remove_mapping_row should have added back one default row if it became empty.
# If the default row add logic is robust, we might not need to explicitly clear again.
# Let's ensure it's empty before loading.
if self.mapping_rows:
print("Warning: Rows still exist after clearing loop in load_mappings.")
# Force clear again?
for i in range(len(self.mapping_rows) - 1, -1, -1):
self._remove_mapping_row(i)
# --- Load New Mappings ---
self._initialized = False # Disable saving during load
if not mappings_data: # If input is empty list
mappings_to_load = self._default_mappings
else:
mappings_to_load = mappings_data
for mapping in mappings_to_load:
if isinstance(mapping, dict): # Basic check
self._add_mapping_row(data=mapping)
# --- Finalize ---
# Ensure at least one row exists (should be handled by _remove_mapping_row)
if not self.mapping_rows:
print("Warning: No rows after loading, adding default.")
self._add_mapping_row(DEFAULT_MAPPING)
self._initialized = True # Enable saving now
self.update_idletasks() # Update layout
self._on_frame_configure() # Recalculate scroll region
# print(f"Loaded {len(self.mapping_rows)} mappings.")
def cleanup_empty_rows(self):
"""Removes rows where both SVG Type and Element Type are empty, except if it's the only row."""
indices_to_remove = []
for i in range(len(self.mapping_rows) - 1, -1, -1):
# Don't remove the *only* row, even if empty
if len(self.mapping_rows) <= 1:
break
row_data = self.mapping_rows[i]
try:
svg_type = row_data['vars']['svg_type'].get().strip()
element_type = row_data['vars']['element_type'].get().strip()
if not svg_type and not element_type:
indices_to_remove.append(i)
except (KeyError, tk.TclError):
indices_to_remove.append(i) # Remove if vars/widgets are broken
if indices_to_remove:
print(f"Cleaning up {len(indices_to_remove)} empty/incomplete mapping rows.")
# Cancel any pending save before modifying rows
if self._save_timer_id is not None:
self.after_cancel(self._save_timer_id)
self._save_timer_id = None
for index in indices_to_remove:
self._remove_mapping_row(index) # This handles re-gridding
# Schedule a save after cleanup is done
self._schedule_config_save(delay=100)
# Example usage (for testing)
if __name__ == '__main__':
root = tk.Tk()
root.title("Element Mapping Frame Test")
root.geometry("950x400")
# --- Style ---
style = ttk.Style()
style.theme_use('clam')
# Add a danger style for the remove button
style.configure("Danger.TButton", foreground='white', background='#a83232')
style.map("Danger.TButton", background=[('active', '#c44'), ('pressed', '#d55')])
# --- Save Callback ---
def mock_save_config():
print(f"[{time.strftime('%H:%M:%S')}] MOCK SAVE triggered!")
current_mappings = mapping_frame.get_mappings()
print(f"Current Mappings ({len(current_mappings)}):")
import json
print(json.dumps(current_mappings, indent=2))
# --- Frame Creation ---
mapping_frame = ElementMappingFrame(root, save_config_callback=mock_save_config)
mapping_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# --- Test Data ---
test_mappings = [
{'svg_type': 'rect', 'label_prefix': 'EQ', 'element_type': 'ia.display.view', 'props_path': 'Symbols/Valves/ValveSO', 'width': '20', 'height': '20', 'x_offset': '0', 'y_offset': '0', 'final_prefix': 'valves/', 'final_suffix': ''},
{'svg_type': 'circle', 'label_prefix': 'PMP', 'element_type': 'ia.display.view', 'props_path': 'Symbols/Pumps/PumpBasic', 'width': '30', 'height': '30', 'x_offset': '-15', 'y_offset': '-15', 'final_prefix': 'pumps/', 'final_suffix': '_status'},
{'svg_type': 'path', 'label_prefix': '', 'element_type': 'ia.display.label', 'props_path': '', 'width': '50', 'height': '15', 'x_offset': '0', 'y_offset': '0', 'final_prefix': 'labels/', 'final_suffix': ''},
# Add more diverse examples if needed
]
# --- Test Controls ---
controls_frame = ttk.Frame(root, padding=5)
controls_frame.pack()
load_button = ttk.Button(controls_frame, text="Load Test Data",
command=lambda: mapping_frame.load_mappings(test_mappings))
load_button.pack(side=tk.LEFT, padx=5)
clear_button = ttk.Button(controls_frame, text="Load Empty ([])",
command=lambda: mapping_frame.load_mappings([]))
clear_button.pack(side=tk.LEFT, padx=5)
cleanup_button = ttk.Button(controls_frame, text="Cleanup Empty Rows",
command=mapping_frame.cleanup_empty_rows)
cleanup_button.pack(side=tk.LEFT, padx=5)
get_button = ttk.Button(controls_frame, text="Get Mappings (Print)", command=mock_save_config)
get_button.pack(side=tk.LEFT, padx=5)
# --- Initial Load ---
# mapping_frame.load_mappings([]) # Start empty
mapping_frame.load_mappings(test_mappings) # Start with test data
root.mainloop()

174
gui/file_handler.py Normal file
View File

@ -0,0 +1,174 @@
import tkinter as tk
from tkinter import filedialog, messagebox
import os
import json
class FileHandler:
"""Handler for file operations."""
def __init__(self, root):
"""
Initialize the file handler.
Args:
root: The Tkinter root window.
"""
self.root = root
def browse_file(self, title="Select File", filetypes=None):
"""Open a file dialog to select a file.
Args:
title (str): The title of the file dialog.
filetypes (list): List of file type tuples (description, pattern).
Returns:
str: The selected file path, or None if no file was selected.
"""
if filetypes is None:
filetypes = [("All files", "*.*")]
file_path = filedialog.askopenfilename(
title=title,
filetypes=filetypes
)
return file_path if file_path else None
def save_file(self, title="Save File", filetypes=None, defaultextension="", initialfile=""):
"""Open a file dialog to save a file.
Args:
title (str): The title of the file dialog.
filetypes (list): List of file type tuples (description, pattern).
defaultextension (str): Default file extension.
initialfile (str): The initial filename to suggest.
Returns:
str: The selected file path, or None if no file was selected.
"""
if filetypes is None:
filetypes = [("All files", "*.*")]
file_path = filedialog.asksaveasfilename(
title=title,
filetypes=filetypes,
defaultextension=defaultextension,
initialfile=initialfile
)
return file_path if file_path else None
def save_text_to_file(self, text, title="Save Text", filetypes=None, defaultextension=".txt"):
"""Save text to a file.
Args:
text (str): The text to save.
title (str): The title of the file dialog.
filetypes (list): List of file type tuples (description, pattern).
defaultextension (str): Default file extension.
Returns:
bool: True if the file was saved successfully, False otherwise.
"""
file_path = self.save_file(title, filetypes, defaultextension)
if file_path:
try:
with open(file_path, 'w') as f:
f.write(text)
return True
except Exception as e:
messagebox.showerror("Error", f"Failed to save file: {str(e)}")
return False
def save_json_to_file(self, data, title="Save JSON", filetypes=None, defaultextension=".json"):
"""Save JSON data to a file.
Args:
data: The JSON data to save.
title (str): The title of the file dialog.
filetypes (list): List of file type tuples (description, pattern).
defaultextension (str): Default file extension.
Returns:
bool: True if the file was saved successfully, False otherwise.
"""
file_path = self.save_file(title, filetypes, defaultextension)
if file_path:
try:
with open(file_path, 'w') as f:
json.dump(data, f, indent=4)
return True
except Exception as e:
messagebox.showerror("Error", f"Failed to save file: {str(e)}")
return False
def load_json_from_file(self, title="Load JSON", filetypes=None):
"""Load JSON data from a file.
Args:
title (str): The title of the file dialog.
filetypes (list): List of file type tuples (description, pattern).
Returns:
dict: The loaded JSON data, or None if no file was selected or loading failed.
"""
if filetypes is None:
filetypes = [("JSON files", "*.json"), ("All files", "*.*")]
file_path = self.browse_file(title, filetypes)
if file_path:
try:
with open(file_path, 'r') as f:
return json.load(f)
except Exception as e:
messagebox.showerror("Error", f"Failed to load file: {str(e)}")
return None
# Example usage (for testing)
if __name__ == '__main__':
root = tk.Tk()
root.title("File Handler Test")
root.geometry("400x300")
# Create file handler
file_handler = FileHandler(root)
# Test buttons
def test_browse():
file_path = file_handler.browse_file(
title="Select a file",
filetypes=[("Text files", "*.txt"), ("All files", "*.*")]
)
if file_path:
print(f"Selected file: {file_path}")
def test_save():
success = file_handler.save_text_to_file(
"Test text content",
title="Save text file",
filetypes=[("Text files", "*.txt")]
)
if success:
print("File saved successfully")
def test_json():
data = {"test": "data", "number": 123}
success = file_handler.save_json_to_file(
data,
title="Save JSON file"
)
if success:
print("JSON file saved successfully")
ttk.Button(root, text="Browse File", command=test_browse).pack(pady=5)
ttk.Button(root, text="Save Text", command=test_save).pack(pady=5)
ttk.Button(root, text="Save JSON", command=test_json).pack(pady=5)
root.mainloop()

63
gui/form_frame.py Normal file
View File

@ -0,0 +1,63 @@
import tkinter as tk
from tkinter import ttk
class FormFrame(ttk.Frame):
"""Frame for selecting the SVG file."""
def __init__(self, parent, file_path_var, browse_command, **kwargs):
"""
Initialize the Form Frame.
Args:
parent: The parent widget.
file_path_var (tk.StringVar): Variable to hold the SVG file path.
browse_command (callable): Command to execute when Browse button is clicked.
**kwargs: Additional arguments for ttk.Frame.
"""
super().__init__(parent, padding=10, **kwargs)
self.file_path_var = file_path_var
self.browse_command = browse_command
self._create_widgets()
def _create_widgets(self):
"""Create the widgets for the form frame."""
ttk.Label(self, text="SVG File:").grid(row=0, column=0, sticky=tk.W, pady=2, padx=5)
file_entry = ttk.Entry(self, textvariable=self.file_path_var, width=60)
file_entry.grid(row=0, column=1, sticky=tk.EW, pady=2)
browse_button = ttk.Button(self, text="Browse...", command=self.browse_command)
browse_button.grid(row=0, column=2, padx=5, pady=2)
# Configure column weights for expansion
self.columnconfigure(1, weight=1)
# Example usage (for testing)
if __name__ == '__main__':
# Import filedialog only needed for the test case
from tkinter import filedialog
root = tk.Tk()
root.title("Form Frame Test")
root.geometry("500x100")
# Style for testing
style = ttk.Style()
style.theme_use('clam')
style.configure('TFrame', background='#f0f0f0')
style.configure('TLabel', background='#f0f0f0')
# Example variables and command
file_path = tk.StringVar(value="/path/to/your/file.svg")
def test_browse():
print("Browse button clicked!")
# In real use, this would open a file dialog
new_path = filedialog.askopenfilename(title="Select SVG", filetypes=[("SVG", "*.svg")])
if new_path:
file_path.set(new_path)
# Create and pack the frame
form_frame_component = FormFrame(root, file_path_var=file_path, browse_command=test_browse)
form_frame_component.pack(fill=tk.X, padx=10, pady=10)
root.mainloop()

125
gui/log_frame.py Normal file
View File

@ -0,0 +1,125 @@
import tkinter as tk
from tkinter import ttk, scrolledtext
class LogFrame(ttk.Frame):
"""Frame for displaying log messages."""
def __init__(self, parent, **kwargs):
"""
Initialize the Log Frame.
Args:
parent: The parent widget.
**kwargs: Additional arguments for ttk.Frame.
"""
super().__init__(parent, **kwargs)
self._create_widgets()
def _create_widgets(self):
"""Create the widgets for the log frame."""
# Create scrolled text widget
self.log_text = scrolledtext.ScrolledText(
self,
wrap=tk.WORD,
width=80,
height=20,
font=('Consolas', 10)
)
self.log_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
self.log_text.configure(state='disabled')
# Create button frame
button_frame = ttk.Frame(self)
button_frame.pack(fill=tk.X, padx=5, pady=(0, 5))
# Create buttons
ttk.Button(
button_frame,
text="Clear",
command=self.clear
).pack(side=tk.LEFT, padx=5)
ttk.Button(
button_frame,
text="Copy to Clipboard",
command=self.copy_to_clipboard
).pack(side=tk.LEFT, padx=5)
ttk.Button(
button_frame,
text="Save to File",
command=self.save_to_file
).pack(side=tk.LEFT, padx=5)
def append(self, text):
"""Append text to the log.
Args:
text (str): The text to append.
"""
self.log_text.configure(state='normal')
self.log_text.insert('end', text)
self.log_text.see('end')
self.log_text.configure(state='disabled')
def clear(self):
"""Clear the log."""
self.log_text.configure(state='normal')
self.log_text.delete(1.0, tk.END)
self.log_text.configure(state='disabled')
def copy_to_clipboard(self):
"""Copy log contents to clipboard."""
self.log_text.configure(state='normal')
text = self.log_text.get(1.0, tk.END)
self.log_text.configure(state='disabled')
if text.strip():
self.clipboard_clear()
self.clipboard_append(text)
self.append("\nCopied to clipboard.\n")
def save_to_file(self):
"""Save log contents to a file."""
self.log_text.configure(state='normal')
text = self.log_text.get(1.0, tk.END)
self.log_text.configure(state='disabled')
if text.strip():
from tkinter import filedialog
file_path = filedialog.asksaveasfilename(
defaultextension=".txt",
filetypes=[("Text files", "*.txt"), ("All files", "*.*")]
)
if file_path:
try:
with open(file_path, 'w') as f:
f.write(text)
self.append(f"\nLog saved to {file_path}\n")
except Exception as e:
self.append(f"\nError saving log: {str(e)}\n")
# Example usage (for testing)
if __name__ == '__main__':
root = tk.Tk()
root.title("Log Frame Test")
root.geometry("600x400")
# Style for testing
style = ttk.Style()
style.theme_use('clam')
style.configure('TFrame', background='#f0f0f0')
style.configure('TLabel', background='#f0f0f0')
# Create and pack the log frame
log_frame = LogFrame(root)
log_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# Test buttons
def add_log():
log_frame.append("Test log message\n")
ttk.Button(root, text="Add Log", command=add_log).pack(pady=5)
root.mainloop()

71
gui/progress_frame.py Normal file
View File

@ -0,0 +1,71 @@
import tkinter as tk
from tkinter import ttk
class ProgressFrame(ttk.Frame):
"""Frame for displaying progress information."""
def __init__(self, parent, **kwargs):
"""
Initialize the Progress Frame.
Args:
parent: The parent widget.
**kwargs: Additional arguments for ttk.Frame.
"""
super().__init__(parent, **kwargs)
self._create_widgets()
def _create_widgets(self):
"""Create the widgets for the progress frame."""
# Create progress bar
self.progress = ttk.Progressbar(
self,
mode='indeterminate',
length=200
)
self.progress.pack(fill=tk.X, expand=True, padx=5, pady=5)
def start(self):
"""Start the progress bar."""
self.progress.start()
def stop(self):
"""Stop the progress bar."""
self.progress.stop()
def reset(self):
"""Reset the progress bar."""
self.progress.stop()
self.progress['value'] = 0
# Example usage (for testing)
if __name__ == '__main__':
root = tk.Tk()
root.title("Progress Frame Test")
root.geometry("400x100")
# Style for testing
style = ttk.Style()
style.theme_use('clam')
style.configure('TFrame', background='#f0f0f0')
style.configure('TLabel', background='#f0f0f0')
# Create and pack the progress frame
progress_frame = ProgressFrame(root)
progress_frame.pack(fill=tk.X, expand=True, padx=10, pady=10)
# Test buttons
def start_progress():
progress_frame.start()
def stop_progress():
progress_frame.stop()
def reset_progress():
progress_frame.reset()
ttk.Button(root, text="Start", command=start_progress).pack(side=tk.LEFT, padx=5)
ttk.Button(root, text="Stop", command=stop_progress).pack(side=tk.LEFT, padx=5)
ttk.Button(root, text="Reset", command=reset_progress).pack(side=tk.LEFT, padx=5)
root.mainloop()

197
gui/results_frame.py Normal file
View File

@ -0,0 +1,197 @@
import tkinter as tk
from tkinter import ttk, scrolledtext, filedialog, messagebox
import json
class ResultsFrame(ttk.Frame):
"""Frame for displaying processing results with actions."""
def __init__(self, parent, export_command=None, **kwargs):
"""
Initialize the Results Frame.
Args:
parent: The parent widget.
export_command: Callback function for the Export SCADA button.
**kwargs: Additional arguments for ttk.Frame.
"""
super().__init__(parent, **kwargs)
self._export_command = export_command # Store the command
self._create_widgets()
def _create_widgets(self):
"""Create the widgets for the results frame."""
# Create scrolled text widget
self.results_text = scrolledtext.ScrolledText(
self,
wrap=tk.WORD,
width=80,
height=20,
font=('Consolas', 10),
state='disabled' # Start disabled
)
self.results_text.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
# Create button frame
button_frame = ttk.Frame(self)
button_frame.pack(fill=tk.X, padx=5, pady=(0, 5))
# Create buttons
self.clear_button = ttk.Button(
button_frame,
text="Clear",
command=self.clear
)
self.clear_button.pack(side=tk.LEFT, padx=5)
self.copy_button = ttk.Button(
button_frame,
text="Copy to Clipboard",
command=self.copy_to_clipboard
)
self.copy_button.pack(side=tk.LEFT, padx=5)
self.save_button = ttk.Button(
button_frame,
text="Save to File",
command=self.save_to_file
)
self.save_button.pack(side=tk.LEFT, padx=5)
# Check if the export command was provided before creating the button
if self._export_command:
self.export_button = ttk.Button(
button_frame,
text="Export SCADA",
command=self._export_command # Use the stored command
)
self.export_button.pack(side=tk.LEFT, padx=5)
else:
# Optional: Log a warning if command is missing, though it shouldn't be
print("Warning: Export command not provided to ResultsFrame.")
def set_results(self, results_data):
"""
Format and display the results data (typically a list of dicts).
Args:
results_data: The data to display.
"""
self.results_text.configure(state='normal')
self.results_text.delete(1.0, tk.END)
if not results_data:
self.results_text.insert(tk.END, "No elements found or processed matching the criteria.")
self.results_text.configure(state='disabled')
return
try:
# Format results as JSON string
formatted_json = json.dumps(results_data, indent=2)
# Insert into text widget (handle large results if necessary)
chunk_size = 20000 # Insert in chunks to avoid freezing on huge results
if len(formatted_json) > chunk_size:
# Optionally show a temporary message while inserting large data
# print(f"Displaying large result ({len(results_data)} items) in chunks.")
for i in range(0, len(formatted_json), chunk_size):
chunk = formatted_json[i:i+chunk_size]
self.results_text.insert(tk.END, chunk)
# Periodically update UI to prevent freezing
if i % (chunk_size * 10) == 0: # Update every 10 chunks
self.update_idletasks()
else:
self.results_text.insert(tk.END, formatted_json)
except TypeError as te:
error_msg = f"Error formatting results: {te}\n\nData might not be JSON serializable.\nProblematic data structure: {str(results_data)[:500]}..."
print(error_msg)
self.results_text.insert(tk.END, error_msg)
messagebox.showerror("Result Display Error", "Data could not be formatted as JSON. See logs for details.")
except Exception as e:
error_msg = f"Unexpected error displaying results: {e}"
print(error_msg)
self.results_text.insert(tk.END, error_msg)
messagebox.showerror("Result Display Error", f"An unexpected error occurred: {e}")
finally:
self.results_text.configure(state='disabled') # Disable editing
def get_results(self):
"""Get the current text content from the results widget."""
return self.results_text.get(1.0, tk.END).strip()
def clear(self):
"""Clear the results text widget."""
self.results_text.configure(state='normal')
self.results_text.delete(1.0, tk.END)
self.results_text.configure(state='disabled')
def copy_to_clipboard(self):
"""Copy the results text content to the clipboard."""
text = self.get_results()
if not text or text == "No elements found or processed matching the criteria.":
messagebox.showinfo("Info", "No results content to copy.", parent=self)
return
try:
self.clipboard_clear()
self.clipboard_append(text)
messagebox.showinfo("Success", "Results copied to clipboard!", parent=self)
except tk.TclError as e:
# Handle potential clipboard access errors (common on some systems/VMs)
messagebox.showerror("Clipboard Error", f"Could not access the system clipboard.\nError: {e}", parent=self)
except Exception as e:
messagebox.showerror("Clipboard Error", f"An unexpected error occurred:\n{e}", parent=self)
def save_to_file(self):
"""Save the results text content to a file."""
text = self.get_results()
if not text or text == "No elements found or processed matching the criteria.":
messagebox.showinfo("Info", "No results content to save.", parent=self)
return
file_path = filedialog.asksaveasfilename(
parent=self,
title="Save Results As",
defaultextension=".json",
filetypes=[("JSON files", "*.json"), ("Text files", "*.txt"), ("All files", "*.*")]
)
if file_path:
try:
with open(file_path, 'w', encoding='utf-8') as f:
f.write(text)
messagebox.showinfo("Success", f"Results saved to\n{file_path}", parent=self)
except Exception as e:
messagebox.showerror("Save Error", f"Error saving results to file:\n{str(e)}", parent=self)
# Example usage (for testing)
if __name__ == '__main__':
root = tk.Tk()
root.title("Results Frame Test")
root.geometry("600x400")
# Style for testing
style = ttk.Style()
style.theme_use('clam')
style.configure('TFrame', background='#f0f0f0')
style.configure('TLabel', background='#f0f0f0')
# Create and pack the results frame
results_frame = ResultsFrame(root)
results_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# Test data
test_data = [
{"id": "rect1", "type": "rectangle", "x": 10, "y": 20, "width": 50, "height": 30},
{"id": "circle1", "type": "circle", "cx": 100, "cy": 100, "r": 40}
]
# Test buttons
def show_results():
results_frame.set_results(test_data)
ttk.Button(root, text="Show Test Results", command=show_results).pack(pady=5)
root.mainloop()

97
gui/scada_frame.py Normal file
View File

@ -0,0 +1,97 @@
import tkinter as tk
from tkinter import ttk
class ScadaFrame(ttk.Frame):
"""Frame for configuring SCADA project settings."""
def __init__(self, parent, **kwargs):
super().__init__(parent, padding="10", **kwargs)
self.columnconfigure(1, weight=1)
self.columnconfigure(3, weight=1)
self.settings = {}
# Define settings fields and their labels/types
# (Label, key, row, column, columnspan, type ['entry', 'spinbox'])
self.field_definitions = [
("Project Title:", 'project_title', 0, 0, 1, 'entry'),
# ("Parent Project:", 'parent_project', 0, 2, 1, 'entry'), # Removed parent project field
("Ignition Base Dir:", 'ignition_base_dir', 0, 2, 1, 'entry'), # Added
("View Name:", 'view_name', 1, 0, 1, 'entry'),
("View Path (Optional):", 'view_path', 1, 2, 1, 'entry'),
("Background SVG URL (Optional):", 'svg_url', 2, 0, 3, 'entry'),
("Image Width:", 'image_width', 3, 0, 1, 'spinbox'),
("Image Height:", 'image_height', 3, 2, 1, 'spinbox'),
("Default View Width:", 'default_width', 4, 0, 1, 'spinbox'),
("Default View Height:", 'default_height', 4, 2, 1, 'spinbox'),
]
# Create label and entry/spinbox widgets for each setting
self.settings_vars = {}
for label_text, key, r, c, span, field_type in self.field_definitions:
label = ttk.Label(self, text=label_text)
label.grid(row=r, column=c, padx=5, pady=5, sticky="w")
var = tk.StringVar()
self.settings_vars[key] = var
if field_type == 'spinbox':
widget = ttk.Spinbox(self, from_=0, to=10000, textvariable=var, width=10)
else: # Default to entry
widget = ttk.Entry(self, textvariable=var)
widget.grid(row=r, column=c + 1, columnspan=span, padx=5, pady=5, sticky="ew")
def get_settings(self):
"""Get the current settings from the input fields."""
settings = {key: var.get() for key, var in self.settings_vars.items()}
# Add back parent_project as empty string for potential compatibility, or handle upstream?
# For direct creation, it's not used by ScadaViewCreator, so omitting it is cleaner.
# settings['parent_project'] = '' # No longer needed
return settings
def set_settings(self, settings_dict):
"""Set the input fields based on a dictionary."""
for key, var in self.settings_vars.items():
var.set(settings_dict.get(key, ''))
# Handle parent_project if it exists in the dict (e.g., from old config)
# No need to set parent_project_var as the field is removed
# Example usage (for testing)
if __name__ == '__main__':
root = tk.Tk()
root.title("SCADA Frame Test")
root.geometry("600x400")
# Style for testing
style = ttk.Style()
style.theme_use('clam')
style.configure('TFrame', background='#f0f0f0')
style.configure('TLabel', background='#f0f0f0')
# Create and pack the SCADA frame
scada_frame = ScadaFrame(root)
scada_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# Test buttons
def get_settings():
settings = scada_frame.get_settings()
print("Current settings:", settings)
def set_settings():
test_settings = {
'project_title': 'Test Project',
'parent_project': 'com.inductiveautomation.perspective',
'view_name': 'TestView',
'view_path': '',
'svg_url': 'http://example.com/test.svg',
'image_width': '800',
'image_height': '600',
'default_width': '14',
'default_height': '14'
}
scada_frame.set_settings(test_settings)
ttk.Button(root, text="Get Settings", command=get_settings).pack(pady=5)
ttk.Button(root, text="Set Settings", command=set_settings).pack(pady=5)
root.mainloop()

67
gui/status_bar.py Normal file
View File

@ -0,0 +1,67 @@
import tkinter as tk
from tkinter import ttk
class StatusBar(ttk.Frame):
"""Status bar component for displaying application status."""
def __init__(self, parent, **kwargs):
"""
Initialize the Status Bar.
Args:
parent: The parent widget.
**kwargs: Additional arguments for ttk.Frame.
"""
super().__init__(parent, **kwargs)
self.status_var = tk.StringVar()
self._create_widgets()
def _create_widgets(self):
"""Create the widgets for the status bar."""
self.status_label = ttk.Label(
self,
textvariable=self.status_var,
anchor=tk.W,
padding=(5, 2)
)
self.status_label.pack(side=tk.LEFT, fill=tk.X, expand=True)
def set_status(self, message):
"""Set the status message.
Args:
message (str): The status message to display.
"""
self.status_var.set(message)
def clear_status(self):
"""Clear the status message."""
self.status_var.set("")
# Example usage (for testing)
if __name__ == '__main__':
root = tk.Tk()
root.title("Status Bar Test")
root.geometry("400x100")
# Style for testing
style = ttk.Style()
style.theme_use('clam')
style.configure('TFrame', background='#f0f0f0')
style.configure('TLabel', background='#f0f0f0')
# Create and pack the status bar
status_bar = StatusBar(root)
status_bar.pack(side=tk.BOTTOM, fill=tk.X)
# Test buttons
def set_status():
status_bar.set_status("Test status message")
def clear_status():
status_bar.clear_status()
ttk.Button(root, text="Set Status", command=set_status).pack(pady=5)
ttk.Button(root, text="Clear Status", command=clear_status).pack(pady=5)
root.mainloop()

100
gui/theme_manager.py Normal file
View File

@ -0,0 +1,100 @@
import tkinter as tk
from tkinter import ttk
class ThemeManager:
"""Manager for application theme configuration (defaulting to light theme)."""
def __init__(self, root):
"""
Initialize the theme manager and apply the light theme.
Args:
root: The Tkinter root window (needed for background config).
"""
self.root = root
self.style = ttk.Style()
self.apply_theme() # Directly apply light theme
def apply_theme(self):
"""Applies the light theme."""
self._apply_light_theme()
# Ensure the root window background matches the theme
try:
bg_color = self.style.lookup('.', 'background')
self.root.configure(background=bg_color)
except tk.TclError:
# Fallback if style lookup fails
self.root.configure(background='SystemButtonFace')
def _apply_light_theme(self):
"""Configure a standard light theme using system defaults where possible."""
try:
# Use a standard theme, preferring OS native look
available_themes = self.style.theme_names()
# Windows preference order
preferred_themes = ['vista', 'xpnative', 'clam', 'alt', 'default']
chosen_theme = 'default'
for theme in preferred_themes:
if theme in available_themes:
try:
self.style.theme_use(theme)
chosen_theme = theme
# print(f"Using theme: {chosen_theme}") # Debug
break
except tk.TclError:
continue
# Reset most styles to theme defaults by configuring with empty strings
# or removing specific configure calls if they were only for dark theme.
# Explicitly set base theme colors for light mode
self.style.configure('.', background='SystemButtonFace', foreground='SystemWindowText')
self.style.configure('TFrame', background='SystemButtonFace')
# Explicitly set foreground AND background for visibility
self.style.configure('TLabel', foreground='SystemWindowText', background='SystemButtonFace')
# Explicitly set foreground AND background for visibility
self.style.configure('TButton', foreground='SystemWindowText', background='SystemButtonFace', padding=5)
# Reset button border color explicitly if needed, or remove if SystemButtonFace is desired
self.style.configure('TButton', bordercolor='SystemGrayText') # Example: Use a visible border
self.style.map('TButton',
background=[('active', 'SystemButtonHighlight'), ('pressed', '!focus', 'SystemButtonFace'), ('focus', 'SystemButtonHighlight')]) # Example map
# Keep Danger button distinct
self.style.configure('Danger.TButton', foreground='black', background='#cc3333', bordercolor='#aa2222', padding=5) # Enhanced danger button
self.style.map('Danger.TButton',
background=[('active', '#dd4444'), ('pressed', '#bb2222')],
foreground=[('active', 'black'), ('pressed', 'black')])
# TEntry: Use defaults. Let's ensure its colors are sensible too.
self.style.configure('TEntry', foreground='SystemWindowText', fieldbackground='SystemWindow', insertcolor='SystemWindowText', bordercolor='SystemGrayText')
# self.style.map('TEntry') # Reset map - might not be needed if configure is explicit
self.style.configure('TLabelframe', background='SystemButtonFace', bordercolor='SystemGrayText')
# Ensure label frame labels are also visible
self.style.configure('TLabelframe.Label', background='SystemButtonFace', foreground='SystemWindowText')
# TNotebook adjustments (if needed, often inherit well)
self.style.configure('TNotebook', background='SystemButtonFace', bordercolor='SystemGrayText', tabmargins=[2, 5, 2, 0])
self.style.configure('TNotebook.Tab', foreground='SystemWindowText', background='SystemButtonFace', padding=[10, 5])
self.style.map('TNotebook.Tab', background=[('selected', 'SystemButtonHighlight')]) # Example map
# Progressbar and Scrollbar adjustments
self.style.configure('Horizontal.TProgressbar', background='SystemHighlight', troughcolor='SystemButtonFace', bordercolor='SystemGrayText')
self.style.configure('TScrollbar', troughcolor='SystemButtonFace', background='SystemScrollbar', bordercolor='SystemGrayText', arrowcolor='SystemWindowText')
# self.style.map('TScrollbar') # Reset map
except Exception as e:
print(f"Error applying light theme: {e}")
def get_scrolledtext_colors(self):
"""Returns appropriate background/foreground colors for ScrolledText (light theme defaults)."""
# Always return light theme defaults now
try:
default_bg = self.root.option_get('background', 'Text')
default_fg = self.root.option_get('foreground', 'Text')
insert_bg = self.root.option_get('insertBackground', 'Text')
if not default_bg: default_bg = 'SystemWindow' # Tk default color name
if not default_fg: default_fg = 'SystemWindowText'
if not insert_bg: insert_bg = default_fg # Cursor color often matches text
return {'bg': default_bg, 'fg': default_fg, 'insertbackground': insert_bg}
except tk.TclError:
# Fallback if options can't be retrieved
return {'bg': 'white', 'fg': 'black', 'insertbackground': 'black'}

131
gui/thread_manager.py Normal file
View File

@ -0,0 +1,131 @@
import threading
import queue
import time
class ThreadManager:
"""Manager for thread operations."""
def __init__(self, root, on_done=None, on_error=None, on_message=None):
"""
Initialize the thread manager.
Args:
root: The Tkinter root window.
on_done: Callback function for when a thread completes.
on_error: Callback function for when a thread encounters an error.
on_message: Callback function for thread messages.
"""
self.root = root
self.queue = queue.Queue()
self.on_done = on_done
self.on_error = on_error
self.on_message = on_message
self._setup_queue_check()
def _setup_queue_check(self):
"""Set up the queue check for thread status updates."""
self._check_queue()
def _check_queue(self):
"""Check the queue for thread status updates."""
try:
while True:
message = self.queue.get_nowait()
if message == "DONE":
if self.on_done:
self.on_done()
elif message == "ERROR":
if self.on_error:
self.on_error("An error occurred during processing.")
else:
if self.on_message:
self.on_message(message)
except queue.Empty:
pass
finally:
self.root.after(100, self._check_queue)
def start_thread(self, target, args=(), kwargs=None):
"""Start a new thread.
Args:
target: The target function to run in the thread.
args: Arguments to pass to the target function.
kwargs: Keyword arguments to pass to the target function.
Returns:
threading.Thread: The started thread.
"""
if kwargs is None:
kwargs = {}
thread = threading.Thread(
target=self._thread_wrapper,
args=(target, args, kwargs),
daemon=True
)
thread.start()
return thread
def _thread_wrapper(self, target, args, kwargs):
"""Wrapper for thread execution to handle errors and messages."""
try:
result = target(*args, **kwargs)
self.queue.put("DONE")
return result
except Exception as e:
self.queue.put("ERROR")
if self.on_error:
self.on_error(str(e))
raise
def put_message(self, message):
"""Put a message in the queue.
Args:
message: The message to put in the queue.
"""
self.queue.put(message)
# Example usage (for testing)
if __name__ == '__main__':
import tkinter as tk
from tkinter import ttk, messagebox
root = tk.Tk()
root.title("Thread Manager Test")
root.geometry("400x300")
def on_done():
print("Thread completed successfully")
messagebox.showinfo("Success", "Thread completed successfully")
def on_error(error):
print(f"Thread error: {error}")
messagebox.showerror("Error", str(error))
def on_message(message):
print(f"Thread message: {message}")
# Create thread manager
thread_manager = ThreadManager(
root,
on_done=on_done,
on_error=on_error,
on_message=on_message
)
# Test function
def test_function():
for i in range(5):
thread_manager.put_message(f"Processing step {i+1}")
time.sleep(1)
return "Test result"
# Test button
def start_test():
thread_manager.start_thread(test_function)
ttk.Button(root, text="Start Test", command=start_test).pack(pady=5)
root.mainloop()

4
processing/__init__.py Normal file
View File

@ -0,0 +1,4 @@
# SVG Processing Module
"""
This package provides tools for processing SVG files and converting them to various formats.
"""

View File

@ -0,0 +1,96 @@
import logging
logger = logging.getLogger(__name__)
def find_mapping_for_element(svg_type, label_prefix, element_mappings, debug=False):
"""Find the appropriate element mapping based on SVG type and label prefix.
Prioritizes exact prefix match over fallback (no prefix).
Args:
svg_type (str): The type of the SVG element (e.g., 'rect', 'path').
label_prefix (str): The extracted label prefix (can be empty).
element_mappings (list): The list of mapping dictionaries from config.
debug (bool): Whether to enable debug logging.
Returns:
tuple: (dict or None, str) containing the matching mapping dictionary
(or None if no match) and the match type ('exact', 'fallback', 'none').
"""
exact_match = None
fallback_match = None
match_type = 'none' # Default match type
if debug:
logger.debug(f"MAPPER: Looking for mapping: svg_type={svg_type}, label_prefix='{label_prefix}'")
logger.debug(f"MAPPER: Available mappings: {len(element_mappings)}")
# Optional: Log details of available mappings if needed
# for i, m in enumerate(element_mappings):
# logger.debug(f" Mapping #{i+1}: svg_type={m.get('svg_type')}, prefix={m.get('label_prefix')}")
# First pass: look for an exact match (case-sensitive for now, consider options)
if label_prefix:
for mapping in element_mappings:
# Ensure keys exist before accessing
map_svg_type = mapping.get('svg_type', '')
map_label_prefix = mapping.get('label_prefix', '')
if map_svg_type == svg_type and map_label_prefix == label_prefix:
exact_match = mapping
if debug:
logger.debug(f"MAPPER: Found exact prefix match: {mapping}")
break
# Second pass: look for a fallback match (empty prefix for the same svg_type)
if not exact_match: # Only look for fallback if no exact match was found
for mapping in element_mappings:
map_svg_type = mapping.get('svg_type', '')
map_label_prefix = mapping.get('label_prefix', '')
if map_svg_type == svg_type and not map_label_prefix:
fallback_match = mapping
if debug and not exact_match: # Log fallback only if no exact match was found
logger.debug(f"MAPPER: Found fallback match (no prefix): {mapping}")
break # Found the fallback for this type, no need to check further
# Determine final mapping and match type
selected_mapping = None
if exact_match:
selected_mapping = exact_match
match_type = 'exact'
elif fallback_match:
selected_mapping = fallback_match
match_type = 'fallback'
# else: match_type remains 'none'
if debug and not selected_mapping:
logger.debug(f"MAPPER: No mapping found for svg_type={svg_type}, label_prefix='{label_prefix}'")
return selected_mapping, match_type
# --- Potentially deprecated functions below, kept for reference ---
# These might be replaced by directly using find_mapping_for_element
# and getting the specific property (e.g., 'element_type') from the result.
def get_element_type_for_svg_type_and_label(svg_type, label_prefix, element_mappings, default_type="ia.display.view"):
"""Get the appropriate element type for an SVG type and label.
Uses find_mapping_for_element internally.
"""
# Use _ to ignore the match_type returned by find_mapping_for_element
mapping, _ = find_mapping_for_element(svg_type, label_prefix, element_mappings)
if mapping:
return mapping.get('element_type', default_type)
return default_type
def get_element_type_for_svg_type(svg_type, element_type_mapping, default_type="ia.display.view"):
"""
(Potentially deprecated) Determine the element type based *only* on SVG element type.
This logic seems superseded by the prefix-based mapping system.
Kept for reference if needed for older config formats or specific use cases.
"""
# This function relies on a different structure in custom_options ('element_type_mapping')
# which might be outdated compared to the list-based 'element_mappings'.
logger.warning("get_element_type_for_svg_type might be deprecated, relying on potentially old config format.")
if svg_type in element_type_mapping and element_type_mapping[svg_type]:
return element_type_mapping[svg_type]
return default_type

View File

@ -0,0 +1,254 @@
import logging
import re
import numpy as np
import math
from typing import Dict, Any, Tuple, Optional
# Group inheritance behavior:
# - Group prefixes always override individual element prefixes
# - Group suffixes always override individual element suffixes
# - This ensures consistent behavior for all elements within a group
# Import necessary functions/modules
from . import svg_math
from . import svg_utils
from . import json_builder
from . import element_mapper
from .geometry_extractor import GeometryExtractor, get_element_geometry, DEFAULT_GEOMETRY
from .prefix_resolver import PrefixResolver
from .position_utils import extract_position_from_attributes
logger = logging.getLogger(__name__)
def process_element(element, element_count, svg_type, svg_dimensions, custom_options,
debug=False, group_prefix=None, group_suffix=None, center_mode='bbox') -> Dict[str, Any]:
"""Process a single SVG element and return its JSON representation.
Args:
element: The SVG element
element_count: A counter for this element type
svg_type: The tag name (e.g., 'rect')
svg_dimensions: (width, height) of the parent SVG
custom_options: Configuration options including mappings
debug: Enable debug logging
group_prefix: Prefix inherited from a parent group
group_suffix: Suffix inherited from a parent group
center_mode: How to calculate the center for paths ('bbox' or 'centroid')
Returns:
dict: The JSON representation of the element, or default on error
"""
try:
debug_buffer = []
if debug:
logger.debug(f"Processing {svg_type} #{element_count} (Group context: prefix='{group_prefix}', suffix='{group_suffix}')")
debug_buffer.append(f"Processing {svg_type} #{element_count}")
# --- Get element name and identifiers ---
element_id = element.getAttribute('id') or ""
element_label = element.getAttribute('inkscape:label') or element_id
element_name = element_label or f"{svg_type}{element_count}"
original_name = element_name
# --- Mapping Logic ---
element_mappings = custom_options.get('element_mappings', [])
element_candidate_prefix = PrefixResolver.get_element_prefix(element_label)
if debug and element_candidate_prefix:
logger.debug(f"Element '{element_label}' has candidate prefix: '{element_candidate_prefix}'")
# Determine which prefix to use for mapping lookup
prefix_to_find, prefix_source = PrefixResolver.determine_prefix_to_use(
element_candidate_prefix, group_prefix, element_mappings, svg_type
)
if debug:
if prefix_source == "element":
logger.debug(f"Using element's own prefix '{prefix_to_find}' (no group prefix override)")
elif prefix_source == "group":
logger.debug(f"Group prefix '{prefix_to_find}' overrides element prefix '{element_candidate_prefix}'")
else:
logger.debug(f"No valid prefix found, will look for fallback mapping")
# Find the appropriate mapping
mapping_to_use, match_type = element_mapper.find_mapping_for_element(
svg_type, prefix_to_find, element_mappings, debug
)
# Determine label prefix for JSON meta
label_prefix = ""
has_prefix_mapping = False
if prefix_source == "element" and mapping_to_use:
label_prefix = mapping_to_use.get('label_prefix', '')
has_prefix_mapping = True
elif prefix_source == "group" and mapping_to_use:
label_prefix = group_prefix
has_prefix_mapping = True
if debug:
logger.debug(f"Element '{element_label}' inherited mapping via group prefix '{prefix_to_find}'.")
if debug:
logger.debug(f"Mapping result: type='{match_type}', mapping={mapping_to_use}")
# --- Geometry and Transformation ---
geometry = get_element_geometry(element, svg_type, center_mode)
orig_center_x = geometry['center_x']
orig_center_y = geometry['center_y']
geom_width = geometry['width']
geom_height = geometry['height']
logger.debug(f"Geometric Center=({orig_center_x:.2f}, {orig_center_y:.2f}), Size=({geom_width:.2f}x{geom_height:.2f})")
# Get transformation matrix and apply it
transform_matrix = svg_math.get_combined_transform_matrix(element)
logger.debug(f"Transform matrix:\n{transform_matrix}")
transformed_center_x, transformed_center_y = svg_math.apply_transform(
(orig_center_x, orig_center_y), transform_matrix
)
logger.debug(f"Transformed Center=({transformed_center_x:.2f}, {transformed_center_y:.2f})")
# Extract rotation angle
rotation_angle = svg_math.extract_rotation_from_transform(element)
# --- Determine Final Size ---
# Start with mapped values, then fall back to geometric size
element_width = mapping_to_use.get('width') if mapping_to_use else None
element_height = mapping_to_use.get('height') if mapping_to_use else None
if element_width is not None or element_height is not None:
logger.debug(f"Using dimensions from {match_type} mapping: W={element_width}, H={element_height}")
# Use geometric size if not specified in mapping
if element_width is None:
element_width = geom_width
if debug:
logger.debug(f"Using geometric width as fallback: {element_width:.2f}")
if element_height is None:
element_height = geom_height
if debug:
logger.debug(f"Using geometric height as fallback: {element_height:.2f}")
# Ensure final dimensions are non-zero
element_width = max(element_width, 1.0)
element_height = max(element_height, 1.0)
logger.debug(f"Final dimensions: {element_width:.2f}x{element_height:.2f}")
# --- Calculate Final Position ---
# Calculate top-left from transformed center and final size
final_x = transformed_center_x - element_width / 2
final_y = transformed_center_y - element_height / 2
logger.debug(f"Calculated initial centered position: ({final_x:.2f}, {final_y:.2f})")
# Check for suspicious default position (-10,-10)
if abs(final_x + 10.0) < 1e-6 and abs(final_y + 10.0) < 1e-6:
logger.warning(f"Position is suspiciously close to (-10,-10) for {element_name}.")
logger.debug(f"SVG type={svg_type}, element_id={element_id}")
logger.debug(f"Original geometry: {geometry}")
logger.debug(f"Transform attribute: {element.getAttribute('transform')}")
if abs(final_x + 10.0) < 1e-9 and abs(final_y + 10.0) < 1e-9:
# Try to use the original center coordinates if they're valid
if abs(orig_center_x) > 1e-6 or abs(orig_center_y) > 1e-6:
logger.info(f"Overriding suspicious position with original center")
final_x = orig_center_x - element_width / 2
final_y = orig_center_y - element_height / 2
else:
# Try to extract position from element attributes
extracted_x, extracted_y = extract_position_from_attributes(element, svg_type)
if extracted_x is not None and extracted_y is not None:
logger.info(f"Overriding suspicious position with attribute-based position")
final_x = extracted_x
final_y = extracted_y
# Apply offsets from mapping
x_offset = mapping_to_use.get('x_offset', 0) if mapping_to_use else 0
y_offset = mapping_to_use.get('y_offset', 0) if mapping_to_use else 0
if x_offset != 0 or y_offset != 0:
final_x += x_offset
final_y += y_offset
if debug:
logger.debug(f"Applied offsets: x={x_offset}, y={y_offset} -> ({final_x}, {final_y})")
# --- Suffix Handling (Rotation Override) ---
suffix = None
suffix_source = None
# First check group suffix since it should take precedence
if group_suffix:
suffix = group_suffix
suffix_source = "group"
if debug:
logger.debug(f"Using group suffix '{suffix}' for element {element_name}")
# Only check element suffix if no group suffix exists
elif element_name and len(element_name) >= 2:
last_char = element_name[-1].lower()
if last_char in ['r', 'd', 'l', 'u']:
suffix = last_char
suffix_source = "element"
if debug:
logger.debug(f"Using element's own suffix '{suffix}'")
# Apply rotation override if a suffix was found
if suffix:
original_rotation = rotation_angle
rotation_map = {'r': 0, 'd': 90, 'l': 180, 'u': 270}
rotation_angle = rotation_map.get(suffix, rotation_angle)
if debug:
logger.debug(f"SUFFIX ROTATION OVERRIDE ({suffix_source}): '{suffix}' "
f"changed {original_rotation:.1f}deg to {rotation_angle}deg")
if debug:
logger.debug(f"Final state: Pos=({final_x:.2f}, {final_y:.2f}), "
f"Size=({element_width:.2f}x{element_height:.2f}), "
f"Rot={rotation_angle:.1f}deg, Prefix='{label_prefix}', Suffix={suffix}")
# --- Name Cleaning ---
cleaned_name = svg_utils.clean_element_name(
element_name,
label_prefix if has_prefix_mapping else None,
suffix,
has_prefix_mapping,
mapping_to_use
)
if debug and cleaned_name != element_name:
logger.debug(f"Cleaned name: '{element_name}' -> '{cleaned_name}'")
element_name = cleaned_name
# --- JSON Construction ---
return json_builder.create_element_json(
element_name=element_name,
element_id=element_id,
element_label=element_label,
element_count=element_count,
x=final_x,
y=final_y,
svg_type=svg_type,
label_prefix=label_prefix,
rotation_angle=rotation_angle,
element_width=element_width,
element_height=element_height,
x_offset=x_offset,
y_offset=y_offset,
original_name=original_name,
debug_buffer=debug_buffer,
has_prefix_mapping=has_prefix_mapping,
custom_options=custom_options,
debug=debug
)
except Exception as e:
logger.error(f"Failed processing {svg_type} #{element_count}: {str(e)}")
import traceback
traceback.print_exc()
return svg_utils.create_default_element(element_count, svg_type, str(e))
# Placeholder for process_element function (to be moved next)
# def process_element(...):
# pass

View File

@ -0,0 +1,190 @@
import logging
import math
from typing import Dict, Optional
try:
from svg.path import parse_path, Path
except ImportError:
Path = None
logger = logging.getLogger(__name__)
# Default values to use when calculations fail
DEFAULT_SIZE = 10.0
DEFAULT_POSITION = 0.0
DEFAULT_GEOMETRY = {
'center_x': DEFAULT_POSITION,
'center_y': DEFAULT_POSITION,
'width': DEFAULT_SIZE,
'height': DEFAULT_SIZE
}
class GeometryExtractor:
"""Extract geometry based on SVG element type"""
@staticmethod
def extract_rect(element) -> Dict[str, float]:
"""Extract geometry for a rectangle"""
x = float(element.getAttribute('x') or 0)
y = float(element.getAttribute('y') or 0)
width = float(element.getAttribute('width') or 0)
height = float(element.getAttribute('height') or 0)
return {
'center_x': x + width / 2,
'center_y': y + height / 2,
'width': width,
'height': height
}
@staticmethod
def extract_circle(element) -> Dict[str, float]:
"""Extract geometry for a circle"""
cx = float(element.getAttribute('cx') or 0)
cy = float(element.getAttribute('cy') or 0)
r = float(element.getAttribute('r') or 0)
return {
'center_x': cx,
'center_y': cy,
'width': r * 2,
'height': r * 2
}
@staticmethod
def extract_ellipse(element) -> Dict[str, float]:
"""Extract geometry for an ellipse"""
cx = float(element.getAttribute('cx') or 0)
cy = float(element.getAttribute('cy') or 0)
rx = float(element.getAttribute('rx') or 0)
ry = float(element.getAttribute('ry') or 0)
return {
'center_x': cx,
'center_y': cy,
'width': rx * 2,
'height': ry * 2
}
@staticmethod
def extract_line(element) -> Dict[str, float]:
"""Extract geometry for a line"""
x1 = float(element.getAttribute('x1') or 0)
y1 = float(element.getAttribute('y1') or 0)
x2 = float(element.getAttribute('x2') or 0)
y2 = float(element.getAttribute('y2') or 0)
return {
'center_x': (x1 + x2) / 2,
'center_y': (y1 + y2) / 2,
'width': abs(x2 - x1),
'height': abs(y2 - y1)
}
@staticmethod
def extract_path(element, center_mode='bbox') -> Dict[str, float]:
"""Extract geometry for a path"""
if Path is None:
logger.warning("svg.path library not found. Cannot calculate accurate path bounding box.")
return DEFAULT_GEOMETRY.copy()
d_attr = element.getAttribute('d')
if not d_attr:
logger.debug("Path element has no 'd' attribute.")
return DEFAULT_GEOMETRY.copy()
try:
parsed_path = parse_path(d_attr)
if not isinstance(parsed_path, Path) or len(parsed_path) == 0:
logger.warning(f"Parsed path is empty or invalid: {d_attr[:50]}...")
return DEFAULT_GEOMETRY.copy()
# Calculate bounding box
xmin, xmax = math.inf, -math.inf
ymin, ymax = math.inf, -math.inf
for i, segment in enumerate(parsed_path):
if not hasattr(segment, 'start') or not hasattr(segment, 'end'):
logger.warning(f"Segment {i} of type {type(segment)} lacks 'start' or 'end' attributes. Skipping.")
continue
# Process start and end points
points = [(segment.start.real, segment.start.imag),
(segment.end.real, segment.end.imag)]
# Add control points if present
if hasattr(segment, 'control1'):
points.append((segment.control1.real, segment.control1.imag))
if hasattr(segment, 'control2'):
points.append((segment.control2.real, segment.control2.imag))
elif hasattr(segment, 'control'):
points.append((segment.control.real, segment.control.imag))
# Update bounds
for x, y in points:
xmin = min(xmin, x)
xmax = max(xmax, x)
ymin = min(ymin, y)
ymax = max(ymax, y)
# Check if bounds were updated
if not all(map(math.isfinite, [xmin, xmax, ymin, ymax])):
logger.warning(f"Could not calculate finite bounding box for path: {d_attr[:50]}...")
return DEFAULT_GEOMETRY.copy()
width = max(xmax - xmin, 1.0) # Ensure non-zero width
height = max(ymax - ymin, 1.0) # Ensure non-zero height
# Calculate center based on mode
if center_mode == 'centroid':
# Fallback to bbox center for now
logger.warning("Centroid calculation not yet implemented for path. Falling back to bbox center.")
center_x = xmin + width / 2
center_y = ymin + height / 2
else: # Default to bounding box center
center_x = xmin + width / 2
center_y = ymin + height / 2
logger.debug(f"Calculated path geom (mode={center_mode}): xmin={xmin:.2f}, xmax={xmax:.2f}, "
f"ymin={ymin:.2f}, ymax={ymax:.2f} -> W={width:.2f}, H={height:.2f}, "
f"C=({center_x:.2f}, {center_y:.2f})")
return {
'center_x': center_x,
'center_y': center_y,
'width': width,
'height': height
}
except Exception as e:
logger.error(f"Error processing path data '{d_attr[:50]}...': {e}")
return DEFAULT_GEOMETRY.copy()
def get_element_geometry(element, svg_type, center_mode='bbox') -> Dict[str, float]:
"""Extract geometry information (center, size) from an SVG element before applying transformations.
Args:
element: The SVG element
svg_type: The tag name of the element (e.g., 'rect', 'path')
center_mode: How to calculate the center ('bbox' or 'centroid')
Returns:
dict: Contains 'center_x', 'center_y', 'width', 'height' before transform
"""
extractors = {
'rect': GeometryExtractor.extract_rect,
'circle': GeometryExtractor.extract_circle,
'ellipse': GeometryExtractor.extract_ellipse,
'line': GeometryExtractor.extract_line,
'path': lambda elem: GeometryExtractor.extract_path(elem, center_mode)
}
try:
if svg_type in extractors:
return extractors[svg_type](element)
else:
logger.warning(f"Unsupported element type for geometry extraction: {svg_type}")
return DEFAULT_GEOMETRY.copy()
except (ValueError, TypeError, NameError) as e:
logger.error(f"Error parsing geometry attributes for {svg_type}: {e}")
return DEFAULT_GEOMETRY.copy()

View File

@ -0,0 +1,441 @@
import xml.etree.ElementTree as ET
import logging
import sys
from xml.dom import minidom
# Import modules from the package
from . import svg_math
from . import svg_utils
from . import svg_parser
from . import element_processor
# Logging configuration
logger = logging.getLogger('svg_transformer')
def configure_logging(debug=False):
"""Configure logging for the SVG transformer."""
level = logging.DEBUG if debug else logging.INFO
logger.setLevel(level)
# Clear existing handlers to avoid duplicates
for handler in logger.handlers[:]:
logger.removeHandler(handler)
# Setup handlers for both stdout and stderr
stdout_handler = logging.StreamHandler(sys.stdout)
stderr_handler = logging.StreamHandler()
formatter = logging.Formatter('%(levelname)s: %(message)s')
stdout_handler.setFormatter(formatter)
stderr_handler.setFormatter(formatter)
logger.addHandler(stdout_handler)
logger.addHandler(stderr_handler)
return logger
def log_message(message, level=logging.INFO):
"""Log a message to both the logger and stdout."""
logger.log(level, message)
# Print directly to stdout and flush for UI feedback
print(message)
sys.stdout.flush()
class SVGTransformer:
"""Class to handle SVG parsing and transformation of SVG elements."""
# SVG element types supported for processing
ELEMENT_TYPES = [
('rect', 'rectangles'),
('circle', 'circles'),
('ellipse', 'ellipses'),
('line', 'lines'),
('polyline', 'polylines'),
('polygon', 'polygons'),
('path', 'paths')
]
def __init__(self, svg_path, custom_options=None, debug=False):
"""Initialize with the path to the SVG file and optional custom options.
Args:
svg_path (str): Path to the SVG file.
custom_options (dict, optional): Custom processing options.
debug (bool, optional): Enable debug logging.
Raises:
ValueError: If the SVG file cannot be loaded or parsed.
"""
self.svg_path = svg_path
self.custom_options = custom_options or {}
self.debug = debug
# Configure logging
configure_logging(debug)
# Load the SVG document
self.doc, self.svg_element = svg_parser.load_svg(svg_path)
# Handle parsing failures
if not self.doc or not self.svg_element:
raise ValueError(f"Failed to load or parse SVG: {svg_path}")
# Log configuration details when in debug mode
if self.debug:
self._log_configuration()
def _log_configuration(self):
"""Log configuration details for debugging."""
log_message("DEBUG: Loaded custom_options:", logging.DEBUG)
if 'element_mappings' in self.custom_options:
mappings = self.custom_options['element_mappings']
log_message(f"DEBUG: Found {len(mappings)} element mappings", logging.DEBUG)
for i, mapping in enumerate(mappings):
log_message(
f"DEBUG: Mapping #{i+1}: "
f"svg_type='{mapping.get('svg_type')}', "
f"label_prefix='{mapping.get('label_prefix')}', "
f"props_path='{mapping.get('props_path')}'",
logging.DEBUG
)
else:
log_message("DEBUG: No element_mappings found in custom_options", logging.DEBUG)
def get_svg_dimensions(self):
"""Get the dimensions of the SVG document.
Returns:
tuple: The width and height of the SVG.
"""
return svg_parser.get_svg_dimensions(self.svg_element)
def process_svg(self, center_mode='bbox'):
"""Process SVG file and extract elements with calculated centers.
Args:
center_mode (str): How to calculate centers ('bbox' or 'centroid').
Returns:
list: Processed elements in JSON format.
"""
results = []
svg_dims = self.get_svg_dimensions()
# Process statistics
stats = {
'total_elements': 0,
'processed_elements': 0,
'group_count': 0
}
# Process top-level elements
self._process_top_level_elements(results, svg_dims, center_mode, stats)
# Process groups
self._process_groups(results, svg_dims, center_mode, stats)
# Log final processing statistics
final_msg = (
f"Total: Processed {stats['total_elements']} elements "
f"({stats['group_count']} top-level groups), "
f"converted {stats['processed_elements']}"
)
log_message(final_msg)
return results
def _process_top_level_elements(self, results, svg_dims, center_mode, stats):
"""Process all top-level SVG elements (direct children of the root svg).
Args:
results (list): List to append processed elements to.
svg_dims (tuple): SVG dimensions as (width, height).
center_mode (str): Center calculation mode.
stats (dict): Statistics dictionary to update.
"""
for svg_type, plural in self.ELEMENT_TYPES:
elements = self.doc.getElementsByTagName(svg_type)
count = 0
successful_conversions = 0
for element in elements:
# Process only direct children of the root SVG element
if element.parentNode == self.svg_element:
count += 1
stats['total_elements'] += 1
# Process the element
element_json = element_processor.process_element(
element, count, svg_type, svg_dims,
self.custom_options, self.debug,
center_mode=center_mode
)
# Store successfully processed elements
if element_json:
results.append(element_json)
stats['processed_elements'] += 1
successful_conversions += 1
# Log results for this element type if any were found
if count > 0:
msg = f"Processed {count} top-level {plural}, converted {successful_conversions}"
log_message(msg)
def _process_groups(self, results, svg_dims, center_mode, stats):
"""Process all group elements in the SVG.
Args:
results (list): List to append processed elements to.
svg_dims (tuple): SVG dimensions as (width, height).
center_mode (str): Center calculation mode.
stats (dict): Statistics dictionary to update.
"""
groups = self.doc.getElementsByTagName('g')
for group in groups:
# Process only top-level groups
if group.parentNode == self.svg_element:
stats['group_count'] += 1
group_data = self._process_group(
group,
stats['group_count'],
svg_dims,
center_mode
)
if group_data:
# Add group elements to results
results.extend(group_data['elements'])
stats['total_elements'] += group_data['count']
stats['processed_elements'] += group_data['converted']
# Log group processing results
log_message(
f"Processed group #{stats['group_count']} ({group_data['id']}): "
f"{group_data['count']} elements, converted {group_data['converted']}"
)
def _process_group(self, group, group_index, svg_dims, center_mode='bbox'):
"""Process a group element and all its children.
Args:
group (minidom.Element): The group element.
group_index (int): Index of this group.
svg_dims (tuple): SVG dimensions as (width, height).
center_mode (str): Center calculation mode.
Returns:
dict: Contains processed elements and statistics.
"""
# Get group attributes and context
group_id = group.getAttribute('id') or f"group{group_index}"
group_context = self._extract_group_context(group, group_index)
# Log group processing start if in debug mode
if self.debug:
self._log_group_processing_start(group_index, group_id, group_context)
# Initialize processing statistics
results = {
'elements': [],
'id': group_id,
'count': 0,
'converted': 0
}
# Track element counts by type within this group
element_types = [t[0] for t in self.ELEMENT_TYPES]
element_count_by_type = {t: 0 for t in element_types}
nested_group_count = 0
# Process all child nodes in the group
for child in group.childNodes:
if child.nodeType != child.ELEMENT_NODE:
continue
svg_type = child.tagName
# Process SVG element
if svg_type in element_types:
results['count'] += 1
element_count_by_type[svg_type] += 1
# Process this element
element_result = self._process_group_element(
child,
svg_type,
element_count_by_type[svg_type],
svg_dims,
group_context,
center_mode
)
if element_result:
results['elements'].append(element_result)
results['converted'] += 1
# Process nested group with recursive call
elif svg_type == 'g':
nested_group_result = self._process_nested_group(
child,
group_index,
nested_group_count + 1,
svg_dims,
center_mode
)
if nested_group_result:
results['elements'].extend(nested_group_result['elements'])
results['count'] += nested_group_result['count']
results['converted'] += nested_group_result['converted']
nested_group_count += 1
return results
def _extract_group_context(self, group, group_index):
"""Extract prefix and suffix information from group attributes.
Args:
group (minidom.Element): The group element.
group_index (int): Index of this group.
Returns:
dict: Group context including prefix and suffix.
"""
group_label = group.getAttribute('inkscape:label') or ""
context = {
'prefix': None,
'suffix': None
}
# Extract prefix from label (before underscore or whole label)
if group_label and "_" in group_label:
context['prefix'] = group_label.split("_")[0]
elif group_label:
context['prefix'] = group_label
# Extract suffix (direction indicator as last character)
if group_label and len(group_label) >= 2:
last_char = group_label[-1].lower()
if last_char in ['r', 'd', 'l', 'u']:
context['suffix'] = last_char
return context
def _log_group_processing_start(self, group_index, group_id, context):
"""Log the start of group processing in debug mode.
Args:
group_index (int): Group index.
group_id (str): Group ID.
context (dict): Group context containing prefix and suffix.
"""
if context['prefix']:
log_message(
f"DEBUG: Group #{group_index} using potential prefix: '{context['prefix']}'",
logging.DEBUG
)
if context['suffix']:
log_message(
f"DEBUG: Group #{group_index} has suffix: '{context['suffix']}'",
logging.DEBUG
)
log_message(
f"PROCESSING GROUP #{group_index}: id='{group_id}', "
f"potential_prefix='{context['prefix']}', suffix='{context['suffix']}'",
logging.DEBUG
)
def _process_group_element(self, element, svg_type, type_count, svg_dims, group_context, center_mode):
"""Process a single element within a group.
Args:
element (minidom.Element): The SVG element.
svg_type (str): Type of SVG element.
type_count (int): Count of this element type within the group.
svg_dims (tuple): SVG dimensions.
group_context (dict): Group context containing prefix and suffix.
center_mode (str): Center calculation mode.
Returns:
dict: Processed element data or None if processing failed.
"""
element_label = (
element.getAttribute('inkscape:label') or
element.getAttribute('id') or
f"{svg_type}#{type_count}"
)
if self.debug:
log_message(
f"DEBUG: Processing {svg_type} #{type_count} ('{element_label}')",
logging.DEBUG
)
# Process the element
element_json = element_processor.process_element(
element,
type_count,
svg_type,
svg_dims,
self.custom_options,
self.debug,
group_prefix=group_context['prefix'],
group_suffix=group_context['suffix'],
center_mode=center_mode
)
# Log processing result in debug mode
if self.debug:
if element_json:
log_message(
f"DEBUG: ... Success: {element_json.get('meta',{}).get('name')}",
logging.DEBUG
)
else:
log_message(f"DEBUG: ... Failed: {element_label}", logging.DEBUG)
return element_json
def _process_nested_group(self, nested_group, parent_index, nested_index, svg_dims, center_mode):
"""Process a nested group within another group.
Args:
nested_group (minidom.Element): The nested group element.
parent_index (int): Parent group index.
nested_index (int): Index of this nested group within parent.
svg_dims (tuple): SVG dimensions.
center_mode (str): Center calculation mode.
Returns:
dict: Processed group data.
"""
nested_group_index = f"{parent_index}.{nested_index}"
nested_group_id = nested_group.getAttribute('id') or f"group{nested_group_index}"
if self.debug:
log_message(
f"DEBUG: Processing nested group <{nested_group_id}>",
logging.DEBUG
)
# Process the nested group recursively
nested_data = self._process_group(
nested_group,
nested_group_index,
svg_dims,
center_mode
)
# Log nested group results in debug mode
if self.debug and nested_data:
log_message(
f"DEBUG: Nested group #{nested_group_index} ({nested_data['id']}): "
f"{nested_data['count']} elements, converted {nested_data['converted']}",
logging.DEBUG
)
return nested_data

151
processing/json_builder.py Normal file
View File

@ -0,0 +1,151 @@
import logging
# Assuming svg_utils is in the same directory or path
from . import svg_utils # Use relative import
logger = logging.getLogger(__name__)
def create_element_json(element_name, element_id, element_label, element_count, x, y, svg_type, label_prefix, rotation_angle=0, element_width=None, element_height=None, x_offset=0, y_offset=0, original_name=None, debug_buffer=None, has_prefix_mapping=None, custom_options=None, debug=False):
"""Create a JSON object for an SVG element."""
# Ensure custom_options is a dictionary
custom_options = custom_options or {}
# If not provided, element_width and element_height should be retrieved from custom_options
if element_width is None:
element_width = custom_options.get('width', 10)
if debug:
logger.debug(f"Using fallback width: {element_width}")
if element_height is None:
element_height = custom_options.get('height', 10)
if debug:
logger.debug(f"Using fallback height: {element_height}")
# Store debug buffer contents if provided
debug_messages = []
if debug_buffer is not None:
debug_messages = debug_buffer.copy()
# Log all debug messages to the console for transparency
if debug:
for msg in debug_messages:
logger.debug(msg)
logger.debug("==== END DEBUG ====")
# Get the appropriate element type and props path based on prefix
exact_match = None
fallback_match = None
logger.debug(f"Looking for mapping for svg_type={svg_type}, label_prefix='{label_prefix}'")
available_mappings = custom_options.get('element_mappings', [])
logger.debug(f"Available mappings: {len(available_mappings)}")
# Debug print all available mappings
if debug:
for i, mapping in enumerate(available_mappings):
logger.debug(f" Available mapping #{i+1}: svg_type={mapping.get('svg_type', 'None')}, label_prefix='{mapping.get('label_prefix', '')}'")
if label_prefix:
for mapping in available_mappings:
if mapping.get('svg_type', '') == svg_type and mapping.get('label_prefix', '') == label_prefix:
exact_match = mapping
logger.debug(f"Found exact match: {mapping}")
break
# Then look for a fallback with no prefix
for mapping in available_mappings:
if mapping.get('svg_type', '') == svg_type and not mapping.get('label_prefix', ''):
fallback_match = mapping
if not exact_match: # Only print if we haven't found an exact match
logger.debug(f"Found fallback match: {mapping}")
break
# Use the appropriate mapping
mapping_to_use = exact_match or fallback_match
# Get element type and props path from mapping
element_type = "ia.display.view" # Default
props_path = "Symbol-Views/Equipment-Views/Status" # Default
if mapping_to_use:
element_type = mapping_to_use.get('element_type', element_type)
props_path = mapping_to_use.get('props_path', props_path)
logger.debug(f"Selected mapping: {mapping_to_use}")
logger.debug(f"Using element_type: {element_type} from {'exact match' if exact_match else 'fallback match'}")
logger.debug(f"Using props_path: {props_path} from {'exact match' if exact_match else 'fallback match'}")
else:
warning_msg = f"WARNING: No mapping found for svg_type={svg_type}, label_prefix='{label_prefix}'. Using defaults: type={element_type}, props={props_path}"
logger.warning(warning_msg)
# Only display in UI for specific types or when in debug mode to avoid flooding
if debug or svg_type in ['rect', 'path'] and not label_prefix:
svg_utils.ui_print(warning_msg, logging.WARNING)
# Preserve rotation angle as float for accuracy, just format it for output
try:
rotation_angle = float(rotation_angle)
except (ValueError, TypeError):
rotation_angle = 0
# Create metadata and meta object
meta = {
'id': element_id,
'name': element_name,
'originalName': original_name or element_name, # Preserve original name
'elementPrefix': label_prefix if label_prefix else None,
'elementNumber': element_count # Add element count for reference
}
# Add info about mapping used
if mapping_to_use:
meta['mappingUsed'] = {
'svg_type': mapping_to_use.get('svg_type'),
'label_prefix': mapping_to_use.get('label_prefix')
}
meta['mappingType'] = 'exact' if exact_match else 'fallback'
else:
meta['mappingUsed'] = None
meta['mappingType'] = 'none'
if has_prefix_mapping is not None:
meta['hasPrefixMapping'] = has_prefix_mapping
# Apply final prefix/suffix info to meta if applicable
if mapping_to_use:
final_prefix = mapping_to_use.get('final_prefix', '')
final_suffix = mapping_to_use.get('final_suffix', '')
if final_prefix:
meta['finalPrefixApplied'] = final_prefix
# If we're applying a final prefix, elementPrefix should reflect the original logical prefix
if not meta['elementPrefix']:
meta['elementPrefix'] = mapping_to_use.get('label_prefix', '') # Use the prefix from the mapping that triggered the final_prefix
if debug:
logger.debug(f"Setting elementPrefix to '{meta['elementPrefix']}' based on mapping that provided final_prefix")
if final_suffix:
meta['finalSuffixApplied'] = final_suffix
# Build the element JSON object with the simpler position structure
element_json = {
'type': element_type,
'version': 0,
'props': {
'path': props_path
},
'meta': meta,
'position': {
'x': round(x, 2), # Round coordinates for cleaner output
'y': round(y, 2),
'width': element_width,
'height': element_height
},
'custom': {}
}
# Add rotation if it's not 0 (or very close to 0)
if abs(rotation_angle) > 1e-6:
element_json['position']['rotate'] = {
'angle': f"{round(rotation_angle, 2)}deg", # Round angle
'anchor': '50% 50%'
}
return element_json

View File

@ -0,0 +1,520 @@
import tkinter as tk
from tkinter import ttk, scrolledtext
import sys
import json # Moved import to top
# Assuming RedirectText is in utils.py
from utils import RedirectText
from gui.element_mapping_frame import ElementMappingFrame
from gui.results_frame import ResultsFrame
from gui.log_frame import LogFrame
class NotebookManager(ttk.Notebook):
"""Manages the notebook tabs for the application."""
def __init__(self, app_instance, theme_manager, **kwargs):
"""
Initialize the Notebook Manager.
Args:
app_instance: The main SVGProcessorApp instance.
theme_manager: The theme manager instance.
**kwargs: Additional arguments for ttk.Notebook.
"""
super().__init__(app_instance.root, **kwargs)
self.app = app_instance # Store reference to the main app
self.theme_manager = theme_manager
self._create_tabs()
def _create_tabs(self):
"""Create the notebook tabs."""
# Element Mapping Tab
self.element_mapping_frame = ElementMappingFrame(self, save_config_callback=self._trigger_save)
self.add(self.element_mapping_frame, text='Element Mapping')
# self.theme_manager.configure_widget(self.element_mapping_frame)
# Results Tab
self.results_frame = ResultsFrame(self, export_command=self.app.create_scada_view)
self.add(self.results_frame, text='Results')
# self.theme_manager.configure_widget(self.results_frame)
# Logs Tab
self.log_frame = LogFrame(self)
self.add(self.log_frame, text='Logs')
# self.theme_manager.configure_widget(self.log_frame)
# Configure the notebook itself with the theme
# self.theme_manager.configure_widget(self)
def _trigger_save(self):
"""Placeholder or method to call the main app's save function if needed."""
# This might be called by ElementMappingFrame when changes occur.
# Check if the main app has a save method and call it.
if hasattr(self.app, '_save_config_from_ui'):
# print("NotebookManager triggering config save...") # Debug
# Schedule the save slightly later to avoid issues during trace callbacks
self.app.root.after_idle(self.app._save_config_from_ui)
else:
print("Warning: Main application has no _save_config_from_ui method to call.")
def get_element_mapping_frame(self):
"""Return the Element Mapping frame."""
return self.element_mapping_frame
def get_results_frame(self):
"""Return the Results frame."""
return self.results_frame
def get_log_frame(self):
"""Return the Log frame."""
return self.log_frame
def select_tab(self, index):
"""Select a specific tab in the notebook by index."""
if self:
try:
self.select(index)
except tk.TclError as e:
print(f"Error selecting notebook tab {index}: {e}")
def update_log(self, text):
"""Append text to the log display."""
if self.log_frame and self.log_frame.winfo_exists():
try:
self.log_frame.log_message(str(text))
except tk.TclError as e:
# print(f"Log update error: {e}") # Ignore if widget destroyed
pass
def update_results(self, elements):
"""Clear and display formatted JSON results in the results tab."""
if not self.results_frame or not self.results_frame.winfo_exists():
print("Warning: Results display widget not available.")
return
self.results_frame.set_results("")
if not elements:
self.results_frame.set_results("No elements found matching the criteria.")
return
try:
# import json # Moved to top
formatted_json = json.dumps(elements, indent=2)
# Simple insertion for now, add chunking if needed later
self.results_frame.set_results(formatted_json)
except TypeError as te:
error_msg = f"Result Display Error: Data could not be serialized to JSON.\n{te}\n\nData Preview:\n{str(elements)[:500]}..."
print(error_msg)
self.results_frame.set_results(error_msg)
except Exception as e:
error_msg = f"Unexpected error displaying results: {e}"
print(error_msg)
self.results_frame.set_results(error_msg)
def get_results_text(self):
"""Get the current text content from the results tab."""
if self.results_frame and self.results_frame.winfo_exists():
return self.results_frame.get_results().strip()
return ""
def clear_results(self):
"""Clear the results text widget."""
if self.results_frame and self.results_frame.winfo_exists():
self.results_frame.set_results("")
def start_progress(self, speed=10):
"""Start the indeterminate progress bar."""
if self.element_mapping_frame and self.element_mapping_frame.progress:
self.element_mapping_frame.progress.start(speed)
def stop_progress(self):
"""Stop the progress bar."""
if self.element_mapping_frame and self.element_mapping_frame.progress:
self.element_mapping_frame.progress.stop()
# ----------------------------------------------------------------------
# Element Mapping Tab Methods
# ----------------------------------------------------------------------
def _create_element_mapping_tab(self, parent_frame):
"""Create the content for the Element Mapping tab, including a scrollable grid."""
# Main frame for the tab content
mapping_content_frame = ttk.Frame(parent_frame)
mapping_content_frame.pack(fill=tk.BOTH, expand=True)
# --- Top Info/Controls ---
top_frame = ttk.Frame(mapping_content_frame)
top_frame.pack(fill=tk.X, pady=(5, 10))
ttk.Label(top_frame, text="Element Mapping Configuration", style="Header.TLabel").pack(side=tk.LEFT, anchor=tk.W)
# --- Scrollable Table Frame ---
table_container = ttk.Frame(mapping_content_frame)
table_container.pack(fill=tk.BOTH, expand=True)
canvas = tk.Canvas(table_container, borderwidth=0, highlightthickness=0) # No border for canvas
scrollbar = ttk.Scrollbar(table_container, orient="vertical", command=canvas.yview)
# Frame inside canvas holds the actual grid of widgets
self.mapping_frame = ttk.Frame(canvas, padding=(5, 5)) # Padding inside grid frame
canvas.configure(yscrollcommand=scrollbar.set)
canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
canvas_frame_id = canvas.create_window((0, 0), window=self.mapping_frame, anchor="nw")
# Update scroll region when the inner frame's size changes
def on_frame_configure(event=None):
canvas.configure(scrollregion=canvas.bbox("all"))
# Update the width of the inner frame when the canvas width changes
def on_canvas_configure(event):
canvas.itemconfig(canvas_frame_id, width=event.width)
self.mapping_frame.bind("<Configure>", on_frame_configure)
canvas.bind('<Configure>', on_canvas_configure)
# Bind mouse wheel scrolling to the canvas (platform specific)
self._bind_mousewheel(canvas)
# --- Grid Headers (inside self.mapping_frame) ---
headers = [
("SVG Type", 10), ("Label Prefix", 10), ("Output Type", 20),
("Props Path", 30), ("Size (WxH)", 12), ("Offset (X,Y)", 12),
("Final Prefix", 15), ("Final Suffix", 15), ("Del", 3)
]
for col, (text, _) in enumerate(headers):
header_label = ttk.Label(self.mapping_frame, text=text, font=('Helvetica', 9, 'bold'), anchor=tk.W)
header_label.grid(row=0, column=col, sticky=tk.EW, pady=(0, 5), padx=3)
# Optional: Configure column weights here if needed
if text == "Output Type": self.mapping_frame.columnconfigure(col, weight=1)
if text == "Props Path": self.mapping_frame.columnconfigure(col, weight=2)
# --- Add Button (outside scrollable area) ---
self.add_button_frame = ttk.Frame(mapping_content_frame)
self.add_button_frame.pack(fill=tk.X, pady=(10, 5)) # Below scrollable area
ttk.Button(self.add_button_frame, text=" + Add Mapping ", command=self._handle_add_mapping_click).pack(side=tk.LEFT, padx=5)
# Initialize mapping rows list here if not done in __init__
if not hasattr(self, 'mapping_rows'): self.mapping_rows = []
def _bind_mousewheel(self, widget):
"""Bind mouse wheel events for scrolling across platforms."""
if sys.platform == "linux":
widget.bind("<Button-4>", lambda e: self._on_mousewheel(e, widget, -1), add='+')
widget.bind("<Button-5>", lambda e: self._on_mousewheel(e, widget, 1), add='+')
else: # Windows and macOS
widget.bind("<MouseWheel>", lambda e: self._on_mousewheel(e, widget), add='+')
def _on_mousewheel(self, event, canvas, direction=None):
"""Handle mouse wheel scroll events for the canvas."""
if direction: # Linux Button-4/5
delta = direction
else: # Windows/macOS MouseWheel
# Determine scroll direction and magnitude (platform dependent)
if sys.platform == "darwin": # macOS may need adjustment
delta = -1 * event.delta
else: # Windows
delta = -1 * (event.delta // 120) # Windows delta is typically +/- 120
canvas.yview_scroll(delta, "units")
def _handle_add_mapping_click(self):
"""Callback for the 'Add New Mapping' button."""
original_allow_empty = self.element_mapping_frame.allow_empty_rows
self.element_mapping_frame.allow_empty_rows = True
new_index = self.add_mapping_row() # Add blank row
self.element_mapping_frame.allow_empty_rows = original_allow_empty
# Focus and scroll
if 0 <= new_index < len(self.mapping_rows):
try:
self.mapping_rows[new_index]['svg_entry'].focus_set()
except (KeyError, tk.TclError): pass
self._scroll_mapping_to_bottom()
def _scroll_mapping_to_bottom(self):
"""Scrolls the element mapping canvas to the bottom."""
self.theme_manager.update_scroll_region(self.element_mapping_frame.mapping_frame)
def add_mapping_row(self, data=None):
"""Adds a mapping row to the UI, optionally populated with data."""
if data is None: data = {}
row_index = len(self.mapping_rows) + 1 # Grid row below header
# --- Data Variables ---
row_vars = {
'svg_type': tk.StringVar(value=data.get('svg_type', '')),
'label_prefix': tk.StringVar(value=data.get('label_prefix', '')),
'element_type': tk.StringVar(value=data.get('element_type', '')),
'props_path': tk.StringVar(value=data.get('props_path', '')),
'width': tk.StringVar(value=str(data.get('width', ''))),
'height': tk.StringVar(value=str(data.get('height', ''))),
'x_offset': tk.StringVar(value=str(data.get('x_offset', ''))),
'y_offset': tk.StringVar(value=str(data.get('y_offset', ''))),
'final_prefix': tk.StringVar(value=data.get('final_prefix', '')),
'final_suffix': tk.StringVar(value=data.get('final_suffix', '')),
}
# Trigger config save on change
save_callback = self.element_mapping_frame.mapping_callbacks.get('save_config', lambda *a: None)
for var in row_vars.values():
var.trace_add("write", lambda *a, cb=save_callback: self._schedule_config_save(cb))
# --- Widgets ---
entries = {}
col = 0
pad_x = 3
pad_y = 1
entries['svg_entry'] = ttk.Entry(self.mapping_frame, textvariable=row_vars['svg_type'], width=10)
entries['svg_entry'].grid(row=row_index, column=col, sticky=tk.EW, pady=pad_y, padx=pad_x); col+=1
entries['label_prefix_entry'] = ttk.Entry(self.mapping_frame, textvariable=row_vars['label_prefix'], width=10)
entries['label_prefix_entry'].grid(row=row_index, column=col, sticky=tk.EW, pady=pad_y, padx=pad_x); col+=1
entries['element_entry'] = ttk.Entry(self.mapping_frame, textvariable=row_vars['element_type'], width=20)
entries['element_entry'].grid(row=row_index, column=col, sticky=tk.EW, pady=pad_y, padx=pad_x); col+=1
entries['props_entry'] = ttk.Entry(self.mapping_frame, textvariable=row_vars['props_path'], width=30)
entries['props_entry'].grid(row=row_index, column=col, sticky=tk.EW, pady=pad_y, padx=pad_x); col+=1
# Size Frame
size_frame = ttk.Frame(self.mapping_frame)
size_frame.grid(row=row_index, column=col, sticky=tk.EW, pady=0, padx=0); col+=1
entries['width_entry'] = ttk.Entry(size_frame, textvariable=row_vars['width'], width=4)
entries['width_entry'].pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(pad_x,1))
ttk.Label(size_frame, text="×").pack(side=tk.LEFT)
entries['height_entry'] = ttk.Entry(size_frame, textvariable=row_vars['height'], width=4)
entries['height_entry'].pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(1,pad_x))
entries['size_frame'] = size_frame
# Offset Frame
offset_frame = ttk.Frame(self.mapping_frame)
offset_frame.grid(row=row_index, column=col, sticky=tk.EW, pady=0, padx=0); col+=1
entries['x_offset_entry'] = ttk.Entry(offset_frame, textvariable=row_vars['x_offset'], width=4)
entries['x_offset_entry'].pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(pad_x,1))
ttk.Label(offset_frame, text=",").pack(side=tk.LEFT)
entries['y_offset_entry'] = ttk.Entry(offset_frame, textvariable=row_vars['y_offset'], width=4)
entries['y_offset_entry'].pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(1,pad_x))
entries['offset_frame'] = offset_frame
# Final Prefix/Suffix
entries['final_prefix_entry'] = ttk.Entry(self.mapping_frame, textvariable=row_vars['final_prefix'], width=15)
entries['final_prefix_entry'].grid(row=row_index, column=col, sticky=tk.EW, pady=pad_y, padx=pad_x); col+=1
entries['final_suffix_entry'] = ttk.Entry(self.mapping_frame, textvariable=row_vars['final_suffix'], width=15)
entries['final_suffix_entry'].grid(row=row_index, column=col, sticky=tk.EW, pady=pad_y, padx=pad_x); col+=1
# Remove Button
current_list_index = len(self.mapping_rows)
remove_button = ttk.Button(self.mapping_frame, text="×", width=2, style="Danger.TButton",
command=lambda idx=current_list_index: self._handle_remove_mapping_click(idx))
remove_button.grid(row=row_index, column=col, sticky=tk.W, pady=0, padx=pad_x); col+=1
entries['remove_button'] = remove_button
# Store row data
row_data_dict = {**row_vars, **entries, 'grid_row': row_index}
self.mapping_rows.append(row_data_dict)
# Schedule save if needed
if self.element_mapping_frame.initialized_mappings:
if self.element_mapping_frame.skip_next_save:
self.element_mapping_frame.skip_next_save = False
else:
self._schedule_config_save(save_callback, delay=100)
# Update canvas scroll region
self._update_mapping_scrollregion()
return current_list_index
def _handle_remove_mapping_click(self, index_in_list):
"""Callback to remove a specific mapping row."""
self.remove_mapping_row(index_in_list)
save_callback = self.element_mapping_frame.mapping_callbacks.get('save_config', lambda: None)
self._schedule_config_save(save_callback, delay=100) # Save quickly after removal
def remove_mapping_row(self, index_in_list):
"""Removes the mapping row widgets and data at the given list index."""
if not (0 <= index_in_list < len(self.mapping_rows)):
return
row_to_remove = self.mapping_rows[index_in_list]
# Destroy Widgets
widgets_to_destroy = [
'svg_entry', 'label_prefix_entry', 'element_entry', 'props_entry',
'width_entry', 'height_entry', 'x_offset_entry', 'y_offset_entry',
'final_prefix_entry', 'final_suffix_entry', 'remove_button',
'size_frame', 'offset_frame'
]
for key in widgets_to_destroy:
widget = row_to_remove.get(key)
if widget and isinstance(widget, tk.Widget):
try: widget.destroy()
except tk.TclError: pass
# Remove data from list
self.mapping_rows.pop(index_in_list)
# Re-grid remaining rows and update commands
self._reindex_mapping_rows()
# Handle empty list case
if not self.mapping_rows:
self.element_mapping_frame.skip_next_save = True
self.add_mapping_row() # Add default row
# Update scroll region
self._update_mapping_scrollregion()
def _reindex_mapping_rows(self):
"""Update grid rows and remove button commands after list changes."""
for i, row_data in enumerate(self.mapping_rows):
new_grid_row = i + 1
col = 0
pad_x = 3
pad_y = 1
# Grid widgets (ensure they exist)
row_data.get('svg_entry', tk.Widget()).grid(row=new_grid_row, column=col, sticky=tk.EW, pady=pad_y, padx=pad_x); col+=1
row_data.get('label_prefix_entry', tk.Widget()).grid(row=new_grid_row, column=col, sticky=tk.EW, pady=pad_y, padx=pad_x); col+=1
row_data.get('element_entry', tk.Widget()).grid(row=new_grid_row, column=col, sticky=tk.EW, pady=pad_y, padx=pad_x); col+=1
row_data.get('props_entry', tk.Widget()).grid(row=new_grid_row, column=col, sticky=tk.EW, pady=pad_y, padx=pad_x); col+=1
row_data.get('size_frame', tk.Widget()).grid(row=new_grid_row, column=col, sticky=tk.EW, pady=0, padx=0); col+=1
row_data.get('offset_frame', tk.Widget()).grid(row=new_grid_row, column=col, sticky=tk.EW, pady=0, padx=0); col+=1
row_data.get('final_prefix_entry', tk.Widget()).grid(row=new_grid_row, column=col, sticky=tk.EW, pady=pad_y, padx=pad_x); col+=1
row_data.get('final_suffix_entry', tk.Widget()).grid(row=new_grid_row, column=col, sticky=tk.EW, pady=pad_y, padx=pad_x); col+=1
remove_button = row_data.get('remove_button', None)
if remove_button: remove_button.grid(row=new_grid_row, column=col, sticky=tk.W, pady=0, padx=pad_x); col+=1
# Update Remove Button Command
if remove_button and isinstance(remove_button, ttk.Button):
remove_button.configure(command=lambda idx=i: self._handle_remove_mapping_click(idx))
row_data['grid_row'] = new_grid_row
def _update_mapping_scrollregion(self):
"""Forces an update of the mapping canvas scroll region."""
self.theme_manager.update_scroll_region(self.element_mapping_frame.mapping_frame)
def _schedule_config_save(self, save_callback, delay=1500):
"""Debounces calls to the save configuration callback."""
if self._save_timer_id is not None:
self.theme_manager.after_cancel(self._save_timer_id)
# print(f"DEBUG: Scheduling save in {delay}ms") # Debug
self._save_timer_id = self.theme_manager.after(delay, save_callback)
def load_element_mappings(self, mappings_data):
"""Clears existing mapping rows and loads new ones from data."""
# Clear existing UI rows
for i in range(len(self.mapping_rows) - 1, -1, -1):
self.remove_mapping_row(i)
# Load new mappings
if isinstance(mappings_data, list) and mappings_data:
for mapping_dict in mappings_data:
if isinstance(mapping_dict, dict):
self.add_mapping_row(data=mapping_dict)
# Ensure at least one row exists
if not self.mapping_rows:
self.element_mapping_frame.skip_next_save = True
self.add_mapping_row() # Add default blank row
self.element_mapping_frame.initialized_mappings = True
self._update_mapping_scrollregion()
def get_element_mappings(self):
"""Extracts the current element mapping configuration from the UI."""
mappings_to_return = []
if not hasattr(self, 'mapping_rows'): return mappings_to_return
for row_data in self.mapping_rows:
try:
svg_type = row_data['svg_type'].get().strip()
element_type = row_data['element_type'].get().strip()
# Only include valid rows (must have svg_type and element_type)
if not svg_type or not element_type:
continue
mapping = {
'svg_type': svg_type,
'element_type': element_type,
'label_prefix': row_data['label_prefix'].get().strip(),
'props_path': row_data['props_path'].get().strip(),
'final_prefix': row_data['final_prefix'].get().strip(),
'final_suffix': row_data['final_suffix'].get().strip(),
}
# Convert numeric, handle errors gracefully (defaulting)
for key, default in [('width', 14), ('height', 14), ('x_offset', 0), ('y_offset', 0)]:
val_str = row_data[key].get().strip()
try: mapping[key] = int(val_str) if val_str else default
except ValueError: mapping[key] = default
mappings_to_return.append(mapping)
except (KeyError, AttributeError, tk.TclError) as e:
print(f"Warning: Skipping invalid mapping row during get: {e}")
continue
return mappings_to_return
def cleanup_incomplete_mappings(self):
"""Removes mapping rows where essential fields are empty."""
if not hasattr(self, 'mapping_rows'): return
indices_to_remove = []
for i in range(len(self.mapping_rows) - 1, -1, -1):
row_data = self.mapping_rows[i]
try:
svg_type = row_data.get('svg_type', tk.StringVar()).get().strip()
element_type = row_data.get('element_type', tk.StringVar()).get().strip()
# Remove if essential field is empty, unless it's the only row left
if (not svg_type or not element_type) and len(self.mapping_rows) > 1:
indices_to_remove.append(i)
except (AttributeError, KeyError, tk.TclError):
indices_to_remove.append(i) # Assume broken row
if indices_to_remove:
# Disable saving during cleanup
if self._save_timer_id:
self.theme_manager.after_cancel(self._save_timer_id)
self._save_timer_id = None
# Remove rows
for index in indices_to_remove:
self.remove_mapping_row(index)
# Example Usage
if __name__ == '__main__':
root = tk.Tk()
root.title("Notebook Manager Test")
root.geometry("800x600")
# Mock ThemeManager for testing
class MockThemeManager:
def configure_widget(self, widget):
# Apply some basic styling for visibility
try:
widget.config(style='TFrame' if isinstance(widget, ttk.Frame) else 'TNotebook')
except tk.TclError:
pass # Ignore if style doesn't apply
def toggle_theme(self): pass
def apply_theme(self): pass
def update_scroll_region(self, widget): pass
def after_cancel(self, timer_id): pass
def after(self, delay, callback): return 1 # Mock implementation
style = ttk.Style()
style.theme_use('clam')
style.configure('TNotebook', background='#f0f0f0', tabmargins=[2, 5, 2, 0])
style.configure('TNotebook.Tab', padding=[5, 2], font=('Arial', 10))
style.configure('TFrame', background='#ffffff')
mock_theme_manager = MockThemeManager()
notebook_manager = NotebookManager(root, mock_theme_manager)
notebook_manager.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# You can access the frames like this:
# notebook_manager.get_results_frame().set_results("Sample results text")
# notebook_manager.get_log_frame().log_message("Sample log message")
# mappings = notebook_manager.get_element_mapping_frame().get_mappings()
root.mainloop()

View File

@ -0,0 +1,33 @@
import logging
from typing import Tuple, Optional
logger = logging.getLogger(__name__)
def extract_position_from_attributes(element, svg_type) -> Tuple[Optional[float], Optional[float]]:
"""Extract position directly from element attributes as a fallback."""
try:
if svg_type == 'rect':
x = float(element.getAttribute('x') or 0)
y = float(element.getAttribute('y') or 0)
return x, y
elif svg_type == 'circle' or svg_type == 'ellipse':
cx = float(element.getAttribute('cx') or 0)
cy = float(element.getAttribute('cy') or 0)
if svg_type == 'circle':
r = float(element.getAttribute('r') or 0)
return cx - r, cy - r
else: # ellipse
rx = float(element.getAttribute('rx') or 0)
ry = float(element.getAttribute('ry') or 0)
return cx - rx, cy - ry
elif svg_type == 'line':
x1 = float(element.getAttribute('x1') or 0)
y1 = float(element.getAttribute('y1') or 0)
return x1, y1
elif svg_type == 'path':
return None, None
except (ValueError, TypeError) as e:
logger.error(f"Error extracting position from attributes: {e}")
return None, None

View File

@ -0,0 +1,41 @@
import logging
from typing import Tuple, Optional, List, Dict, Any
from . import element_mapper
logger = logging.getLogger(__name__)
class PrefixResolver:
"""Resolve prefixes for element mapping"""
@staticmethod
def get_element_prefix(element_label: str) -> str:
"""Extract prefix from element label"""
if element_label and "_" in element_label:
return element_label.split("_")[0]
return ""
@staticmethod
def determine_prefix_to_use(element_candidate_prefix: str, group_prefix: str,
element_mappings: List[Dict[str, Any]], svg_type: str) -> Tuple[Optional[str], str]:
"""Determine which prefix to use for mapping lookup
Group prefixes always take precedence over element prefixes.
"""
# First check group prefix since it should take precedence
if group_prefix:
_, group_match_type = element_mapper.find_mapping_for_element(
svg_type, group_prefix, element_mappings, debug=False
)
if group_match_type == 'exact':
return group_prefix, "group"
# Only check element prefix if no valid group prefix exists
if element_candidate_prefix:
_, element_match_type = element_mapper.find_mapping_for_element(
svg_type, element_candidate_prefix, element_mappings, debug=False
)
if element_match_type == 'exact':
return element_candidate_prefix, "element"
return None, "none"

84
processing/processor.py Normal file
View File

@ -0,0 +1,84 @@
import json
import os
import io
from contextlib import redirect_stdout
import traceback
from lxml import etree
from config_manager import DEFAULT_CONFIG
import logging
# Core processing logic
from . import element_processor
# Use the absolute import
from processing.inkscape_transform import SVGTransformer
from .scada_exporter import ScadaCreatorError # Updated import name
logger = logging.getLogger(__name__)
class ProcessorError(Exception):
"""Custom exception for processing errors."""
pass
def process_svg_file(file_path, custom_options, message_callback, log_callback, center_mode='bbox'):
"""
Processes the SVG file using SVGTransformer.
Args:
file_path (str): Path to the SVG file.
custom_options (dict): Dictionary containing element mappings and SCADA settings.
message_callback (callable): Function to send status messages (e.g., thread_manager.put_message).
log_callback (callable): Function to log messages (e.g., log_frame.log_message).
Returns:
list: A list of processed element dictionaries.
Raises:
ProcessorError: If an error occurs during processing.
"""
if not os.path.exists(file_path):
raise ProcessorError(f"File not found: {file_path}")
message_callback(f"Processing: {os.path.basename(file_path)}...")
log_callback(f"Starting SVG processing for {file_path}...")
log_callback(f"Options: {json.dumps(custom_options, indent=2)}")
try:
# Use a string buffer to capture stdout from the transformer if needed
# log_capture = io.StringIO()
# with redirect_stdout(log_capture):
# Initialize transformer
# Pass only relevant parts of custom_options if SVGTransformer expects specific keys
transformer = SVGTransformer(file_path, custom_options)
# Process SVG
elements = transformer.process_svg(center_mode=center_mode)
# Log captured stdout if used
# captured_log = log_capture.getvalue()
# if captured_log:
# log_callback(f"Transformer Output:\n{captured_log}")
log_callback(f"Successfully processed {len(elements)} elements from {os.path.basename(file_path)}.")
message_callback("Processing complete.") # Intermediate message
# Simply return the elements processed by the transformer
return elements
except FileNotFoundError as fnf_err:
log_callback(f"ERROR: SVG file not found: {fnf_err}")
raise ProcessorError(f"SVG file not found: {fnf_err}") from fnf_err
except json.JSONDecodeError as json_err:
log_callback(f"ERROR: Failed to parse potential JSON within SVG or options: {json_err}")
raise ProcessorError(f"JSON parsing error: {json_err}") from json_err
except ImportError as imp_err:
log_callback(f"ERROR: Missing dependency, likely Inkscape: {imp_err}")
raise ProcessorError(f"Missing dependency (Inkscape?): {imp_err}") from imp_err
except ScadaCreatorError as sce: # Update except block if ScadaCreatorError could be raised here
log_callback(f"ERROR: SCADA View Creation related error during processing: {sce}")
raise ProcessorError(f"SCADA View Creation Error: {sce}") from sce
except Exception as e:
# Use imported functions
log_callback(f"ERROR: An unexpected error occurred during SVG processing: {e}")
log_callback(f"Traceback:\n{traceback.format_exc()}")
raise ProcessorError(f"Processing failed: {e}") from e

View File

@ -0,0 +1,470 @@
import os
import json
import re
import zipfile # Restored zip dependency
import shutil # For directory operations
from datetime import datetime
from PIL import Image # For thumbnail creation
class ScadaCreatorError(Exception): # Renamed Error class
"""Custom exception for SCADA view creation errors."""
pass
class ScadaViewCreator: # Renamed class
"""
Handles the direct creation of Ignition SCADA view files (view.json,
resource.json, thumbnail.png) within the project structure.
"""
def __init__(self, view_data, ignition_base_dir, custom_export_dir=None, log_callback=print, status_callback=print, create_zip=True):
"""
Initialize the view creator.
Args:
view_data (dict): A dictionary containing all necessary data:
- project_title (str): Ignition project title.
# - parent_project (str): Removed, not needed for direct creation.
- view_path (str): Path within the project's views folder (e.g., 'Group/SubGroup'). Optional.
- view_name (str): Name for the view file/directory (e.g., 'MyView').
- svg_url (str): URL or path for the background SVG image (optional).
- image_width (int): Width of the background image.
- image_height (int): Height of the background image.
- default_width (int): Default width for the view.
- default_height (int): Default height for the view.
- elements (list): List of processed element dictionaries.
ignition_base_dir (str): The base directory for Ignition projects
(e.g., 'C:/Program Files/Inductive Automation/Ignition/data/projects').
custom_export_dir (str, optional): Custom directory to export files to. If provided, ignores the ignition_base_dir
and exports files directly to this location.
log_callback (callable): Function to call for logging messages.
status_callback (callable): Function to call for status updates.
create_zip (bool): Whether to create a zip file containing the export (default: True).
"""
self.data = view_data
self.ignition_base_dir = ignition_base_dir
self.custom_export_dir = custom_export_dir
self.create_zip = create_zip
self._log = lambda msg: log_callback(f"[ViewCreator] {msg}") # Updated log prefix
self._status = status_callback
# Validate required data
required_keys = [
'project_title', 'view_name', 'image_width',
'image_height', 'default_width', 'default_height', 'elements'
]
missing_keys = [k for k in required_keys if k not in self.data]
if missing_keys:
raise ValueError(f"Missing required view data keys: {', '.join(missing_keys)}")
# Only validate base directory if no custom export directory is provided
if not self.custom_export_dir and (not self.ignition_base_dir or not os.path.isdir(self.ignition_base_dir)):
raise ValueError(f"Invalid ignition_base_dir provided: {self.ignition_base_dir}")
def create_view_files(self): # Renamed method, removed zip_file_path
"""
Creates the SCADA view files (view.json, resource.json, thumbnail.png)
directly in the project's file structure or in a custom export location.
"""
project_title_cleaned = self._clean_path_part(self.data["project_title"])
if not project_title_cleaned:
raise ValueError("Project title cannot be empty after cleaning.")
self._log(f"Starting SCADA view file creation for project: {project_title_cleaned}")
try:
# --- Create temporary working directory if creating a zip file ---
temp_work_dir = None
if self.create_zip and self.custom_export_dir:
import tempfile
temp_work_dir = tempfile.mkdtemp(prefix="scada_export_")
self._log(f"Created temporary working directory: {temp_work_dir}")
# --- Determine target directory ---
if self.custom_export_dir:
if self.create_zip:
# If creating a zip, use the temporary directory to build the structure
target_root_dir = temp_work_dir if temp_work_dir else self.custom_export_dir
# Place perspective folder directly at root for zip
perspective_rel_path = "com.inductiveautomation.perspective"
# Save the root dir for later project.json creation
self.zip_root_dir = target_root_dir
else:
# Otherwise use custom export directory directly
self._log(f"Using custom export directory: {self.custom_export_dir}")
target_view_dir = self.custom_export_dir
target_root_dir = None
else:
# Use standard Ignition structure
target_root_dir = self.ignition_base_dir
perspective_rel_path = os.path.join("com.inductiveautomation.perspective", "views")
# --- Clean and Combine View Path and Name ---
view_path_raw = self.data.get("view_path", "").strip(' /\\')
view_name_raw = self.data["view_name"].strip(' /\\')
cleaned_view_path_parts = [self._clean_path_part(part) for part in re.split(r'[\\/]+', view_path_raw) if part]
cleaned_view_name = self._clean_path_part(view_name_raw)
if not cleaned_view_name:
self._log("Warning: Cleaned view name is empty. Using 'DefaultView'.")
cleaned_view_name = "DefaultView"
# For later creation of zip file
self.view_name = cleaned_view_name
final_view_rel_parts = cleaned_view_path_parts + [cleaned_view_name]
view_dir_rel = os.path.join(*final_view_rel_parts)
# Construct the absolute path for the view directory
if target_root_dir:
if self.create_zip:
# For zip structure, views folder is inside perspective folder
target_view_dir = os.path.join(
target_root_dir,
perspective_rel_path,
"views",
view_dir_rel
)
else:
target_view_dir = os.path.join(
target_root_dir,
project_title_cleaned,
perspective_rel_path,
view_dir_rel
)
# Save project directory for later zip creation
if self.create_zip:
self.project_dir = os.path.join(
target_root_dir,
project_title_cleaned
)
self._log(f"Calculated target view directory: {target_view_dir}")
# Create directories if they don't exist
self._status(f"Ensuring directory structure exists: {target_view_dir}")
os.makedirs(target_view_dir, exist_ok=True)
self._log(f"View directory created/ensured: {target_view_dir}")
# --- Create View Files ---
self._status("Creating view files...")
self._create_view_json(target_view_dir)
self._create_resource_json(target_view_dir)
self._create_thumbnail(target_view_dir)
# Create project.json at top level for zip structure
if self.create_zip and hasattr(self, 'zip_root_dir'):
self._create_project_json(self.zip_root_dir)
self._log(f"View files created in: {target_view_dir}")
# --- Create Zip File If Requested ---
zip_file_path = None
if self.create_zip and self.custom_export_dir:
self._status("Creating zip file...")
zip_file_path = self._create_zip_file(
project_title_cleaned,
self.zip_root_dir if hasattr(self, 'zip_root_dir') else target_view_dir
)
self._log(f"Zip file created at: {zip_file_path}")
# Clean up temporary directory
if temp_work_dir:
self._log(f"Cleaning up temporary directory: {temp_work_dir}")
shutil.rmtree(temp_work_dir)
self._log(f"View files successfully created.")
if zip_file_path:
self._log(f"Zip file created at: {zip_file_path}")
else:
self._log(f"Files created in directory: '{target_view_dir}'.")
self._status("View creation complete.")
except Exception as e:
# Clean up temp directory if it exists
if 'temp_work_dir' in locals() and temp_work_dir and os.path.exists(temp_work_dir):
try:
shutil.rmtree(temp_work_dir)
except:
pass
# Log the error and re-raise as a specific ScadaCreatorError
import traceback
error_details = f"Error during view file creation:\n{e}\nTraceback:\n{traceback.format_exc()}"
self._log(f"View Creation Failed! Details:\n{error_details}")
self._status(f"View creation failed: {e}")
raise ScadaCreatorError(f"Failed to create view files: {e}") from e
def _create_zip_file(self, project_title, source_dir):
"""Create a zip file containing the project structure."""
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M")
zip_filename = f"{project_title}_SCADA_{timestamp}.zip"
zip_path = os.path.join(self.custom_export_dir, zip_filename)
self._log(f"Creating zip file at: {zip_path}")
self._status("Creating zip archive...")
try:
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
# Add project.json directly to the root
project_json_path = os.path.join(source_dir, "project.json")
if os.path.exists(project_json_path):
zipf.write(project_json_path, "project.json")
self._log(f" Added to zip: project.json")
# Add com.inductiveautomation.perspective folder and its contents
perspective_dir = os.path.join(source_dir, "com.inductiveautomation.perspective")
if os.path.exists(perspective_dir):
# Walk through the perspective directory
for root, dirs, files in os.walk(perspective_dir):
for file in files:
file_path = os.path.join(root, file)
# Create relative path within the zip
arcname = os.path.join("com.inductiveautomation.perspective",
os.path.relpath(file_path, perspective_dir))
zipf.write(file_path, arcname)
self._log(f" Added to zip: {arcname}")
self._status("Zip file created successfully.")
return zip_path
except Exception as e:
self._log(f"Error creating zip file: {e}")
raise ScadaCreatorError(f"Failed to create zip file: {e}") from e
# --- Helper for cleaning path parts ---
def _clean_path_part(self, part):
"""Cleans a path component for safe file/directory naming."""
# Replace invalid filename characters and multiple spaces/underscores
cleaned = re.sub(r'[<>:"/\\|?*\s]+', '_', part)
cleaned = re.sub(r'_+', '_', cleaned) # Consolidate underscores
return cleaned.strip('_')
# --- Helper methods for creating files ---
def _create_project_json(self, base_dir):
"""Create a project.json file at the root of the project directory."""
project_config = {
"title": self.data["project_title"],
"description": "JP",
"parent": "SCADA_PERSPECTIVE_PARENT_PROJECT",
"enabled": True,
"inheritable": False
}
# Create/ensure project.json at the root level
project_file = os.path.join(base_dir, "project.json")
try:
with open(project_file, 'w', encoding='utf-8') as f:
json.dump(project_config, f, indent=2)
self._log(" - project.json created.")
except IOError as e:
self._log(f"ERROR: Could not write project.json to {project_file}: {e}")
raise ScadaCreatorError(f"Could not write project.json: {e}") from e
def _create_resource_json(self, view_dir):
"""Create the resource.json file with specified structure."""
resource_config = {
"scope": "G",
"version": 1,
"restricted": False,
"overridable": True,
"files": [
"view.json",
"thumbnail.png"
],
"attributes": {
"lastModification": {
# Using fixed values as requested
"actor": "ilia-gu-autstand",
"timestamp": "2025-04-10T15:25:02Z"
},
# Using fixed signature as requested
"lastModificationSignature": "578829ca084d3cc740d02206de92730e6cb6ee3ea927e5e28f4f8dfe44b95ade"
}
}
resource_file = os.path.join(view_dir, "resource.json")
try:
with open(resource_file, 'w', encoding='utf-8') as f:
json.dump(resource_config, f, indent=2)
self._log(" - resource.json created.")
except IOError as e:
self._log(f"ERROR: Could not write resource.json to {resource_file}: {e}")
raise ScadaCreatorError(f"Could not write resource.json: {e}") from e
def _create_thumbnail(self, view_dir):
"""Create a minimal placeholder thumbnail.png."""
thumbnail_file = os.path.join(view_dir, "thumbnail.png")
try:
# Check if Pillow is available
try:
from PIL import Image
img = Image.new('RGBA', (1, 1), (0, 0, 0, 0)) # 1x1 transparent pixel
img.save(thumbnail_file, "PNG")
self._log(" - thumbnail.png created (placeholder).")
except ImportError:
self._log("Warning: Pillow (PIL) not installed. Cannot create thumbnail PNG.")
# Create empty file as fallback if Pillow is not installed
with open(thumbnail_file, 'w') as f:
pass
self._log(" - thumbnail.png created (empty file fallback - Pillow missing).")
except Exception as e:
self._log(f"Error creating thumbnail PNG: {e}. Creating empty file.")
# Create empty file as fallback on other errors
try:
with open(thumbnail_file, 'w') as f:
pass
self._log(" - thumbnail.png created (empty file fallback - error during creation).")
except IOError as io_e:
self._log(f"ERROR: Could not write empty thumbnail.png to {thumbnail_file}: {io_e}")
raise ScadaCreatorError(f"Could not write thumbnail.png: {io_e}") from io_e
def _create_view_json(self, view_dir):
"""Create the main view.json file."""
view_config = {
"custom": {},
"params": {},
"props": {
"defaultSize": {
# Using default_width/height from data for consistency
"height": self.data["default_height"],
"width": self.data["default_width"]
}
},
"root": {
"type": "ia.container.coord",
"meta": {"name": "root"},
"props": {"style": {"overflow": "visible"}},
"children": []
}
}
# Add background image if URL is provided
svg_url = self.data.get("svg_url")
if svg_url:
background_image = {
"type": "ia.display.image",
"version": 0,
"meta": {"name": "BackgroundImage"},
"position": {
"x": 0, "y": 0,
"width": self.data["image_width"],
"height": self.data["image_height"]
},
"props": {"source": svg_url, "fit": {"mode": "contain"}},
"custom": {}
}
view_config["root"]["children"].append(background_image)
# Convert processed elements
scada_elements = []
processed_elements = self.data.get("elements", [])
self._log(f"Processing {len(processed_elements)} elements for view.json...")
for i, element_data in enumerate(processed_elements):
try:
element_type = element_data.get('type', 'ia.display.label')
# Use cleaned element name for meta name if possible
element_name_raw = element_data.get('meta', {}).get('name', f'element_{i+1}')
element_name = self._clean_path_part(element_name_raw) or f'element_{i+1}' # Fallback if cleaning results in empty
position = element_data.get('position', {})
props = element_data.get('props', {})
custom = element_data.get('custom', {})
# Position details
x = position.get('x', 0)
y = position.get('y', 0)
width = position.get('width', 10)
height = position.get('height', 10)
rotation_info = position.get('rotate', None)
# Basic structure
scada_component = {
"type": element_type,
"version": 0,
"meta": {"name": element_name}, # Use cleaned name
"position": {
"x": x, "y": y,
"width": width, "height": height
},
"props": props,
"custom": custom
}
# Add rotation
if rotation_info and isinstance(rotation_info, dict):
angle_str = str(rotation_info.get('angle', '0')).lower().replace('deg', '').strip()
try:
angle = float(angle_str)
scada_component["position"]["rotate"] = {"angle": angle}
anchor = rotation_info.get('anchor')
if anchor:
scada_component["position"]["rotate"]["anchor"] = anchor
except ValueError:
self._log(f"Warning: Invalid rotation angle '{angle_str}' for '{element_name}'.")
# --- Adjustments based on type (especially for ia.display.view) ---
if element_type == 'ia.display.view':
# Ensure props exists
if 'props' not in scada_component:
scada_component['props'] = {}
# Ensure path exists in props, use default from mapping if available
if 'path' not in scada_component['props']:
# Attempt to get path from original props if it was somehow separated
original_path = props.get('path', 'Error/MissingPath')
scada_component['props']['path'] = original_path
if original_path == 'Error/MissingPath':
self._log(f"Warning: Embedded view '{element_name}' missing 'path'. Setting default 'Error/MissingPath'. Check mappings.")
# Ensure params exists and construct tagProps based on example
if 'params' not in scada_component['props']:
scada_component['props']['params'] = {}
# Construct tagProps using element name as placeholder tag path
# TODO: Make tag path generation more configurable if needed
placeholder_tag_path = f"System/Path/Placeholder/{element_name}"
scada_component['props']['params']['tagProps'] = [
placeholder_tag_path,
"value", "value", "value", "value",
"value", "value", "value", "value", "value"
]
# Include other potential params from original data if needed
# This merge might be complex depending on desired behavior
# original_params = props.get('params', {})
# scada_component['props']['params'].update(original_params) # Careful merge needed
elif element_type == 'ia.display.label':
# Ensure text prop exists for labels
if 'props' not in scada_component:
scada_component['props'] = {}
if 'text' not in scada_component['props']:
# Use raw name for text, cleaned name for meta
scada_component['props']['text'] = element_name_raw
# Add other type-specific adjustments here if necessary
scada_elements.append(scada_component)
except Exception as el_err:
element_name_raw = element_data.get('meta',{}).get('name', f'unknown_{i+1}')
self._log(f"ERROR converting element {i} ('{element_name_raw}') to SCADA format: {el_err}")
# Add placeholder on error
scada_elements.append({
"type": "ia.display.label", "meta": {"name": f"ERROR_{self._clean_path_part(element_name_raw)}_{i+1}"},
"position": {"x": 0, "y": i*15, "width": 100, "height": 15},
"props": {"text": f"Export Error: {el_err}"}
})
view_config["root"]["children"].extend(scada_elements)
# Write file
view_file = os.path.join(view_dir, "view.json")
try:
with open(view_file, 'w', encoding='utf-8') as f:
json.dump(view_config, f, indent=2)
self._log(f" - view.json created with {len(scada_elements)} elements.")
except IOError as e:
self._log(f"ERROR: Could not write view.json to {view_file}: {e}")
raise ScadaCreatorError(f"Could not write view.json: {e}") from e

54
project_config.md Normal file
View File

@ -0,0 +1,54 @@
# Project Configuration (LTM)
*This file contains the stable, long-term context for the project.*
*It should be updated infrequently, primarily when core goals, tech, or patterns change.*
---
## Core Goal
Create a desktop application for extracting SVG elements and converting them to JSON format for automation systems, particularly Ignition SCADA. The tool enables users to process SVG files with customizable configurations and export the results in a standardized format compatible with industrial automation platforms.
---
## Tech Stack
* **Language:** Python 3.6+
* **GUI Framework:** Tkinter
* **XML Processing:** lxml
* **Image Processing:** Pillow (PIL)
* **Numerical Operations:** NumPy
* **Packaging:** PyInstaller
* **Configuration:** JSON
---
## Critical Patterns & Conventions
* **Architecture:** Modular design with separation between GUI and processing logic
* **Modules:**
* **GUI Module:** Handles user interface, configuration input, and results display
* **Processing Module:** Manages SVG parsing, transformation, and JSON conversion
* **Configuration Management:** JSON-based with backward compatibility support
* **Element Mapping System:** Label prefix-based configurations for SVG elements
* **Matrix Transformations:** Homogeneous coordinate matrices for transform calculations
* **Error Handling:** Global exception handling with user-friendly messages
* **Threading:** Background processing for UI responsiveness during operations
* **Entry Point:** app_runner.py initializes the application
---
## Key Constraints
* **Cross-platform Support:** Primary focus on Windows with additional support for macOS and Linux
* **Performance:** Must efficiently process complex SVG files with multiple elements
* **Output Format:** Must conform to Ignition SCADA JSON schema requirements
* **Error Recovery:** Must handle malformed SVG elements gracefully
* **Target Users:** Industrial automation engineers with varying technical expertise
---
## Tokenization Settings
* **Estimation Method:** Character-based
* **Characters Per Token (Estimate):** 4

4
requirements.txt Normal file
View File

@ -0,0 +1,4 @@
numpy>=1.21.0
Pillow>=9.0.0
pyinstaller>=6.0.0
lxml>=4.9.0

29
test_svg_modules.py Normal file
View File

@ -0,0 +1,29 @@
import logging
import sys
# Set up logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# Import modules to test
try:
from processing.geometry_extractor import GeometryExtractor, get_element_geometry
from processing.prefix_resolver import PrefixResolver
from processing.position_utils import extract_position_from_attributes
from processing.element_processor import process_element
logger.info("Successfully imported all modules!")
# Print some module info to verify
logger.info(f"GeometryExtractor methods: {[method for method in dir(GeometryExtractor) if not method.startswith('_')]}")
logger.info(f"PrefixResolver methods: {[method for method in dir(PrefixResolver) if not method.startswith('_')]}")
logger.info(f"Position utils functions: {extract_position_from_attributes.__name__}")
logger.info(f"Main process function: {process_element.__name__}")
logger.info("All modules look good!")
except Exception as e:
logger.error(f"Error importing modules: {e}")
sys.exit(1)
print("✅ Module structure test completed successfully!")

218
utils.py Normal file
View File

@ -0,0 +1,218 @@
import os
import sys
import io
import tkinter as tk # Needed for RedirectText TclError handling
# Attempt to import PIL, handle if not found
try:
from PIL import Image, ImageTk
_PIL_AVAILABLE = True
except ImportError:
_PIL_AVAILABLE = False
# Define dummy classes if PIL not available to avoid NameErrors later
class Image:
@staticmethod
def open(path):
raise ImportError("PIL/Pillow is not installed.")
class Resampling:
LANCZOS = None # Placeholder
class ImageTk:
@staticmethod
def PhotoImage(img):
raise ImportError("PIL/Pillow is not installed.")
def get_application_path():
"""Get the base path for the application, works for both dev and PyInstaller"""
if getattr(sys, 'frozen', False):
# If the application is run as a bundle (PyInstaller)
# Use the directory of the executable
return os.path.dirname(sys.executable)
else:
# If running as a regular Python script
return os.path.dirname(os.path.abspath(__file__))
def resource_path(relative_path):
"""
Get absolute path to resource, works for dev and for PyInstaller.
This function helps locate resources whether running from source or
from a packaged executable.
Args:
relative_path (str): Path relative to the script or executable
Returns:
str: Absolute path to the resource
"""
try:
# PyInstaller creates a temp folder and stores path in _MEIPASS
base_path = getattr(sys, '_MEIPASS', None)
if base_path is None:
# Fall back to application directory
base_path = get_application_path()
except Exception:
base_path = get_application_path()
return os.path.join(base_path, relative_path)
class RedirectText:
"""
Redirect stdout to a tkinter widget.
This class provides a file-like interface to redirect standard output
to a tkinter text widget. It's used to capture console output for
display in the GUI.
"""
def __init__(self, text_widget):
"""
Initialize the stdout redirector.
Args:
text_widget: A tkinter text or scrolledtext widget to receive the output.
"""
self.text_widget = text_widget
# self.buffer = io.StringIO() # Removed: Not used, text_buffer handles accumulation
self.text_buffer = ""
def write(self, string):
"""
Write string to the buffer and text widget.
This method is called when text is written to stdout.
Args:
string (str): The string to write to the widget.
"""
try:
# Accumulate text and schedule updates in batches for smoother UI
self.text_buffer += str(string) # Ensure string conversion
# Flush more frequently or on newline to see logs sooner
if len(self.text_buffer) >= 50 or '\n' in self.text_buffer:
self._flush_text_buffer()
except Exception as e:
# Handle potential errors, e.g., if widget is destroyed prematurely
print(f"RedirectText Error writing: {e}") # Print to actual stderr
def _flush_text_buffer(self):
"""Flush accumulated text to the widget for smoother UI updates."""
if not self.text_buffer:
return
text_to_insert = self.text_buffer
self.text_buffer = "" # Clear buffer before potential error
try:
if self.text_widget and self.text_widget.winfo_exists():
# Add the text to the widget
self.text_widget.insert(tk.END, text_to_insert)
self.text_widget.see(tk.END) # Auto-scroll to the end
# Only call update_idletasks occasionally to reduce UI freezing
# Maybe only on newlines?
if '\n' in text_to_insert:
self.text_widget.update_idletasks()
else:
# Widget might have been destroyed
print(f"RedirectText Warning: Target widget does not exist.")
except tk.TclError as e:
# Handle tkinter errors (e.g., widget destroyed)
print(f"RedirectText TclError flushing buffer: {e}")
except Exception as e:
# Handle other unexpected errors during flush
print(f"RedirectText Unexpected error flushing buffer: {e}")
def flush(self):
"""
Flush the buffer.
This method is called when the buffer needs to be flushed.
It's required for file-like objects. Ensures any remaining text is written.
"""
try:
self._flush_text_buffer() # Ensure Tkinter buffer is flushed
except Exception as e:
print(f"RedirectText Error during flush: {e}")
def getvalue(self):
"""
Get the current value of the internal text buffer.
Returns:
str: The current accumulated text in the buffer.
"""
return self.text_buffer
# Add isatty() method for compatibility with some libraries that check it
def isatty(self):
return False
# --- Icon Handling Functions ---
def find_icon_file():
"""
Find the specific icon file using resource_path.
Returns:
str or None: Absolute path to the automation_standard_logo.png file, or None if not found.
"""
# Only look for automation_standard_logo.png as requested
icon_file = "automation_standard_logo.png"
try:
path = resource_path(icon_file) # resource_path handles packaged app paths
if os.path.exists(path):
# print(f"Found icon: {path}") # Optional debug
return path
except Exception as e:
# Log error if needed
print(f"Error finding icon {icon_file}: {e}")
return None # Return None if icon not found
def set_window_icon(root_window):
"""
Set the window icon using resource_path and handling different platforms/formats.
Args:
root_window: The tkinter root window (tk.Tk instance).
"""
if not root_window or not isinstance(root_window, tk.Tk):
print("Warning: Invalid root window provided to set_window_icon.")
return
try:
icon_path = find_icon_file() # Uses resource_path internally
if not icon_path:
print("Warning: Could not find icon file.")
return
# Always use PhotoImage method for .png file
set_window_icon_photoimage(root_window, icon_path)
except Exception as e:
print(f"Error setting window icon: {e}")
def set_window_icon_photoimage(root_window, icon_path):
"""Helper to set window icon using PhotoImage (handles PIL dependency)."""
if not _PIL_AVAILABLE:
print("Warning: PIL/Pillow not found. Cannot set image icon.")
return
try:
icon_img = Image.open(icon_path)
# Resize if needed, e.g., to 32x32 or 64x64 (adjust as desired)
icon_img = icon_img.resize((32, 32), Image.Resampling.LANCZOS)
icon_photo = ImageTk.PhotoImage(icon_img)
# Store reference on the root window to avoid garbage collection
root_window.icon_photo_ref = icon_photo
root_window.iconphoto(True, icon_photo)
# print(f"Applied icon using iconphoto: {icon_path}")
except ImportError:
# This case should be caught by _PIL_AVAILABLE, but added for safety
print("Warning: PIL/Pillow import failed. Cannot set image icon.")
except tk.TclError as tcl_err:
print(f"Error setting iconphoto: {tcl_err}")
except Exception as img_e:
print(f"Error applying image icon: {img_e}")

227
workflow_state.md Normal file
View File

@ -0,0 +1,227 @@
# Workflow State & Rules (STM + Rules + Log)
*This file contains the dynamic state, embedded rules, active plan, and log for the current session.*
*It is read and updated frequently by the AI during its operational loop.*
---
## State
*Holds the current status of the workflow.*
```yaml
Phase: CONSTRUCT # Current workflow phase (ANALYZE, BLUEPRINT, CONSTRUCT, VALIDATE, BLUEPRINT_REVISE)
Status: IN_PROGRESS # Current status (READY, IN_PROGRESS, BLOCKED_*, NEEDS_*, COMPLETED, COMPLETED_ITERATION)
CurrentTaskID: UpdateReadmeDocumentation # Identifier for the main task being worked on
CurrentStep: Step 13 # Identifier for the specific step in the plan being executed
CurrentItem: null # Identifier for the item currently being processed in iteration
```
---
## Plan
*Contains the step-by-step implementation plan generated during the BLUEPRINT phase.*
**Task: ImplementNestedGroupProcessing**
* `[x] Step 1: (CONSTRUCT Phase) Modify the process_group method in inkscape_transform.py to recursively process nested groups.`
* `[x] Step 2: (CONSTRUCT Phase) Implement the recursive call to process_group when a nested 'g' element is encountered.`
* `[x] Step 3: (CONSTRUCT Phase) Update counter/index tracking for nested groups (use format "parent_index.child_index").`
* `[x] Step 4: (CONSTRUCT Phase) Properly aggregate and return results from nested groups by extending the current group's results.`
* `[x] Step 5: (CONSTRUCT Phase) Ensure group context (prefixes, suffixes) are correctly passed to nested groups.`
* `[x] Step 6: (VALIDATE Phase) Verify nested group processing works by testing with a sample SVG file containing nested groups.`
**Task: FixElementPositioningAndLabels**
* `[x] Step 1: (CONSTRUCT Phase) Fix label prefix inheritance from groups. Modify the process_element function in element_processor.py to correctly set label_prefix when prefix_source is "group".`
* `[x] Step 2: (CONSTRUCT Phase) Fix element position calculation. Add debugging to track position calculation flow and fix any issues with the transformation or geometry extraction.`
* `[x] Step 3: (CONSTRUCT Phase) Ensure the label_prefix value is correctly passed to json_builder.create_element_json to properly set the "elementPrefix" field in the JSON output.`
* `[x] Step 4: (CONSTRUCT Phase) Review and fix any fallback logic related to position calculation that might be causing default (-10,-10) positions for elements.`
* `[x] Step 5: (VALIDATE Phase) Test the fixes with an SVG file containing nested groups and verify that elements have correct prefix inheritance and position values.`
**Task: ImplementCustomExportLocation**
* `[x] Step 1: (CONSTRUCT Phase) Modify the ScadaCreator class in scada_exporter.py to accept a custom export directory parameter that will override the default path construction.`
* `[x] Step 2: (CONSTRUCT Phase) Update the _create_view_files method in scada_exporter.py to use the custom export directory when provided, while maintaining the original behavior when no custom directory is specified.`
* `[x] Step 3: (CONSTRUCT Phase) Add a new field in the SCADA settings frame in svg_processor_gui.py to allow users to specify a custom export location.`
* `[x] Step 4: (CONSTRUCT Phase) Add a "Browse" button next to the new field that opens a directory selection dialog.`
* `[x] Step 5: (CONSTRUCT Phase) Modify the create_scada_view method in svg_processor_gui.py to pass the custom export location to the ScadaCreator.`
* `[x] Step 6: (CONSTRUCT Phase) Update the config_manager.py to include the custom export location in the default configuration.`
* `[x] Step 7: (VALIDATE Phase) Test the new feature by exporting a SCADA view to a custom location and verifying that all files are correctly created in the specified directory.`
**Task: UpdateReadmeDocumentation**
* `[x] Step 1: (CONSTRUCT Phase) Review existing README.md sections and identify areas for improvement, consolidation, and expansion based on project_config.md and actual application features.`
* `[x] Step 2: (CONSTRUCT Phase) Restructure README.md for better flow and readability. Consolidate duplicated sections (e.g., "Technical Details", "License", "Acknowledgments").`
* `[x] Step 3: (CONSTRUCT Phase) Expand "Overview" to clearly state the problem the application solves and its benefits.`
* `[x] Step 4: (CONSTRUCT Phase) Enhance "Features" section: Ensure all key functionalities are listed and briefly described. Refer to `project_config.md` for core features.`
* `[x] Step 5: (CONSTRUCT Phase) Update "Installation" section: Verify prerequisites, source installation, and PyInstaller build instructions. Add notes on virtual environments for all OS if not sufficiently covered.`
* `[x] Step 6: (CONSTRUCT Phase) Improve "Usage" section: Make it a step-by-step guide for a new user. Ensure clarity on configuring project settings and element mappings. Add screenshots or ASCII diagrams if helpful (consider limitations).`
* `[x] Step 7: (CONSTRUCT Phase) Detail "Configuration File (`config.json`)" section: Explain key configuration options and their purpose. Mention `config_manager.py`'s role in handling this.`
* `[x] Step 8: (CONSTRUCT Phase) Refine "Technical Details" section:
* `[x] Ensure "Project Structure" accurately reflects the current module organization. Describe the role of each key file/directory (e.g. `app_runner.py`, `gui/`, `processing/`, `utils.py`, `config_manager.py`).`
* `[x] Clarify "SVG Processing Workflow" and "SCADA Export Process".`
* `[x] Elaborate on "JSON Element Structure", "Label Prefix System", "Matrix Transformations", and "Rotation Handling".`
* `[x] Add a subsection on "Error Handling" strategy as defined in `project_config.md`.`
* `[x] Add a subsection on "Threading" for UI responsiveness as defined in `project_config.md`.`
* `[x] Step 9: (CONSTRUCT Phase) Update "Testing" section: If `pytest` is used, provide clear instructions. Mention any specific test configurations or environments.`
* `[x] Step 10: (CONSTRUCT Phase) Enhance "Troubleshooting" section: Add common issues and solutions. Refer to logs for debugging.`
* `[x] Step 11: (CONSTRUCT Phase) Add "Dependencies" section: List all dependencies from `requirements.txt` with brief explanations of their roles (lxml for XML, numpy for math, Pillow for images, svg.path for paths).`
* `[x] Step 12: (CONSTRUCT Phase) Add "Contributing" section: Briefly outline how others can contribute (e.g., reporting bugs, suggesting features, submitting PRs). Mention coding standards if any are implicitly followed.`
* `[x] Step 13: (CONSTRUCT Phase) Add/Update "License" section: Prompt user for actual license type if "[Specify Your License Here]" is still present.`
* `[ ] Step 14: (CONSTRUCT Phase) Review and refine "Acknowledgments". (Skipping as per user request to move to final step)`
* `[ ] Step 15: (CONSTRUCT Phase) Perform a final review of the entire `README.md` for clarity, consistency, grammar, and formatting.`
* `[ ] Step 16: (VALIDATE Phase) Confirm with the user that the updated README.md is comprehensive and accurate.`
---
## Rules
*Embedded rules governing the AI's autonomous operation.*
**# --- Core Workflow Rules ---**
RULE_WF_PHASE_ANALYZE:
**Constraint:** Goal is understanding request/context. NO solutioning or implementation planning.
RULE_WF_PHASE_BLUEPRINT:
**Constraint:** Goal is creating a detailed, unambiguous step-by-step plan. NO code implementation.
RULE_WF_PHASE_CONSTRUCT:
**Constraint:** Goal is executing the `## Plan` exactly. NO deviation. If issues arise, trigger error handling or revert phase.
RULE_WF_PHASE_VALIDATE:
**Constraint:** Goal is verifying implementation against `## Plan` and requirements using tools. NO new implementation.
RULE_WF_TRANSITION_01:
**Trigger:** Explicit user command (`@analyze`, `@blueprint`, `@construct`, `@validate`).
**Action:** Update `State.Phase` accordingly. Log phase change.
RULE_WF_TRANSITION_02:
**Trigger:** AI determines current phase constraint prevents fulfilling user request OR error handling dictates phase change (e.g., RULE_ERR_HANDLE_TEST_01).
**Action:** Log the reason. Update `State.Phase` (e.g., to `BLUEPRINT_REVISE`). Set `State.Status` appropriately (e.g., `NEEDS_PLAN_APPROVAL`). Report to user.
RULE_ITERATE_01: # Triggered by RULE_MEM_READ_STM_01 when State.Status == READY and State.CurrentItem == null, or after VALIDATE phase completion.
**Trigger:** `State.Status == READY` and `State.CurrentItem == null` OR after `VALIDATE` phase completion.
**Action:**
1. Check `## Items` section for more items.
2. If more items:
3. Set `State.CurrentItem` to the next item.
4. Clear `## Log`.
5. Set `State.Phase = ANALYZE`, `State.Status = READY`.
6. Log "Starting processing item [State.CurrentItem]".
7. If no more items:
8. Trigger `RULE_ITERATE_02`.
RULE_ITERATE_02:
**Trigger:** `RULE_ITERATE_01` determines no more items.
**Action:**
1. Set `State.Status = COMPLETED_ITERATION`.
2. Log "Tokenization iteration completed."
**# --- Initialization & Resumption Rules ---**
RULE_INIT_01:
**Trigger:** AI session/task starts AND `workflow_state.md` is missing or empty.
**Action:**
1. Create `workflow_state.md` with default structure.
2. Read `project_config.md` (prompt user if missing).
3. Set `State.Phase = ANALYZE`, `State.Status = READY`.
4. Log "Initialized new session."
5. Prompt user for the first task.
RULE_INIT_02:
**Trigger:** AI session/task starts AND `workflow_state.md` exists.
**Action:**
1. Read `project_config.md`.
2. Read existing `workflow_state.md`.
3. Log "Resumed session."
4. Check `State.Status`: Handle READY, COMPLETED, BLOCKED_*, NEEDS_*, IN_PROGRESS appropriately (prompt user or report status).
RULE_INIT_03:
**Trigger:** User confirms continuation via RULE_INIT_02 (for IN_PROGRESS state).
**Action:** Proceed with the next action based on loaded state and rules.
**# --- Memory Management Rules ---**
RULE_MEM_READ_LTM_01:
**Trigger:** Start of a new major task or phase.
**Action:** Read `project_config.md`. Log action.
RULE_MEM_READ_STM_01:
**Trigger:** Before *every* decision/action cycle.
**Action:**
1. Read `workflow_state.md`.
2. If `State.Status == READY` and `State.CurrentItem == null`:
3. Log "Attempting to trigger RULE_ITERATE_01".
4. Trigger `RULE_ITERATE_01`.
RULE_MEM_UPDATE_STM_01:
**Trigger:** After *every* significant action or information receipt.
**Action:** Immediately update relevant sections (`## State`, `## Plan`, `## Log`) in `workflow_state.md` and save.
RULE_MEM_UPDATE_LTM_01:
**Trigger:** User command (`@config/update`) OR end of successful VALIDATE phase for significant change.
**Action:** Propose concise updates to `project_config.md` based on `## Log`/diffs. Set `State.Status = NEEDS_LTM_APPROVAL`. Await user confirmation.
RULE_MEM_VALIDATE_01:
**Trigger:** After updating `workflow_state.md` or `project_config.md`.
**Action:** Perform internal consistency check. If issues found, log and set `State.Status = NEEDS_CLARIFICATION`.
**# --- Tool Integration Rules (Cursor Environment) ---**
RULE_TOOL_LINT_01:
**Trigger:** Relevant source file saved during CONSTRUCT phase.
**Action:** Instruct Cursor terminal to run lint command. Log attempt. On completion, parse output, log result, set `State.Status = BLOCKED_LINT` if errors.
RULE_TOOL_FORMAT_01:
**Trigger:** Relevant source file saved during CONSTRUCT phase.
**Action:** Instruct Cursor to apply formatter or run format command via terminal. Log attempt.
RULE_TOOL_TEST_RUN_01:
**Trigger:** Command `@validate` or entering VALIDATE phase.
**Action:** Instruct Cursor terminal to run test suite. Log attempt. On completion, parse output, log result, set `State.Status = BLOCKED_TEST` if failures, `TESTS_PASSED` if success.
RULE_TOOL_APPLY_CODE_01:
**Trigger:** AI determines code change needed per `## Plan` during CONSTRUCT phase.
RULE_PROCESS_ITEM_01:
**Trigger:** `State.Phase == CONSTRUCT` and `State.CurrentItem` is not null and current step in `## Plan` requires item processing.
**Action:**
1. **Get Item Text:** Based on `State.CurrentItem`, extract the corresponding 'Text to Tokenize' from the `## Items` section.
2. **Summarize (Placeholder):** Use a placeholder to generate a summary of the extracted text. For example, "Summary of [text] is [placeholder summary]".
3. **Estimate Token Count:**
a. Read `Characters Per Token (Estimate)` from `project_config.md`.
b. Get the text content of the item from the `## Items` section. (Placeholder: Implement logic to extract text based on `State.CurrentItem` from the `## Items` table.)
c. Calculate `estimated_tokens = length(text_content) / 4`.
4. **Summarize (Placeholder):** Use a placeholder to generate a summary of the extracted text. For example, "Summary of [text] is [placeholder summary]". (Placeholder: Replace with actual summarization tool/logic)
5. **Store Results:** Append a new row to the `## TokenizationResults` table with:
* `Item ID`: `State.CurrentItem`
* `Summary`: The generated summary. (Placeholder: Implement logic to store the summary.)
* `Token Count`: `estimated_tokens`.
6. Log the processing actions, results, and estimated token count to the `## Log`. (Placeholder: Implement logging.)
**Action:** Generate modification. Instruct Cursor to apply it. Log action.
**# --- Error Handling & Recovery Rules ---**
RULE_ERR_HANDLE_LINT_01:
**Trigger:** `State.Status` is `BLOCKED_LINT`.
**Action:** Analyze error in `## Log`. Attempt auto-fix if simple/confident. Apply fix via RULE_TOOL_APPLY_CODE_01. Re-run lint via RULE_TOOL_LINT_01. If success, reset `State.Status`. If fail/complex, set `State.Status = BLOCKED_LINT_UNRESOLVED`, report to user.
RULE_ERR_HANDLE_TEST_01:
**Trigger:** `State.Status` is `BLOCKED_TEST`.
**Action:** Analyze failure in `## Log`. Attempt auto-fix if simple/localized/confident. Apply fix via RULE_TOOL_APPLY_CODE_01. Re-run failed test(s) or suite via RULE_TOOL_TEST_RUN_01. If success, reset `State.Status`. If fail/complex, set `State.Phase = BLUEPRINT_REVISE`, `State.Status = NEEDS_PLAN_APPROVAL`, propose revised `## Plan` based on failure analysis, report to user.
RULE_ERR_HANDLE_GENERAL_01:
**Trigger:** Unexpected error or ambiguity.
**Action:** Log error/situation to `## Log`. Set `State.Status = BLOCKED_UNKNOWN`. Report to user, request instructions.
---
## Log
*A chronological log of significant actions, events, tool outputs, and decisions.*
*(This section will be populated by the AI during operation)*
* `[2023-11-18 13:45:00] Initialized new session. State set to ANALYZE/READY.`
* `