From 7ee1469b2e848d84b3ee25141b5b8c01496271cb Mon Sep 17 00:00:00 2001 From: ilia gurielidze Date: Fri, 16 May 2025 18:15:31 +0400 Subject: [PATCH] first commit --- .cursorignore | 34 ++ .gitignore | 87 ++++++ README.md | 346 ++++++++++++++++++++ app_runner.py | 73 +++++ config_manager.py | 350 +++++++++++++++++++++ gui/button_frame.py | 79 +++++ gui/element_mapping_frame.py | 461 +++++++++++++++++++++++++++ gui/file_handler.py | 174 +++++++++++ gui/form_frame.py | 63 ++++ gui/log_frame.py | 125 ++++++++ gui/progress_frame.py | 71 +++++ gui/results_frame.py | 197 ++++++++++++ gui/scada_frame.py | 97 ++++++ gui/status_bar.py | 67 ++++ gui/theme_manager.py | 100 ++++++ gui/thread_manager.py | 131 ++++++++ processing/__init__.py | 4 + processing/element_mapper.py | 96 ++++++ processing/element_processor.py | 254 +++++++++++++++ processing/geometry_extractor.py | 190 +++++++++++ processing/inkscape_transform.py | 441 ++++++++++++++++++++++++++ processing/json_builder.py | 151 +++++++++ processing/notebook_manager.py | 520 +++++++++++++++++++++++++++++++ processing/position_utils.py | 33 ++ processing/prefix_resolver.py | 41 +++ processing/processor.py | 84 +++++ processing/scada_exporter.py | 470 ++++++++++++++++++++++++++++ project_config.md | 54 ++++ requirements.txt | 4 + test_svg_modules.py | 29 ++ utils.py | 218 +++++++++++++ workflow_state.md | 227 ++++++++++++++ 32 files changed, 5271 insertions(+) create mode 100644 .cursorignore create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app_runner.py create mode 100644 config_manager.py create mode 100644 gui/button_frame.py create mode 100644 gui/element_mapping_frame.py create mode 100644 gui/file_handler.py create mode 100644 gui/form_frame.py create mode 100644 gui/log_frame.py create mode 100644 gui/progress_frame.py create mode 100644 gui/results_frame.py create mode 100644 gui/scada_frame.py create mode 100644 gui/status_bar.py create mode 100644 gui/theme_manager.py create mode 100644 gui/thread_manager.py create mode 100644 processing/__init__.py create mode 100644 processing/element_mapper.py create mode 100644 processing/element_processor.py create mode 100644 processing/geometry_extractor.py create mode 100644 processing/inkscape_transform.py create mode 100644 processing/json_builder.py create mode 100644 processing/notebook_manager.py create mode 100644 processing/position_utils.py create mode 100644 processing/prefix_resolver.py create mode 100644 processing/processor.py create mode 100644 processing/scada_exporter.py create mode 100644 project_config.md create mode 100644 requirements.txt create mode 100644 test_svg_modules.py create mode 100644 utils.py create mode 100644 workflow_state.md diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..bf02c6a --- /dev/null +++ b/.cursorignore @@ -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/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2cd385f --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ee0cb28 --- /dev/null +++ b/README.md @@ -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 `` 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 `` 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 + 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: + ``` + / + ├── 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! \ No newline at end of file diff --git a/app_runner.py b/app_runner.py new file mode 100644 index 0000000..ff25e05 --- /dev/null +++ b/app_runner.py @@ -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) \ No newline at end of file diff --git a/config_manager.py b/config_manager.py new file mode 100644 index 0000000..37f2ad4 --- /dev/null +++ b/config_manager.py @@ -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) \ No newline at end of file diff --git a/gui/button_frame.py b/gui/button_frame.py new file mode 100644 index 0000000..26bee74 --- /dev/null +++ b/gui/button_frame.py @@ -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() \ No newline at end of file diff --git a/gui/element_mapping_frame.py b/gui/element_mapping_frame.py new file mode 100644 index 0000000..1682fd6 --- /dev/null +++ b/gui/element_mapping_frame.py @@ -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("", 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("", self._on_mousewheel, add='+') + widget.bind("", self._on_mousewheel, add='+') + # Windows + widget.bind("", 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() \ No newline at end of file diff --git a/gui/file_handler.py b/gui/file_handler.py new file mode 100644 index 0000000..b2894f3 --- /dev/null +++ b/gui/file_handler.py @@ -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() \ No newline at end of file diff --git a/gui/form_frame.py b/gui/form_frame.py new file mode 100644 index 0000000..bceb3d2 --- /dev/null +++ b/gui/form_frame.py @@ -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() \ No newline at end of file diff --git a/gui/log_frame.py b/gui/log_frame.py new file mode 100644 index 0000000..d7318cf --- /dev/null +++ b/gui/log_frame.py @@ -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() \ No newline at end of file diff --git a/gui/progress_frame.py b/gui/progress_frame.py new file mode 100644 index 0000000..b8410d3 --- /dev/null +++ b/gui/progress_frame.py @@ -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() \ No newline at end of file diff --git a/gui/results_frame.py b/gui/results_frame.py new file mode 100644 index 0000000..945152c --- /dev/null +++ b/gui/results_frame.py @@ -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() \ No newline at end of file diff --git a/gui/scada_frame.py b/gui/scada_frame.py new file mode 100644 index 0000000..8f08975 --- /dev/null +++ b/gui/scada_frame.py @@ -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() \ No newline at end of file diff --git a/gui/status_bar.py b/gui/status_bar.py new file mode 100644 index 0000000..14bfd81 --- /dev/null +++ b/gui/status_bar.py @@ -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() \ No newline at end of file diff --git a/gui/theme_manager.py b/gui/theme_manager.py new file mode 100644 index 0000000..f7d38e9 --- /dev/null +++ b/gui/theme_manager.py @@ -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'} \ No newline at end of file diff --git a/gui/thread_manager.py b/gui/thread_manager.py new file mode 100644 index 0000000..26db7bb --- /dev/null +++ b/gui/thread_manager.py @@ -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() \ No newline at end of file diff --git a/processing/__init__.py b/processing/__init__.py new file mode 100644 index 0000000..5573b6a --- /dev/null +++ b/processing/__init__.py @@ -0,0 +1,4 @@ +# SVG Processing Module +""" +This package provides tools for processing SVG files and converting them to various formats. +""" \ No newline at end of file diff --git a/processing/element_mapper.py b/processing/element_mapper.py new file mode 100644 index 0000000..008702c --- /dev/null +++ b/processing/element_mapper.py @@ -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 \ No newline at end of file diff --git a/processing/element_processor.py b/processing/element_processor.py new file mode 100644 index 0000000..6482836 --- /dev/null +++ b/processing/element_processor.py @@ -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 \ No newline at end of file diff --git a/processing/geometry_extractor.py b/processing/geometry_extractor.py new file mode 100644 index 0000000..a88f23d --- /dev/null +++ b/processing/geometry_extractor.py @@ -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() \ No newline at end of file diff --git a/processing/inkscape_transform.py b/processing/inkscape_transform.py new file mode 100644 index 0000000..82ffbf6 --- /dev/null +++ b/processing/inkscape_transform.py @@ -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 \ No newline at end of file diff --git a/processing/json_builder.py b/processing/json_builder.py new file mode 100644 index 0000000..cfbaa09 --- /dev/null +++ b/processing/json_builder.py @@ -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 \ No newline at end of file diff --git a/processing/notebook_manager.py b/processing/notebook_manager.py new file mode 100644 index 0000000..e0e5f33 --- /dev/null +++ b/processing/notebook_manager.py @@ -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("", on_frame_configure) + canvas.bind('', 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("", lambda e: self._on_mousewheel(e, widget, -1), add='+') + widget.bind("", lambda e: self._on_mousewheel(e, widget, 1), add='+') + else: # Windows and macOS + widget.bind("", 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() \ No newline at end of file diff --git a/processing/position_utils.py b/processing/position_utils.py new file mode 100644 index 0000000..03a7b53 --- /dev/null +++ b/processing/position_utils.py @@ -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 \ No newline at end of file diff --git a/processing/prefix_resolver.py b/processing/prefix_resolver.py new file mode 100644 index 0000000..0e34c2a --- /dev/null +++ b/processing/prefix_resolver.py @@ -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" \ No newline at end of file diff --git a/processing/processor.py b/processing/processor.py new file mode 100644 index 0000000..f3e560f --- /dev/null +++ b/processing/processor.py @@ -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 \ No newline at end of file diff --git a/processing/scada_exporter.py b/processing/scada_exporter.py new file mode 100644 index 0000000..6495df2 --- /dev/null +++ b/processing/scada_exporter.py @@ -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 \ No newline at end of file diff --git a/project_config.md b/project_config.md new file mode 100644 index 0000000..136e69c --- /dev/null +++ b/project_config.md @@ -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 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5ac30b8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +numpy>=1.21.0 +Pillow>=9.0.0 +pyinstaller>=6.0.0 +lxml>=4.9.0 \ No newline at end of file diff --git a/test_svg_modules.py b/test_svg_modules.py new file mode 100644 index 0000000..8608cce --- /dev/null +++ b/test_svg_modules.py @@ -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!") \ No newline at end of file diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..5ff3cef --- /dev/null +++ b/utils.py @@ -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}") \ No newline at end of file diff --git a/workflow_state.md b/workflow_state.md new file mode 100644 index 0000000..fb4b364 --- /dev/null +++ b/workflow_state.md @@ -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.` +* ` \ No newline at end of file