diff --git a/README.md b/README.md index 49fd398..47b2a3a 100644 --- a/README.md +++ b/README.md @@ -344,3 +344,172 @@ def info(self) -> ModuleInfo: ``` The module manager will automatically load dependencies before your module. + +--- + +## Command System (Undo/Redo) + +Synodetik provides a command pattern implementation for undo/redo functionality. Modules can create custom undoable commands and integrate with the application's undo/redo stack. + +### Keyboard Shortcuts + +| Action | Shortcut | +|--------|----------| +| Undo | `Ctrl+Z` | +| Redo | `Ctrl+Y` or `Ctrl+Shift+Z` | + +### Using the Command Manager + +```python +from synodetik.core.commands import get_command_manager + +# Get the global command manager +manager = get_command_manager() + +# Check state +if manager.can_undo: + print(f"Can undo: {manager.undo_description}") + +if manager.can_redo: + print(f"Can redo: {manager.redo_description}") + +# Execute undo/redo +manager.undo() +manager.redo() +``` + +### Creating Custom Commands + +Create undoable commands by subclassing `Command`: + +```python +from synodetik.core.commands import Command, get_command_manager + + +class ChangeColorCommand(Command): + """Command to change a machine's color.""" + + def __init__(self, machine, new_color: str): + super().__init__() + self._machine = machine + self._new_color = new_color + self._old_color = machine.color # Store for undo + + @property + def description(self) -> str: + return f"Change color of '{self._machine.name}'" + + def execute(self) -> None: + """Apply the new color.""" + self._machine.color = self._new_color + # Update UI if needed + + def undo(self) -> None: + """Restore the old color.""" + self._machine.color = self._old_color + # Update UI if needed + + +# Execute the command (adds to undo stack) +cmd = ChangeColorCommand(machine, "#FF5500") +get_command_manager().execute(cmd) +``` + +### Built-in Commands + +| Command | Description | +|---------|-------------| +| `AddMachineCommand` | Add a machine to the synoptik | +| `RemoveMachineCommand` | Remove a machine and its connections | +| `MoveMachineCommand` | Move a machine to a new position | +| `CreateConnectionCommand` | Create a connection between ports | +| `RemoveConnectionCommand` | Remove a connection | + +### Batch Commands + +Group multiple commands into a single undo operation: + +```python +manager = get_command_manager() + +# Start batch mode +manager.begin_batch() + +# Execute multiple commands +manager.execute(cmd1) +manager.execute(cmd2) +manager.execute(cmd3) + +# End batch - all commands become one undo operation +manager.end_batch("Import machines") + +# Now undo() will undo all three commands at once +``` + +### State Change Notifications + +Listen for undo/redo state changes (useful for updating UI): + +```python +def on_state_changed(state): + """Called when undo/redo state changes.""" + print(f"Can undo: {state.can_undo} ({state.undo_description})") + print(f"Can redo: {state.can_redo} ({state.redo_description})") + print(f"History: {state.undo_count} undo, {state.redo_count} redo") + +get_command_manager().on_state_changed = on_state_changed +``` + +### Example: Undoable Module Action + +Complete example of a module with undoable actions: + +```python +from synodetik.modules import Module, ModuleInfo, MenuItem +from synodetik.core.commands import Command, get_command_manager + + +class SetNoteCommand(Command): + """Command to set a note on a machine.""" + + def __init__(self, machine, note: str): + super().__init__() + self._machine = machine + self._new_note = note + self._old_note = getattr(machine, 'note', '') + + @property + def description(self) -> str: + return f"Set note on '{self._machine.name}'" + + def execute(self) -> None: + self._machine.note = self._new_note + + def undo(self) -> None: + self._machine.note = self._old_note + + +class NotesModule(Module): + """Module that adds notes to machines.""" + + @property + def info(self) -> ModuleInfo: + return ModuleInfo( + id="notes", + name="Machine Notes", + version="1.0.0", + author="Developer", + description="Add notes to machines" + ) + + def on_load(self) -> bool: + return True + + def on_unload(self) -> None: + pass + + def set_note(self, machine, note: str): + """Set a note on a machine (undoable).""" + cmd = SetNoteCommand(machine, note) + get_command_manager().execute(cmd) +``` diff --git a/gl_audiovisual_power.png b/gl_audiovisual_power.png new file mode 100644 index 0000000..bb664bc Binary files /dev/null and b/gl_audiovisual_power.png differ diff --git a/requirements.txt b/requirements.txt index 835f7fe..8e71ada 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ dearpygui>=1.9.0 pyyaml>=6.0 +reportlab \ No newline at end of file diff --git a/synodetik/__init__.py b/synodetik/__init__.py index 98c7b66..e9250a1 100644 --- a/synodetik/__init__.py +++ b/synodetik/__init__.py @@ -22,6 +22,12 @@ from synodetik.core import ( # Events EventType, Event, EventDispatcher, get_dispatcher, + + # Commands (undo/redo) + Command, CommandManager, CommandManagerState, + get_command_manager, set_command_manager, + AddMachineCommand, RemoveMachineCommand, MoveMachineCommand, + CreateConnectionCommand, RemoveConnectionCommand, ) from synodetik.modules import ( @@ -30,7 +36,7 @@ from synodetik.modules import ( ) -__version__ = "0.4.0" +__version__ = "0.5.0" __all__ = [ # Models @@ -54,4 +60,10 @@ __all__ = [ # Modules 'Module', 'ModuleInfo', 'MenuItem', 'ModuleError', 'ModuleManager', + + # Commands + 'Command', 'CommandManager', 'CommandManagerState', + 'get_command_manager', 'set_command_manager', + 'AddMachineCommand', 'RemoveMachineCommand', 'MoveMachineCommand', + 'CreateConnectionCommand', 'RemoveConnectionCommand', ] diff --git a/synodetik/core/__init__.py b/synodetik/core/__init__.py index f7007f4..24a3a0c 100644 --- a/synodetik/core/__init__.py +++ b/synodetik/core/__init__.py @@ -24,7 +24,7 @@ from synodetik.core.model import ( ) from synodetik.core.port import Port, PortDirection, create_input, create_output, create_full_duplex from synodetik.core.connection import Connection -from synodetik.core.synoptik import Synoptik +from synodetik.core.synoptik import Synoptik, TitleBlock # Library from synodetik.core.library import Library, get_library @@ -35,6 +35,20 @@ from synodetik.core.config import ConfigManager, AppConfig, get_config_manager # Events from synodetik.core.events import EventDispatcher, EventType, Event, get_dispatcher +# Commands (undo/redo) +from synodetik.core.commands import ( + Command, + CommandManager, + CommandManagerState, + get_command_manager, + set_command_manager, + AddMachineCommand, + RemoveMachineCommand, + MoveMachineCommand, + CreateConnectionCommand, + RemoveConnectionCommand, +) + __all__ = [ # Models @@ -42,7 +56,7 @@ __all__ = [ "Flux", "create_flux_type", "Machine", "create_machine", "Port", "PortDirection", "create_input", "create_output", "create_full_duplex", - "Connection", "Synoptik", + "Connection", "Synoptik", "TitleBlock", # Library "Library", "get_library", @@ -52,4 +66,16 @@ __all__ = [ # Events "EventDispatcher", "EventType", "Event", "get_dispatcher", + + # Commands + "Command", + "CommandManager", + "CommandManagerState", + "get_command_manager", + "set_command_manager", + "AddMachineCommand", + "RemoveMachineCommand", + "MoveMachineCommand", + "CreateConnectionCommand", + "RemoveConnectionCommand", ] diff --git a/synodetik/core/commands/__init__.py b/synodetik/core/commands/__init__.py new file mode 100644 index 0000000..9001265 --- /dev/null +++ b/synodetik/core/commands/__init__.py @@ -0,0 +1,104 @@ +""" +Command pattern implementation for undo/redo functionality. + +This module provides a complete undo/redo system that can be extended +by third-party modules. + +Basic Usage: + from synodetik.core.commands import get_command_manager, AddMachineCommand + + # Execute a command (adds to undo stack) + cmd = AddMachineCommand(machine, synoptik, node_editor) + get_command_manager().execute(cmd) + + # Undo last action + get_command_manager().undo() + + # Redo last undone action + get_command_manager().redo() + +Creating Custom Commands: + from synodetik.core.commands import Command, get_command_manager + + class MyCommand(Command): + def __init__(self, data): + super().__init__() + self._data = data + + @property + def description(self) -> str: + return "My custom action" + + def execute(self) -> None: + # Perform the action + pass + + def undo(self) -> None: + # Reverse the action + pass + + # Execute custom command + get_command_manager().execute(MyCommand(data)) + +Batch Commands: + manager = get_command_manager() + + # Group multiple commands as one undo operation + manager.begin_batch() + manager.execute(cmd1) + manager.execute(cmd2) + manager.execute(cmd3) + manager.end_batch("Import multiple machines") + + # Now undo() will undo all three at once + +State Callbacks: + def on_state_changed(state): + print(f"Can undo: {state.can_undo}") + print(f"Can redo: {state.can_redo}") + print(f"Undo: {state.undo_description}") + + get_command_manager().on_state_changed = on_state_changed +""" + +from synodetik.core.commands.base import ( + Command, + CommandManager, + CommandManagerState, + BatchCommand, + get_command_manager, + set_command_manager, +) + +from synodetik.core.commands.machine import ( + AddMachineCommand, + RemoveMachineCommand, + MoveMachineCommand, +) + +from synodetik.core.commands.connection import ( + CreateConnectionCommand, + RemoveConnectionCommand, +) + + +__all__ = [ + # Base classes + "Command", + "CommandManager", + "CommandManagerState", + "BatchCommand", + + # Global access + "get_command_manager", + "set_command_manager", + + # Machine commands + "AddMachineCommand", + "RemoveMachineCommand", + "MoveMachineCommand", + + # Connection commands + "CreateConnectionCommand", + "RemoveConnectionCommand", +] diff --git a/synodetik/core/commands/base.py b/synodetik/core/commands/base.py new file mode 100644 index 0000000..0258f95 --- /dev/null +++ b/synodetik/core/commands/base.py @@ -0,0 +1,391 @@ +""" +Command pattern base classes for undo/redo functionality. + +This module provides the foundation for implementing undoable operations +in Synodetik. Third-party modules can create custom commands by subclassing +the Command base class. + +Example: + from synodetik.core.commands import Command, get_command_manager + + class MyCustomCommand(Command): + def __init__(self, data): + self._data = data + + @property + def description(self) -> str: + return "My custom action" + + def execute(self) -> None: + # Perform the action + pass + + def undo(self) -> None: + # Reverse the action + pass + + # Execute the command (adds to undo stack) + cmd = MyCustomCommand(data) + get_command_manager().execute(cmd) +""" +from abc import ABC, abstractmethod +from typing import Optional, Callable +from dataclasses import dataclass, field + + +class Command(ABC): + """ + Abstract base class for undoable commands. + + Subclass this to create custom undoable operations. + Each command must be able to execute and undo itself. + + Attributes: + _executed: Whether the command has been executed + """ + + def __init__(self): + self._executed: bool = False + + @property + @abstractmethod + def description(self) -> str: + """ + Human-readable description of the command. + + Used for UI display (e.g., "Undo: Add machine Camera 1"). + + Returns: + Description string + """ + pass + + @abstractmethod + def execute(self) -> None: + """ + Execute the command. + + This method performs the actual operation. + After execution, the command should be in a state where + undo() can reverse it. + """ + pass + + @abstractmethod + def undo(self) -> None: + """ + Reverse the command. + + This method undoes the operation performed by execute(). + After undo, the command should be in a state where + execute() can redo it. + """ + pass + + def redo(self) -> None: + """ + Redo the command. + + By default, this just calls execute() again. + Override if redo requires different logic. + """ + self.execute() + + +@dataclass +class CommandManagerState: + """State snapshot for debugging and UI.""" + can_undo: bool + can_redo: bool + undo_description: Optional[str] + redo_description: Optional[str] + undo_count: int + redo_count: int + + +class CommandManager: + """ + Manages command execution and undo/redo stacks. + + The CommandManager maintains two stacks: + - Undo stack: Commands that can be undone + - Redo stack: Commands that have been undone and can be redone + + When a new command is executed, the redo stack is cleared. + + Attributes: + max_history: Maximum number of commands to keep in history + + Events: + on_state_changed: Called when undo/redo state changes + + Example: + manager = CommandManager(max_history=50) + + # Execute a command + manager.execute(AddMachineCommand(machine, synoptik)) + + # Undo last command + if manager.can_undo: + manager.undo() + + # Redo last undone command + if manager.can_redo: + manager.redo() + """ + + def __init__(self, max_history: int = 50): + """ + Initialize the command manager. + + Args: + max_history: Maximum commands to keep in undo history + """ + self._undo_stack: list[Command] = [] + self._redo_stack: list[Command] = [] + self._max_history = max_history + self._on_state_changed: Optional[Callable[[CommandManagerState], None]] = None + self._batch_mode: bool = False + self._batch_commands: list[Command] = [] + + @property + def max_history(self) -> int: + """Maximum number of commands in undo history.""" + return self._max_history + + @max_history.setter + def max_history(self, value: int) -> None: + """Set maximum history and trim if necessary.""" + self._max_history = max(1, value) + self._trim_history() + + @property + def can_undo(self) -> bool: + """Whether there are commands to undo.""" + return len(self._undo_stack) > 0 + + @property + def can_redo(self) -> bool: + """Whether there are commands to redo.""" + return len(self._redo_stack) > 0 + + @property + def undo_description(self) -> Optional[str]: + """Description of the next command to undo.""" + if self._undo_stack: + return self._undo_stack[-1].description + return None + + @property + def redo_description(self) -> Optional[str]: + """Description of the next command to redo.""" + if self._redo_stack: + return self._redo_stack[-1].description + return None + + @property + def on_state_changed(self) -> Optional[Callable[[CommandManagerState], None]]: + """Callback when undo/redo state changes.""" + return self._on_state_changed + + @on_state_changed.setter + def on_state_changed(self, callback: Optional[Callable[[CommandManagerState], None]]) -> None: + """Set state change callback.""" + self._on_state_changed = callback + + def execute(self, command: Command) -> None: + """ + Execute a command and add it to the undo stack. + + Clears the redo stack since we're branching history. + + Args: + command: The command to execute + """ + if self._batch_mode: + # In batch mode, collect commands without executing + self._batch_commands.append(command) + return + + command.execute() + command._executed = True + + self._undo_stack.append(command) + self._redo_stack.clear() + + self._trim_history() + self._notify_state_changed() + + def undo(self) -> bool: + """ + Undo the last executed command. + + Returns: + True if a command was undone, False if stack was empty + """ + if not self.can_undo: + return False + + command = self._undo_stack.pop() + command.undo() + command._executed = False + + self._redo_stack.append(command) + self._notify_state_changed() + + return True + + def redo(self) -> bool: + """ + Redo the last undone command. + + Returns: + True if a command was redone, False if stack was empty + """ + if not self.can_redo: + return False + + command = self._redo_stack.pop() + command.redo() + command._executed = True + + self._undo_stack.append(command) + self._notify_state_changed() + + return True + + def clear(self) -> None: + """Clear all undo and redo history.""" + self._undo_stack.clear() + self._redo_stack.clear() + self._notify_state_changed() + + def get_state(self) -> CommandManagerState: + """Get current state for UI display.""" + return CommandManagerState( + can_undo=self.can_undo, + can_redo=self.can_redo, + undo_description=self.undo_description, + redo_description=self.redo_description, + undo_count=len(self._undo_stack), + redo_count=len(self._redo_stack) + ) + + def begin_batch(self) -> None: + """ + Begin a batch of commands that will be grouped as one undo operation. + + Use this when multiple commands should be undone together. + + Example: + manager.begin_batch() + manager.execute(cmd1) + manager.execute(cmd2) + manager.end_batch("Multiple operations") + # Now undo() will undo both cmd1 and cmd2 + """ + self._batch_mode = True + self._batch_commands = [] + + def end_batch(self, description: str = "Batch operation") -> None: + """ + End a batch and create a composite command. + + Args: + description: Description for the composite command + """ + if not self._batch_mode: + return + + self._batch_mode = False + commands = self._batch_commands + self._batch_commands = [] + + if not commands: + return + + # Execute all commands and create composite + for cmd in commands: + cmd.execute() + cmd._executed = True + + composite = BatchCommand(commands, description) + composite._executed = True + + self._undo_stack.append(composite) + self._redo_stack.clear() + + self._trim_history() + self._notify_state_changed() + + def cancel_batch(self) -> None: + """Cancel a batch without executing commands.""" + self._batch_mode = False + self._batch_commands = [] + + def _trim_history(self) -> None: + """Remove oldest commands if history exceeds max.""" + while len(self._undo_stack) > self._max_history: + self._undo_stack.pop(0) + + def _notify_state_changed(self) -> None: + """Notify listener of state change.""" + if self._on_state_changed: + self._on_state_changed(self.get_state()) + + +class BatchCommand(Command): + """ + A composite command that groups multiple commands. + + Used internally by CommandManager.begin_batch() / end_batch(). + """ + + def __init__(self, commands: list[Command], description: str = "Batch operation"): + super().__init__() + self._commands = commands + self._description = description + + @property + def description(self) -> str: + return self._description + + def execute(self) -> None: + """Execute all commands in order.""" + for cmd in self._commands: + cmd.execute() + + def undo(self) -> None: + """Undo all commands in reverse order.""" + for cmd in reversed(self._commands): + cmd.undo() + + +# Global command manager instance +_command_manager: Optional[CommandManager] = None + + +def get_command_manager() -> CommandManager: + """ + Get the global command manager instance. + + Returns: + The singleton CommandManager + """ + global _command_manager + if _command_manager is None: + _command_manager = CommandManager() + return _command_manager + + +def set_command_manager(manager: CommandManager) -> None: + """ + Set the global command manager instance. + + Useful for testing or custom configurations. + + Args: + manager: The CommandManager to use globally + """ + global _command_manager + _command_manager = manager diff --git a/synodetik/core/commands/connection.py b/synodetik/core/commands/connection.py new file mode 100644 index 0000000..7ca4fb5 --- /dev/null +++ b/synodetik/core/commands/connection.py @@ -0,0 +1,130 @@ +""" +Connection-related commands for undo/redo. + +Commands for creating and removing connections between ports. +""" +from typing import TYPE_CHECKING, Optional + +from synodetik.core.commands.base import Command + +if TYPE_CHECKING: + from synodetik.core import Connection, Synoptik + from synodetik.gui.components.node_editor import NodeEditorPanel + + +class CreateConnectionCommand(Command): + """ + Command to create a connection between two ports. + + Args: + source_port_id: Output port ID + target_port_id: Input port ID + synoptik: The synoptik to add connection to + node_editor: Optional node editor for UI updates + """ + + def __init__( + self, + source_port_id: str, + target_port_id: str, + synoptik: 'Synoptik', + node_editor: Optional['NodeEditorPanel'] = None + ): + super().__init__() + self._source_port_id = source_port_id + self._target_port_id = target_port_id + self._synoptik = synoptik + self._node_editor = node_editor + self._connection: Optional['Connection'] = None + + @property + def description(self) -> str: + return "Create connection" + + @property + def connection(self) -> Optional['Connection']: + """The created connection (available after execute).""" + return self._connection + + def execute(self) -> None: + """Create the connection in model and UI.""" + # Create in model + self._connection = self._synoptik.connect( + self._source_port_id, + self._target_port_id + ) + + if not self._connection: + return + + # Add to UI + if self._node_editor: + self._node_editor.add_connection(self._connection) + + def undo(self) -> None: + """Remove the connection from model and UI.""" + if not self._connection: + return + + # Remove from UI + if self._node_editor: + self._node_editor.remove_connection(self._connection.id) + + # Remove from model + self._synoptik.disconnect(self._connection.id) + + +class RemoveConnectionCommand(Command): + """ + Command to remove a connection. + + Stores the connection for undo. + + Args: + connection_id: ID of the connection to remove + synoptik: The synoptik containing the connection + node_editor: Optional node editor for UI updates + """ + + def __init__( + self, + connection_id: str, + synoptik: 'Synoptik', + node_editor: Optional['NodeEditorPanel'] = None + ): + super().__init__() + self._connection_id = connection_id + self._synoptik = synoptik + self._node_editor = node_editor + self._connection: Optional['Connection'] = None + + @property + def description(self) -> str: + return "Remove connection" + + def execute(self) -> None: + """Remove the connection and store it for undo.""" + # Store connection before removal + self._connection = self._synoptik.get_connection(self._connection_id) + if not self._connection: + return + + # Remove from UI + if self._node_editor: + self._node_editor.remove_connection(self._connection_id) + + # Remove from model + self._synoptik.disconnect(self._connection_id) + + def undo(self) -> None: + """Restore the connection.""" + if not self._connection: + return + + # Restore to model + self._synoptik.connections.append(self._connection) + self._synoptik.touch() + + # Restore to UI + if self._node_editor: + self._node_editor.add_connection(self._connection) diff --git a/synodetik/core/commands/machine.py b/synodetik/core/commands/machine.py new file mode 100644 index 0000000..65eeb15 --- /dev/null +++ b/synodetik/core/commands/machine.py @@ -0,0 +1,202 @@ +""" +Machine-related commands for undo/redo. + +Commands for adding, removing, and moving machines in the synoptik. +""" +from typing import TYPE_CHECKING, Optional + +from synodetik.core.commands.base import Command + +if TYPE_CHECKING: + from synodetik.core import Machine, Synoptik + from synodetik.gui.components.node_editor import NodeEditorPanel + + +class AddMachineCommand(Command): + """ + Command to add a machine to the synoptik. + + Undo removes the machine, redo adds it back. + + Args: + machine: The machine to add + synoptik: The synoptik to add to + node_editor: Optional node editor for UI updates + """ + + def __init__( + self, + machine: 'Machine', + synoptik: 'Synoptik', + node_editor: Optional['NodeEditorPanel'] = None + ): + super().__init__() + self._machine = machine + self._synoptik = synoptik + self._node_editor = node_editor + + @property + def description(self) -> str: + return f"Add machine '{self._machine.name}'" + + def execute(self) -> None: + """Add the machine to the synoptik and UI.""" + # Add to model + if self._machine not in self._synoptik.machines: + self._synoptik.add_machine(self._machine) + + # Add to UI + if self._node_editor: + self._node_editor.add_machine(self._machine) + + def undo(self) -> None: + """Remove the machine from the synoptik and UI.""" + # Remove from UI first + if self._node_editor: + self._node_editor.remove_machine(self._machine.id) + + # Remove from model (without removing connections - they'll be handled separately) + self._synoptik.machines = [ + m for m in self._synoptik.machines if m.id != self._machine.id + ] + self._synoptik.touch() + + +class RemoveMachineCommand(Command): + """ + Command to remove a machine from the synoptik. + + Stores the machine and its connections for undo. + + Args: + machine_id: ID of the machine to remove + synoptik: The synoptik to remove from + node_editor: Optional node editor for UI updates + """ + + def __init__( + self, + machine_id: str, + synoptik: 'Synoptik', + node_editor: Optional['NodeEditorPanel'] = None + ): + super().__init__() + self._machine_id = machine_id + self._synoptik = synoptik + self._node_editor = node_editor + + # Store machine and connections for undo + self._machine: Optional['Machine'] = None + self._connections: list = [] + + @property + def description(self) -> str: + name = self._machine.name if self._machine else self._machine_id + return f"Remove machine '{name}'" + + def execute(self) -> None: + """Remove the machine and store it for undo.""" + # Store machine before removal + self._machine = self._synoptik.get_machine(self._machine_id) + if not self._machine: + return + + # Store related connections + port_ids = {p.id for p in self._machine.all_ports} + self._connections = [ + c for c in self._synoptik.connections + if c.source_port_id in port_ids or c.target_port_id in port_ids + ] + + # Remove connections from UI + if self._node_editor: + for conn in self._connections: + self._node_editor.remove_connection(conn.id) + + # Remove from UI + if self._node_editor: + self._node_editor.remove_machine(self._machine_id) + + # Remove from model (this also removes connections) + self._synoptik.remove_machine(self._machine_id) + + def undo(self) -> None: + """Restore the machine and its connections.""" + if not self._machine: + return + + # Restore machine to model + self._synoptik.add_machine(self._machine) + + # Restore machine to UI + if self._node_editor: + self._node_editor.add_machine(self._machine) + + # Restore connections + for conn in self._connections: + self._synoptik.connections.append(conn) + if self._node_editor: + self._node_editor.add_connection(conn) + + self._synoptik.touch() + + +class MoveMachineCommand(Command): + """ + Command to move a machine to a new position. + + Stores old and new positions for undo/redo. + + Args: + machine_id: ID of the machine to move + old_position: Previous position (x, y) + new_position: New position (x, y) + synoptik: The synoptik containing the machine + node_editor: Optional node editor for UI updates + """ + + def __init__( + self, + machine_id: str, + old_position: tuple[float, float], + new_position: tuple[float, float], + synoptik: 'Synoptik', + node_editor: Optional['NodeEditorPanel'] = None + ): + super().__init__() + self._machine_id = machine_id + self._old_position = old_position + self._new_position = new_position + self._synoptik = synoptik + self._node_editor = node_editor + + @property + def description(self) -> str: + machine = self._synoptik.get_machine(self._machine_id) + name = machine.name if machine else self._machine_id + return f"Move machine '{name}'" + + def execute(self) -> None: + """Move the machine to the new position.""" + self._set_position(self._new_position) + + def undo(self) -> None: + """Move the machine back to the old position.""" + self._set_position(self._old_position) + + def _set_position(self, position: tuple[float, float]) -> None: + """Set machine position in model and UI.""" + machine = self._synoptik.get_machine(self._machine_id) + if not machine: + return + + # Update model + machine.position = position + self._synoptik.touch() + + # Update UI + if self._node_editor: + import dearpygui.dearpygui as dpg + node_tag = self._node_editor._machine_to_node.get(self._machine_id) + if node_tag and dpg.does_item_exist(node_tag): + dpg.set_item_pos(node_tag, list(position)) diff --git a/synodetik/core/synoptik.py b/synodetik/core/synoptik.py index 43bbf6b..77d85ab 100644 --- a/synodetik/core/synoptik.py +++ b/synodetik/core/synoptik.py @@ -15,6 +15,86 @@ from synodetik.core.port import Port from synodetik.core.model.flux import Flux +@dataclass +class TitleBlock: + """ + Title block (cartouche) information for PDF export. + + Contains project metadata displayed in the standard + technical drawing title block. + """ + # Project information + project_name: str = "" + project_number: str = "" + + # Document information + document_title: str = "" + document_number: str = "" + + # Revision tracking + revision: str = "A" + revision_date: str = "" + revision_description: str = "Initial release" + + # People + author: str = "" + checker: str = "" + approver: str = "" + + # Organization + company_name: str = "" + department: str = "" + logo_path: str = "" # Path to company logo image + + # Additional fields + client: str = "" + location: str = "" + notes: str = "" + + def to_dict(self) -> dict: + """Serialize to dictionary.""" + return { + "project_name": self.project_name, + "project_number": self.project_number, + "document_title": self.document_title, + "document_number": self.document_number, + "revision": self.revision, + "revision_date": self.revision_date, + "revision_description": self.revision_description, + "author": self.author, + "checker": self.checker, + "approver": self.approver, + "company_name": self.company_name, + "department": self.department, + "logo_path": self.logo_path, + "client": self.client, + "location": self.location, + "notes": self.notes, + } + + @classmethod + def from_dict(cls, data: dict) -> "TitleBlock": + """Deserialize from dictionary.""" + return cls( + project_name=data.get("project_name", ""), + project_number=data.get("project_number", ""), + document_title=data.get("document_title", ""), + document_number=data.get("document_number", ""), + revision=data.get("revision", "A"), + revision_date=data.get("revision_date", ""), + revision_description=data.get("revision_description", "Initial release"), + author=data.get("author", ""), + checker=data.get("checker", ""), + approver=data.get("approver", ""), + company_name=data.get("company_name", ""), + department=data.get("department", ""), + logo_path=data.get("logo_path", ""), + client=data.get("client", ""), + location=data.get("location", ""), + notes=data.get("notes", ""), + ) + + @dataclass class Synoptik: """ @@ -35,6 +115,7 @@ class Synoptik: machines: list[Machine] = field(default_factory=list) connections: list[Connection] = field(default_factory=list) custom_flux_types: list[Flux] = field(default_factory=list) + title_block: TitleBlock = field(default_factory=TitleBlock) created_at: str = field(default_factory=lambda: datetime.now().isoformat()) modified_at: str = field(default_factory=lambda: datetime.now().isoformat()) id: str = field(default_factory=lambda: str(uuid.uuid4())) @@ -174,6 +255,7 @@ class Synoptik: "machines": [m.to_dict() for m in self.machines], "connections": [c.to_dict() for c in self.connections], "custom_flux_types": [f.to_dict() for f in self.custom_flux_types], + "title_block": self.title_block.to_dict(), "created_at": self.created_at, "modified_at": self.modified_at } @@ -193,4 +275,6 @@ class Synoptik: synoptik.custom_flux_types = [ Flux.from_dict(f) for f in data.get("custom_flux_types", []) ] + if "title_block" in data: + synoptik.title_block = TitleBlock.from_dict(data["title_block"]) return synoptik diff --git a/synodetik/gui/app.py b/synodetik/gui/app.py index f18be26..9bd2bf6 100644 --- a/synodetik/gui/app.py +++ b/synodetik/gui/app.py @@ -11,7 +11,12 @@ from typing import Optional, List from synodetik.core import ( Synoptik, Machine, Library, get_library, - get_config_manager + get_config_manager, + get_command_manager, + AddMachineCommand, + RemoveMachineCommand, + CreateConnectionCommand, + RemoveConnectionCommand, ) from synodetik.utils import serialization from synodetik.events import EventType, get_dispatcher @@ -48,6 +53,7 @@ class SynoptikApp: self.config = get_config_manager() self.events = get_dispatcher() self.library = get_library() + self.commands = get_command_manager() # UI Components (created in setup) self.palette: Optional[PalettePanel] = None @@ -121,6 +127,16 @@ class SynoptikApp: button=dpg.mvMouseButton_Right, callback=self._on_right_click ) + # Undo: Ctrl+Z + dpg.add_key_release_handler( + key=dpg.mvKey_Z, + callback=self._on_undo_redo_key + ) + # Redo: Ctrl+Y + dpg.add_key_release_handler( + key=dpg.mvKey_Y, + callback=self._on_redo_key + ) def _setup_modules(self) -> None: """Initialize and load modules.""" @@ -214,6 +230,19 @@ class SynoptikApp: # Edit menu with dpg.menu(label="Edit", tag="menu_edit"): + dpg.add_menu_item( + label="Undo", + shortcut="Ctrl+Z", + callback=self._undo, + tag="menu_undo" + ) + dpg.add_menu_item( + label="Redo", + shortcut="Ctrl+Y", + callback=self._redo, + tag="menu_redo" + ) + dpg.add_separator() dpg.add_menu_item( label="Delete Selection", shortcut="Del", @@ -264,6 +293,9 @@ class SynoptikApp: self.synoptik = Synoptik(name=name) self.current_file = None + # Clear undo/redo history + self.commands.clear() + if self.node_editor: self.node_editor.clear() if self.properties: @@ -272,15 +304,25 @@ class SynoptikApp: self.events.emit(EventType.SYNOPTIK_CREATED, {"synoptik": self.synoptik}) - def add_machine(self, machine: Machine) -> None: - """Add a machine to the current synoptik.""" + def add_machine(self, machine: Machine, use_command: bool = True) -> None: + """ + Add a machine to the current synoptik. + + Args: + machine: The machine to add + use_command: If True, use command system for undo/redo + """ if not self.synoptik: return - self.synoptik.add_machine(machine) - - if self.node_editor: - self.node_editor.add_machine(machine) + if use_command: + cmd = AddMachineCommand(machine, self.synoptik, self.node_editor) + self.commands.execute(cmd) + else: + # Direct add (used during file load) + self.synoptik.add_machine(machine) + if self.node_editor: + self.node_editor.add_machine(machine) def add_machine_from_library( self, @@ -292,24 +334,33 @@ class SynoptikApp: if machine: self.add_machine(machine) - def remove_machine(self, machine_id: str) -> None: - """Remove a machine from the synoptik.""" + def remove_machine(self, machine_id: str, use_command: bool = True) -> None: + """ + Remove a machine from the synoptik. + + Args: + machine_id: ID of the machine to remove + use_command: If True, use command system for undo/redo + """ if not self.synoptik: return - # Remove associated connections first - machine = self.synoptik.get_machine(machine_id) - if machine: - for port in machine.all_ports: - for conn in self.synoptik.get_connections_for_port(port.id): - self.node_editor.remove_connection(conn.id) - - # Remove from model - self.synoptik.remove_machine(machine_id) - - # Remove from view - if self.node_editor: - self.node_editor.remove_machine(machine_id) + if use_command: + cmd = RemoveMachineCommand(machine_id, self.synoptik, self.node_editor) + self.commands.execute(cmd) + else: + # Direct remove (used during synoptik operations) + machine = self.synoptik.get_machine(machine_id) + if machine: + for port in machine.all_ports: + for conn in self.synoptik.get_connections_for_port(port.id): + if self.node_editor: + self.node_editor.remove_connection(conn.id) + + self.synoptik.remove_machine(machine_id) + + if self.node_editor: + self.node_editor.remove_machine(machine_id) # ========================================================================= # Event Handlers @@ -325,12 +376,20 @@ class SynoptikApp: if not self.synoptik: return - connection = self.synoptik.connect(source_port_id, target_port_id) - if connection: - self.node_editor.add_connection(connection) + # Use command for undo/redo support + cmd = CreateConnectionCommand( + source_port_id, + target_port_id, + self.synoptik, + self.node_editor + ) + self.commands.execute(cmd) + + # Emit event if connection was created + if cmd.connection: self.events.emit( EventType.CONNECTION_CREATED, - {"connection": connection} + {"connection": cmd.connection} ) def _on_link_deleted(self, connection_id: str) -> None: @@ -338,7 +397,10 @@ class SynoptikApp: if not self.synoptik: return - self.synoptik.disconnect(connection_id) + # Use command for undo/redo support + cmd = RemoveConnectionCommand(connection_id, self.synoptik, self.node_editor) + self.commands.execute(cmd) + self.events.emit( EventType.CONNECTION_REMOVED, {"connection_id": connection_id} @@ -356,6 +418,39 @@ class SynoptikApp: """Handle Delete key press.""" self._delete_selected() + def _on_undo_redo_key(self, sender, app_data) -> None: + """Handle Ctrl+Z (undo) or Ctrl+Shift+Z (redo).""" + ctrl_down = dpg.is_key_down(dpg.mvKey_LControl) or dpg.is_key_down(dpg.mvKey_RControl) + shift_down = dpg.is_key_down(dpg.mvKey_LShift) or dpg.is_key_down(dpg.mvKey_RShift) + + if ctrl_down: + if shift_down: + # Ctrl+Shift+Z = Redo + self._redo() + else: + # Ctrl+Z = Undo + self._undo() + + def _on_redo_key(self, sender, app_data) -> None: + """Handle Ctrl+Y (redo).""" + ctrl_down = dpg.is_key_down(dpg.mvKey_LControl) or dpg.is_key_down(dpg.mvKey_RControl) + if ctrl_down: + self._redo() + + def _undo(self, sender=None, app_data=None) -> None: + """Undo the last command.""" + if self.commands.can_undo: + desc = self.commands.undo_description + self.commands.undo() + print(f"Undo: {desc}") + + def _redo(self, sender=None, app_data=None) -> None: + """Redo the last undone command.""" + if self.commands.can_redo: + desc = self.commands.redo_description + self.commands.redo() + print(f"Redo: {desc}") + def _on_right_click(self, sender, app_data) -> None: """Handle right click for context menu.""" mouse_pos = dpg.get_mouse_pos(local=False) @@ -454,18 +549,35 @@ class SynoptikApp: if not self.node_editor or not self.synoptik: return - # Delete connections first - for conn_id in self.node_editor.get_selected_connections(): - self.synoptik.disconnect(conn_id) - self.node_editor.remove_connection(conn_id) - self.events.emit( - EventType.CONNECTION_REMOVED, - {"connection_id": conn_id} - ) + selected_conns = self.node_editor.get_selected_connections() + selected_machines = self.node_editor.get_selected_machines() - # Delete machines - for machine_id in self.node_editor.get_selected_machines(): - self.remove_machine(machine_id) + if not selected_conns and not selected_machines: + return + + # Use batch mode if multiple items selected + use_batch = len(selected_conns) + len(selected_machines) > 1 + + if use_batch: + self.commands.begin_batch() + + try: + # Delete connections first + for conn_id in selected_conns: + cmd = RemoveConnectionCommand(conn_id, self.synoptik, self.node_editor) + self.commands.execute(cmd) + self.events.emit( + EventType.CONNECTION_REMOVED, + {"connection_id": conn_id} + ) + + # Delete machines + for machine_id in selected_machines: + cmd = RemoveMachineCommand(machine_id, self.synoptik, self.node_editor) + self.commands.execute(cmd) + finally: + if use_batch: + self.commands.end_batch("Delete selection") # ========================================================================= # Dialog Windows @@ -650,13 +762,16 @@ class SynoptikApp: self.synoptik = serialization.load(filepath) self.current_file = filepath + # Clear undo/redo history + self.commands.clear() + if self.node_editor: self.node_editor.clear() if self.properties: self.properties.set_synoptik(self.synoptik) - # Recreate nodes + # Recreate nodes (without using commands) for machine in self.synoptik.machines: self.node_editor.add_machine(machine) diff --git a/synodetik/modules/builtins/pdf_export/__init__.py b/synodetik/modules/builtins/pdf_export/__init__.py new file mode 100644 index 0000000..8710c3b --- /dev/null +++ b/synodetik/modules/builtins/pdf_export/__init__.py @@ -0,0 +1,83 @@ +""" +PDF Export Module for Synodetik. + +Exports synoptik diagrams to PDF with customizable styles, +title blocks (cartouche), and flux type legends. + +Basic Usage: + The module adds "Export to PDF..." to the File menu. + +Programmatic Usage: + from synodetik.modules.builtins.pdf_export import ( + PdfRenderer, + ExportSettings, + get_preset + ) + + # Quick export with defaults + renderer = PdfRenderer() + renderer.render(synoptik, "output.pdf") + + # Export with preset + settings = get_preset("blueprint") + renderer.render(synoptik, "output.pdf", settings) + + # Custom settings + settings = ExportSettings() + settings.page.format = PageFormat.A3 + settings.node.use_machine_color = True + renderer.render(synoptik, "output.pdf", settings) + +Style Presets: + - "light": Clean white background + - "dark": Dark theme matching editor + - "blueprint": Technical drawing style + - "minimal": Clean with thin lines + - "print": Optimized for printing +""" + +from synodetik.modules.builtins.pdf_export.module import PdfExportModule +from synodetik.modules.builtins.pdf_export.renderer import PdfRenderer +from synodetik.modules.builtins.pdf_export.styles import ( + ExportSettings, + PageStyle, + PageFormat, + PageOrientation, + NodeStyle, + ConnectionStyle, + ConnectionLineStyle, + ConnectionPathStyle, + TitleBlockStyle, + LegendStyle, + get_preset, + PRESETS, +) +from synodetik.modules.builtins.pdf_export.dialog import ExportDialog + + +__all__ = [ + # Main module + "PdfExportModule", + + # Renderer + "PdfRenderer", + + # Dialog + "ExportDialog", + + # Settings + "ExportSettings", + "PageStyle", + "PageFormat", + "PageOrientation", + "NodeStyle", + "ConnectionStyle", + "ConnectionLineStyle", + "ConnectionPathStyle", + "TitleBlockStyle", + "LegendStyle", + + # Presets + "get_preset", + "PRESETS", +] diff --git a/synodetik/modules/builtins/pdf_export/dialog.py b/synodetik/modules/builtins/pdf_export/dialog.py new file mode 100644 index 0000000..171754a --- /dev/null +++ b/synodetik/modules/builtins/pdf_export/dialog.py @@ -0,0 +1,810 @@ +""" +PDF Export Dialog. + +Provides a DearPyGui dialog for configuring PDF export settings. +""" +import dearpygui.dearpygui as dpg +from typing import TYPE_CHECKING, Optional, Callable +from pathlib import Path +from datetime import datetime + +from synodetik.modules.builtins.pdf_export.styles import ( + ExportSettings, + PageFormat, + PageOrientation, + ConnectionLineStyle, + ConnectionPathStyle, + PRESETS, + get_preset, +) + +if TYPE_CHECKING: + from synodetik.core import Synoptik + + +class ExportDialog: + """ + Dialog for configuring and initiating PDF export. + + Provides tabs for: + - Page settings (format, orientation, margins) + - Node style (colors, fonts, borders) + - Connection style (line width, style, arrows) + - Title block (project info, revision) + - Legend settings + """ + + DIALOG_TAG = "pdf_export_dialog" + + def __init__( + self, + synoptik: 'Synoptik', + on_export: Optional[Callable[[str, ExportSettings], None]] = None + ): + """ + Initialize export dialog. + + Args: + synoptik: The synoptik to export + on_export: Callback(filepath, settings) when export is confirmed + """ + self._synoptik = synoptik + self._on_export = on_export + self._settings = ExportSettings() + self._output_path: str = "" + + def show(self) -> None: + """Show the export dialog.""" + if dpg.does_item_exist(self.DIALOG_TAG): + dpg.delete_item(self.DIALOG_TAG) + + with dpg.window( + label="Export to PDF", + tag=self.DIALOG_TAG, + width=500, + height=600, + modal=False, + no_collapse=True, + no_resize=False, + pos=(100, 50), + on_close=self._on_cancel + ): + # Output file selection + with dpg.group(horizontal=True): + dpg.add_input_text( + tag="pdf_output_path", + hint="Select output file...", + width=380 + ) + dpg.add_button( + label="Browse...", + callback=self._browse_output + ) + + dpg.add_separator() + + # Preset selector + with dpg.group(horizontal=True): + dpg.add_text("Preset:") + dpg.add_combo( + items=list(PRESETS.keys()), + default_value="light", + callback=self._on_preset_changed, + tag="preset_combo", + width=150 + ) + + dpg.add_separator() + + # Tabs for settings + with dpg.tab_bar(): + self._create_page_tab() + self._create_node_tab() + self._create_connection_tab() + self._create_title_block_tab() + self._create_legend_tab() + + dpg.add_separator() + + # Action buttons + with dpg.group(horizontal=True): + dpg.add_button( + label="Export", + callback=self._on_export_clicked, + width=100 + ) + dpg.add_button( + label="Cancel", + callback=self._on_cancel, + width=100 + ) + + def _create_page_tab(self) -> None: + """Create page settings tab.""" + with dpg.tab(label="Page"): + dpg.add_spacing(count=2) + + # Format + with dpg.group(horizontal=True): + dpg.add_text("Format:", indent=10) + dpg.add_combo( + items=[f.value for f in PageFormat], + default_value=PageFormat.A4.value, + tag="page_format", + width=120, + callback=self._update_settings + ) + + # Orientation + with dpg.group(horizontal=True): + dpg.add_text("Orientation:", indent=10) + dpg.add_combo( + items=[o.value for o in PageOrientation], + default_value=PageOrientation.LANDSCAPE.value, + tag="page_orientation", + width=120, + callback=self._update_settings + ) + + dpg.add_spacing(count=2) + dpg.add_text("Margins (mm):") + + # Margins + with dpg.group(horizontal=True, indent=20): + dpg.add_text("Top:") + dpg.add_input_float( + tag="margin_top", + default_value=15.0, + width=60, + step=1, + callback=self._update_settings + ) + dpg.add_text("Bottom:") + dpg.add_input_float( + tag="margin_bottom", + default_value=15.0, + width=60, + step=1, + callback=self._update_settings + ) + + with dpg.group(horizontal=True, indent=20): + dpg.add_text("Left:") + dpg.add_input_float( + tag="margin_left", + default_value=15.0, + width=60, + step=1, + callback=self._update_settings + ) + dpg.add_text("Right:") + dpg.add_input_float( + tag="margin_right", + default_value=15.0, + width=60, + step=1, + callback=self._update_settings + ) + + dpg.add_spacing(count=2) + + # Options + dpg.add_checkbox( + label="Show border", + tag="show_border", + default_value=True, + callback=self._update_settings + ) + dpg.add_checkbox( + label="Show grid", + tag="show_grid", + default_value=False, + callback=self._update_settings + ) + dpg.add_checkbox( + label="Auto-fit to page", + tag="auto_fit", + default_value=True, + callback=self._update_settings + ) + dpg.add_checkbox( + label="Center content", + tag="center_content", + default_value=True, + callback=self._update_settings + ) + + def _create_node_tab(self) -> None: + """Create node style tab.""" + with dpg.tab(label="Nodes"): + dpg.add_spacing(count=2) + + dpg.add_checkbox( + label="Use machine color as background", + tag="use_machine_color", + default_value=True, + callback=self._update_settings + ) + + with dpg.group(horizontal=True): + dpg.add_text("Background opacity:") + dpg.add_slider_float( + tag="bg_opacity", + default_value=1.0, + min_value=0.1, + max_value=1.0, + width=150, + callback=self._update_settings + ) + + dpg.add_spacing(count=2) + dpg.add_text("Border:") + + with dpg.group(horizontal=True, indent=20): + dpg.add_text("Width:") + dpg.add_input_float( + tag="border_width", + default_value=1.0, + width=60, + step=0.5, + callback=self._update_settings + ) + dpg.add_text("Radius:") + dpg.add_input_float( + tag="corner_radius", + default_value=6.0, + width=60, + step=1, + callback=self._update_settings + ) + + dpg.add_spacing(count=2) + dpg.add_text("Title:") + + with dpg.group(horizontal=True, indent=20): + dpg.add_text("Size:") + dpg.add_input_int( + tag="title_font_size", + default_value=10, + width=60, + callback=self._update_settings + ) + + dpg.add_spacing(count=2) + dpg.add_text("Ports:") + + with dpg.group(horizontal=True, indent=20): + dpg.add_text("Size:") + dpg.add_input_int( + tag="port_font_size", + default_value=8, + width=60, + callback=self._update_settings + ) + + dpg.add_checkbox( + label="Show port color indicators", + tag="show_port_indicators", + default_value=True, + callback=self._update_settings + ) + + dpg.add_checkbox( + label="Draw shadow", + tag="node_shadow", + default_value=False, + callback=self._update_settings + ) + + def _create_connection_tab(self) -> None: + """Create connection style tab.""" + with dpg.tab(label="Connections"): + dpg.add_spacing(count=2) + + with dpg.group(horizontal=True): + dpg.add_text("Line width:") + dpg.add_input_float( + tag="line_width", + default_value=1.5, + width=60, + step=0.5, + callback=self._update_settings + ) + + with dpg.group(horizontal=True): + dpg.add_text("Line style:") + dpg.add_combo( + items=[s.value for s in ConnectionLineStyle], + default_value=ConnectionLineStyle.SOLID.value, + tag="line_style", + width=120, + callback=self._update_settings + ) + + with dpg.group(horizontal=True): + dpg.add_text("Path style:") + dpg.add_combo( + items=[s.value for s in ConnectionPathStyle], + default_value=ConnectionPathStyle.BEZIER.value, + tag="path_style", + width=120, + callback=self._update_settings + ) + + dpg.add_spacing(count=2) + + dpg.add_checkbox( + label="Use flux type colors", + tag="use_flux_color", + default_value=True, + callback=self._update_settings + ) + + dpg.add_checkbox( + label="Show directional arrows", + tag="show_arrows", + default_value=True, + callback=self._update_settings + ) + + with dpg.group(horizontal=True): + dpg.add_text("Arrow size:") + dpg.add_input_float( + tag="arrow_size", + default_value=6.0, + width=60, + step=1, + callback=self._update_settings + ) + + def _create_title_block_tab(self) -> None: + """Create title block settings tab - ISO 7200 compliant.""" + with dpg.tab(label="Title Block"): + dpg.add_spacing(count=2) + + dpg.add_checkbox( + label="Include title block (ISO 7200)", + tag="tb_enabled", + default_value=True, + callback=self._update_settings + ) + + with dpg.group(horizontal=True): + dpg.add_text("Height (mm):") + dpg.add_input_float( + tag="tb_height", + default_value=55.0, + width=60, + step=5, + callback=self._update_settings + ) + dpg.add_text("Width (mm):") + dpg.add_input_float( + tag="tb_width", + default_value=180.0, + width=60, + step=10, + callback=self._update_settings + ) + + dpg.add_separator() + dpg.add_text("ISO 7200 Mandatory Fields:", color=(100, 180, 255)) + + # Title block fields - populated from synoptik.title_block + tb = self._synoptik.title_block + + # === Identification Fields (ISO Table 1) === + dpg.add_spacing(count=1) + dpg.add_text("Identification:", color=(150, 150, 150)) + + with dpg.group(horizontal=True): + dpg.add_text("Legal Owner:") + dpg.add_input_text( + tag="tb_company", + default_value=tb.company_name, + width=200, + hint="Company/Organization name" + ) + + with dpg.group(horizontal=True): + dpg.add_text("Document No.:") + dpg.add_input_text( + tag="tb_doc_number", + default_value=tb.document_number, + width=150, + hint="Unique identifier" + ) + + with dpg.group(horizontal=True): + dpg.add_text("Revision:") + dpg.add_input_text( + tag="tb_revision", + default_value=tb.revision, + width=50 + ) + dpg.add_text("Date:") + dpg.add_input_text( + tag="tb_rev_date", + default_value=tb.revision_date or datetime.now().strftime("%Y-%m-%d"), + width=100 + ) + + # === Descriptive Fields (ISO Table 2) === + dpg.add_spacing(count=1) + dpg.add_text("Description:", color=(150, 150, 150)) + + with dpg.group(horizontal=True): + dpg.add_text("Title:") + dpg.add_input_text( + tag="tb_doc_title", + default_value=tb.document_title or self._synoptik.name, + width=280, + hint="Document title" + ) + + # === Administrative Fields (ISO Table 3) === + dpg.add_spacing(count=1) + dpg.add_text("Administrative:", color=(150, 150, 150)) + + with dpg.group(horizontal=True): + dpg.add_text("Created by:") + dpg.add_input_text( + tag="tb_author", + default_value=tb.author, + width=120 + ) + + with dpg.group(horizontal=True): + dpg.add_text("Checked by:") + dpg.add_input_text( + tag="tb_checker", + default_value=tb.checker, + width=120 + ) + + with dpg.group(horizontal=True): + dpg.add_text("Approved by:") + dpg.add_input_text( + tag="tb_approver", + default_value=tb.approver, + width=120 + ) + + # === Logo === + dpg.add_separator() + dpg.add_text("Logo:", color=(150, 150, 150)) + + with dpg.group(horizontal=True): + dpg.add_input_text( + tag="tb_logo", + default_value=tb.logo_path, + width=280, + hint="Path to logo image" + ) + dpg.add_button( + label="...", + callback=self._browse_logo, + width=30 + ) + + dpg.add_spacing(count=2) + dpg.add_button( + label="Save to Project", + callback=self._save_title_block_to_project, + width=150 + ) + + def _create_legend_tab(self) -> None: + """Create legend settings tab.""" + with dpg.tab(label="Legend"): + dpg.add_spacing(count=2) + + dpg.add_checkbox( + label="Show flux type legend", + tag="legend_enabled", + default_value=True, + callback=self._update_settings + ) + + with dpg.group(horizontal=True): + dpg.add_text("Position:") + dpg.add_combo( + items=["top-left", "top-right", "bottom-left", "bottom-right"], + default_value="top-right", + tag="legend_position", + width=120, + callback=self._update_settings + ) + + with dpg.group(horizontal=True): + dpg.add_text("Title:") + dpg.add_input_text( + tag="legend_title", + default_value="Signal Types", + width=200 + ) + + def _browse_output(self, sender=None, app_data=None) -> None: + """Show file browser for output path.""" + if dpg.does_item_exist("pdf_file_dialog"): + dpg.delete_item("pdf_file_dialog") + + default_name = f"{self._synoptik.name.replace(' ', '_')}.pdf" + + with dpg.file_dialog( + label="Save PDF As", + tag="pdf_file_dialog", + directory_selector=False, + show=True, + modal=True, + callback=self._on_file_selected, + cancel_callback=lambda: None, + default_filename=default_name, + width=600, + height=400 + ): + dpg.add_file_extension(".pdf", color=(0, 255, 0, 255)) + + def _on_file_selected(self, sender, app_data) -> None: + """Handle file selection.""" + filepath = app_data.get('file_path_name', '') + if filepath: + if not filepath.lower().endswith('.pdf'): + filepath += '.pdf' + self._output_path = filepath + dpg.set_value("pdf_output_path", filepath) + + def _browse_logo(self, sender=None, app_data=None) -> None: + """Browse for logo image.""" + if dpg.does_item_exist("logo_file_dialog"): + dpg.delete_item("logo_file_dialog") + + with dpg.file_dialog( + label="Select Logo Image", + tag="logo_file_dialog", + directory_selector=False, + show=True, + modal=True, + callback=self._on_logo_selected, + cancel_callback=lambda: None, + width=600, + height=400 + ): + dpg.add_file_extension(".png", color=(0, 255, 0, 255)) + dpg.add_file_extension(".jpg", color=(0, 255, 255, 255)) + dpg.add_file_extension(".jpeg", color=(0, 255, 255, 255)) + + def _on_logo_selected(self, sender, app_data) -> None: + """Handle logo file selection.""" + filepath = app_data.get('file_path_name', '') + if filepath: + dpg.set_value("tb_logo", filepath) + + def _on_preset_changed(self, sender, app_data) -> None: + """Handle preset selection change.""" + preset_name = app_data + self._settings = get_preset(preset_name) + self._update_ui_from_settings() + + def _update_ui_from_settings(self) -> None: + """Update UI controls from current settings.""" + s = self._settings + + # Page + if dpg.does_item_exist("page_format"): + dpg.set_value("page_format", s.page.format.value) + if dpg.does_item_exist("page_orientation"): + dpg.set_value("page_orientation", s.page.orientation.value) + if dpg.does_item_exist("margin_top"): + dpg.set_value("margin_top", s.page.margin_top) + if dpg.does_item_exist("margin_bottom"): + dpg.set_value("margin_bottom", s.page.margin_bottom) + if dpg.does_item_exist("margin_left"): + dpg.set_value("margin_left", s.page.margin_left) + if dpg.does_item_exist("margin_right"): + dpg.set_value("margin_right", s.page.margin_right) + if dpg.does_item_exist("show_border"): + dpg.set_value("show_border", s.page.show_border) + if dpg.does_item_exist("show_grid"): + dpg.set_value("show_grid", s.page.show_grid) + if dpg.does_item_exist("auto_fit"): + dpg.set_value("auto_fit", s.auto_fit) + if dpg.does_item_exist("center_content"): + dpg.set_value("center_content", s.center_content) + + # Node + if dpg.does_item_exist("use_machine_color"): + dpg.set_value("use_machine_color", s.node.use_machine_color) + if dpg.does_item_exist("bg_opacity"): + dpg.set_value("bg_opacity", s.node.background_opacity) + if dpg.does_item_exist("border_width"): + dpg.set_value("border_width", s.node.border_width) + if dpg.does_item_exist("corner_radius"): + dpg.set_value("corner_radius", s.node.corner_radius) + if dpg.does_item_exist("title_font_size"): + dpg.set_value("title_font_size", s.node.title_font_size) + if dpg.does_item_exist("port_font_size"): + dpg.set_value("port_font_size", s.node.port_font_size) + if dpg.does_item_exist("show_port_indicators"): + dpg.set_value("show_port_indicators", s.node.show_port_indicators) + if dpg.does_item_exist("node_shadow"): + dpg.set_value("node_shadow", s.node.shadow) + + # Connection + if dpg.does_item_exist("line_width"): + dpg.set_value("line_width", s.connection.line_width) + if dpg.does_item_exist("line_style"): + dpg.set_value("line_style", s.connection.line_style.value) + if dpg.does_item_exist("path_style"): + dpg.set_value("path_style", s.connection.path_style.value) + if dpg.does_item_exist("use_flux_color"): + dpg.set_value("use_flux_color", s.connection.use_flux_color) + if dpg.does_item_exist("show_arrows"): + dpg.set_value("show_arrows", s.connection.show_arrows) + if dpg.does_item_exist("arrow_size"): + dpg.set_value("arrow_size", s.connection.arrow_size) + + # Title block + if dpg.does_item_exist("tb_enabled"): + dpg.set_value("tb_enabled", s.title_block.enabled) + if dpg.does_item_exist("tb_height"): + dpg.set_value("tb_height", s.title_block.height) + if dpg.does_item_exist("tb_width"): + dpg.set_value("tb_width", s.title_block.width) + + # Legend + if dpg.does_item_exist("legend_enabled"): + dpg.set_value("legend_enabled", s.legend.enabled) + if dpg.does_item_exist("legend_position"): + dpg.set_value("legend_position", s.legend.position) + + def _update_settings(self, sender=None, app_data=None) -> None: + """Update settings from UI controls.""" + s = self._settings + + # Page + if dpg.does_item_exist("page_format"): + s.page.format = PageFormat(dpg.get_value("page_format")) + if dpg.does_item_exist("page_orientation"): + s.page.orientation = PageOrientation(dpg.get_value("page_orientation")) + if dpg.does_item_exist("margin_top"): + s.page.margin_top = dpg.get_value("margin_top") + if dpg.does_item_exist("margin_bottom"): + s.page.margin_bottom = dpg.get_value("margin_bottom") + if dpg.does_item_exist("margin_left"): + s.page.margin_left = dpg.get_value("margin_left") + if dpg.does_item_exist("margin_right"): + s.page.margin_right = dpg.get_value("margin_right") + if dpg.does_item_exist("show_border"): + s.page.show_border = dpg.get_value("show_border") + if dpg.does_item_exist("show_grid"): + s.page.show_grid = dpg.get_value("show_grid") + if dpg.does_item_exist("auto_fit"): + s.auto_fit = dpg.get_value("auto_fit") + if dpg.does_item_exist("center_content"): + s.center_content = dpg.get_value("center_content") + + # Node + if dpg.does_item_exist("use_machine_color"): + s.node.use_machine_color = dpg.get_value("use_machine_color") + if dpg.does_item_exist("bg_opacity"): + s.node.background_opacity = dpg.get_value("bg_opacity") + if dpg.does_item_exist("border_width"): + s.node.border_width = dpg.get_value("border_width") + if dpg.does_item_exist("corner_radius"): + s.node.corner_radius = dpg.get_value("corner_radius") + if dpg.does_item_exist("title_font_size"): + s.node.title_font_size = dpg.get_value("title_font_size") + if dpg.does_item_exist("port_font_size"): + s.node.port_font_size = dpg.get_value("port_font_size") + if dpg.does_item_exist("show_port_indicators"): + s.node.show_port_indicators = dpg.get_value("show_port_indicators") + if dpg.does_item_exist("node_shadow"): + s.node.shadow = dpg.get_value("node_shadow") + + # Connection + if dpg.does_item_exist("line_width"): + s.connection.line_width = dpg.get_value("line_width") + if dpg.does_item_exist("line_style"): + s.connection.line_style = ConnectionLineStyle(dpg.get_value("line_style")) + if dpg.does_item_exist("path_style"): + s.connection.path_style = ConnectionPathStyle(dpg.get_value("path_style")) + if dpg.does_item_exist("use_flux_color"): + s.connection.use_flux_color = dpg.get_value("use_flux_color") + if dpg.does_item_exist("show_arrows"): + s.connection.show_arrows = dpg.get_value("show_arrows") + if dpg.does_item_exist("arrow_size"): + s.connection.arrow_size = dpg.get_value("arrow_size") + + # Title block + if dpg.does_item_exist("tb_enabled"): + s.title_block.enabled = dpg.get_value("tb_enabled") + if dpg.does_item_exist("tb_height"): + s.title_block.height = dpg.get_value("tb_height") + if dpg.does_item_exist("tb_width"): + s.title_block.width = dpg.get_value("tb_width") + + # Legend + if dpg.does_item_exist("legend_enabled"): + s.legend.enabled = dpg.get_value("legend_enabled") + if dpg.does_item_exist("legend_position"): + s.legend.position = dpg.get_value("legend_position") + if dpg.does_item_exist("legend_title"): + s.legend.title = dpg.get_value("legend_title") + + def _save_title_block_to_project(self, sender=None, app_data=None) -> None: + """Save title block info to the synoptik.""" + tb = self._synoptik.title_block + + if dpg.does_item_exist("tb_company"): + tb.company_name = dpg.get_value("tb_company") + if dpg.does_item_exist("tb_doc_number"): + tb.document_number = dpg.get_value("tb_doc_number") + if dpg.does_item_exist("tb_doc_title"): + tb.document_title = dpg.get_value("tb_doc_title") + if dpg.does_item_exist("tb_revision"): + tb.revision = dpg.get_value("tb_revision") + if dpg.does_item_exist("tb_rev_date"): + tb.revision_date = dpg.get_value("tb_rev_date") + if dpg.does_item_exist("tb_author"): + tb.author = dpg.get_value("tb_author") + if dpg.does_item_exist("tb_checker"): + tb.checker = dpg.get_value("tb_checker") + if dpg.does_item_exist("tb_approver"): + tb.approver = dpg.get_value("tb_approver") + if dpg.does_item_exist("tb_logo"): + tb.logo_path = dpg.get_value("tb_logo") + + self._synoptik.touch() + print("Title block saved to project") + + def _update_synoptik_title_block(self) -> None: + """Update synoptik title block from dialog values.""" + tb = self._synoptik.title_block + + if dpg.does_item_exist("tb_company"): + tb.company_name = dpg.get_value("tb_company") + if dpg.does_item_exist("tb_doc_number"): + tb.document_number = dpg.get_value("tb_doc_number") + if dpg.does_item_exist("tb_doc_title"): + tb.document_title = dpg.get_value("tb_doc_title") + if dpg.does_item_exist("tb_revision"): + tb.revision = dpg.get_value("tb_revision") + if dpg.does_item_exist("tb_rev_date"): + tb.revision_date = dpg.get_value("tb_rev_date") + if dpg.does_item_exist("tb_author"): + tb.author = dpg.get_value("tb_author") + if dpg.does_item_exist("tb_checker"): + tb.checker = dpg.get_value("tb_checker") + if dpg.does_item_exist("tb_approver"): + tb.approver = dpg.get_value("tb_approver") + if dpg.does_item_exist("tb_logo"): + tb.logo_path = dpg.get_value("tb_logo") + + def _on_export_clicked(self, sender=None, app_data=None) -> None: + """Handle export button click.""" + # Get output path + output_path = dpg.get_value("pdf_output_path") if dpg.does_item_exist("pdf_output_path") else "" + + if not output_path: + print("Please select an output file") + return + + # Update settings from UI + self._update_settings() + + # Update title block in synoptik + self._update_synoptik_title_block() + + # Close dialog + if dpg.does_item_exist(self.DIALOG_TAG): + dpg.delete_item(self.DIALOG_TAG) + + # Call export callback + if self._on_export: + self._on_export(output_path, self._settings) + + def _on_cancel(self, sender=None, app_data=None) -> None: + """Handle cancel button click.""" + if dpg.does_item_exist(self.DIALOG_TAG): + dpg.delete_item(self.DIALOG_TAG) diff --git a/synodetik/modules/builtins/pdf_export/module.py b/synodetik/modules/builtins/pdf_export/module.py new file mode 100644 index 0000000..2e2b899 --- /dev/null +++ b/synodetik/modules/builtins/pdf_export/module.py @@ -0,0 +1,133 @@ +""" +PDF Export Module. + +Provides PDF export functionality for Synodetik diagrams with +customizable node styles, connection rendering, and title blocks. +""" +from pathlib import Path + +from synodetik.modules import Module, ModuleInfo, MenuItem +from synodetik.modules.builtins.pdf_export.styles import ExportSettings +from synodetik.modules.builtins.pdf_export.renderer import PdfRenderer +from synodetik.modules.builtins.pdf_export.dialog import ExportDialog + + +class PdfExportModule(Module): + """ + Module for exporting synoptik diagrams to PDF. + + Features: + - Customizable page settings (format, orientation, margins) + - Node style customization (colors, borders, fonts) + - Connection style options (line style, arrows, colors) + - Title block (cartouche) with project metadata + - Flux type legend + - Multiple style presets + + Usage via menu: File > Export to PDF... + """ + + def __init__(self): + super().__init__() + self._renderer = PdfRenderer() + + @property + def info(self) -> ModuleInfo: + return ModuleInfo( + id="pdf_export", + name="PDF Export", + version="1.0.0", + author="Synodetik", + description="Export synoptik diagrams to PDF with customizable styles" + ) + + def on_load(self) -> bool: + """Initialize the module.""" + print(f"[{self.info.name}] Module loaded") + return True + + def on_unload(self) -> None: + """Clean up module resources.""" + print(f"[{self.info.name}] Module unloaded") + + def get_menu_items(self) -> list[MenuItem]: + """Add menu items for PDF export.""" + return [ + MenuItem( + label="Export to PDF...", + callback=self._show_export_dialog, + shortcut="Ctrl+E", + parent_menu="File", + separator_before=True + ) + ] + + def _show_export_dialog(self, sender=None, app_data=None) -> None: + """Show the PDF export dialog.""" + if not self.app or not self.app.synoptik: + print("No synoptik to export") + return + + # Update node positions from editor before export + if self.app.node_editor: + for machine in self.app.synoptik.machines: + pos = self.app.node_editor.get_node_position(machine.id) + if pos: + machine.position = pos + + dialog = ExportDialog( + synoptik=self.app.synoptik, + on_export=self._do_export + ) + dialog.show() + + def _do_export(self, output_path: str, settings: ExportSettings) -> None: + """Perform the PDF export.""" + if not self.app or not self.app.synoptik: + return + + print(f"Exporting to: {output_path}") + + success = self._renderer.render( + synoptik=self.app.synoptik, + output_path=output_path, + settings=settings + ) + + if success: + print(f"PDF exported successfully: {output_path}") + else: + print("PDF export failed") + + def export( + self, + output_path: str | Path, + settings: ExportSettings = None + ) -> bool: + """ + Export the current synoptik to PDF programmatically. + + This method can be used by other modules or scripts. + + Args: + output_path: Path for the output PDF file + settings: Export settings (uses defaults if None) + + Returns: + True if export was successful + """ + if not self.app or not self.app.synoptik: + return False + + # Update positions + if self.app.node_editor: + for machine in self.app.synoptik.machines: + pos = self.app.node_editor.get_node_position(machine.id) + if pos: + machine.position = pos + + return self._renderer.render( + synoptik=self.app.synoptik, + output_path=str(output_path), + settings=settings or ExportSettings() + ) diff --git a/synodetik/modules/builtins/pdf_export/renderer.py b/synodetik/modules/builtins/pdf_export/renderer.py new file mode 100644 index 0000000..d6a3f65 --- /dev/null +++ b/synodetik/modules/builtins/pdf_export/renderer.py @@ -0,0 +1,935 @@ +""" +PDF Renderer for Synodetik. + +Renders a Synoptik diagram to PDF using ReportLab. +""" +import os +from typing import TYPE_CHECKING, Optional +from pathlib import Path + +from reportlab.lib.pagesizes import A4, A3, A2, A1, A0, LETTER, LEGAL, TABLOID +from reportlab.lib.units import mm, inch +from reportlab.lib.colors import HexColor, Color, black, white +from reportlab.pdfgen import canvas +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont + +from synodetik.modules.builtins.pdf_export.styles import ( + ExportSettings, + PageFormat, + PageOrientation, + ConnectionLineStyle, + ConnectionPathStyle, +) + +if TYPE_CHECKING: + from synodetik.core import Synoptik, Machine, Connection, TitleBlock + + +def hex_to_color(hex_color: str, opacity: float = 1.0) -> Color: + """Convert hex color string to ReportLab Color.""" + hex_color = hex_color.lstrip('#') + if len(hex_color) == 8: + # RGBA format + r = int(hex_color[0:2], 16) / 255.0 + g = int(hex_color[2:4], 16) / 255.0 + b = int(hex_color[4:6], 16) / 255.0 + a = int(hex_color[6:8], 16) / 255.0 + return Color(r, g, b, a * opacity) + elif len(hex_color) == 6: + r = int(hex_color[0:2], 16) / 255.0 + g = int(hex_color[2:4], 16) / 255.0 + b = int(hex_color[4:6], 16) / 255.0 + return Color(r, g, b, opacity) + return black + + +class PdfRenderer: + """ + Renders Synoptik diagrams to PDF. + + Usage: + renderer = PdfRenderer() + renderer.render(synoptik, "output.pdf", settings) + """ + + # Page size mappings + PAGE_SIZES = { + PageFormat.A4: A4, + PageFormat.A3: A3, + PageFormat.A2: A2, + PageFormat.A1: A1, + PageFormat.A0: A0, + PageFormat.LETTER: LETTER, + PageFormat.LEGAL: LEGAL, + PageFormat.TABLOID: TABLOID, + } + + def __init__(self): + self._canvas: Optional[canvas.Canvas] = None + self._settings: Optional[ExportSettings] = None + self._synoptik: Optional['Synoptik'] = None + + # Calculated values + self._page_width: float = 0 + self._page_height: float = 0 + self._content_x: float = 0 + self._content_y: float = 0 + self._content_width: float = 0 + self._content_height: float = 0 + self._scale: float = 1.0 + self._offset_x: float = 0 + self._offset_y: float = 0 + + # Node positions cache (scaled) + self._node_bounds: dict[str, tuple] = {} # machine_id -> (x, y, w, h) + self._port_positions: dict[str, tuple] = {} # port_id -> (x, y) + + def render( + self, + synoptik: 'Synoptik', + output_path: str | Path, + settings: Optional[ExportSettings] = None + ) -> bool: + """ + Render synoptik to PDF file. + + Args: + synoptik: The synoptik to render + output_path: Output PDF file path + settings: Export settings (uses defaults if None) + + Returns: + True if successful, False otherwise + """ + self._synoptik = synoptik + self._settings = settings or ExportSettings() + + try: + # Calculate page dimensions + self._calculate_page_dimensions() + + # Calculate scale and offset + self._calculate_transform() + + # Create canvas + pagesize = (self._page_width, self._page_height) + self._canvas = canvas.Canvas(str(output_path), pagesize=pagesize) + + # Set PDF metadata + self._set_metadata() + + # Draw layers (back to front) + self._draw_background() + self._draw_grid() + self._draw_connections() + self._draw_nodes() + self._draw_legend() + self._draw_title_block() + self._draw_border() + + # Save + self._canvas.save() + return True + + except Exception as e: + print(f"PDF export error: {e}") + import traceback + traceback.print_exc() + return False + finally: + self._canvas = None + + def _calculate_page_dimensions(self) -> None: + """Calculate page and content area dimensions.""" + page_style = self._settings.page + + # Get base page size + base_size = self.PAGE_SIZES.get(page_style.format, A4) + + # Apply orientation + if page_style.orientation == PageOrientation.LANDSCAPE: + self._page_width = max(base_size) + self._page_height = min(base_size) + else: + self._page_width = min(base_size) + self._page_height = max(base_size) + + # Calculate content area (accounting for title block) + self._content_x = page_style.margin_left * mm + self._content_y = page_style.margin_bottom * mm + self._content_width = self._page_width - (page_style.margin_left + page_style.margin_right) * mm + self._content_height = self._page_height - (page_style.margin_top + page_style.margin_bottom) * mm + + # Reserve space for title block (ISO 7200: bottom-right) + if self._settings.title_block.enabled: + tb_height = self._settings.title_block.height * mm + # Title block is at bottom, so raise content area + self._content_y += tb_height + self._content_height -= tb_height + + def _calculate_transform(self) -> None: + """Calculate scale and offset to fit content.""" + if not self._synoptik or not self._synoptik.machines: + self._scale = 1.0 + self._offset_x = self._content_x + self._offset_y = self._content_y + return + + # Find bounding box of all machines + min_x = min_y = float('inf') + max_x = max_y = float('-inf') + + for machine in self._synoptik.machines: + x, y = machine.position + # Estimate node size + node_w, node_h = self._estimate_node_size(machine) + + min_x = min(min_x, x) + min_y = min(min_y, y) + max_x = max(max_x, x + node_w) + max_y = max(max_y, y + node_h) + + # Add padding + padding = 20 + min_x -= padding + min_y -= padding + max_x += padding + max_y += padding + + content_bbox_w = max_x - min_x + content_bbox_h = max_y - min_y + + if self._settings.auto_fit and content_bbox_w > 0 and content_bbox_h > 0: + # Calculate scale to fit + scale_x = self._content_width / content_bbox_w + scale_y = self._content_height / content_bbox_h + self._scale = min(scale_x, scale_y, 1.5) # Cap at 1.5x + else: + self._scale = self._settings.scale + + # Calculate offset to center + scaled_w = content_bbox_w * self._scale + scaled_h = content_bbox_h * self._scale + + if self._settings.center_content: + self._offset_x = self._content_x + (self._content_width - scaled_w) / 2 - min_x * self._scale + self._offset_y = self._content_y + (self._content_height - scaled_h) / 2 - min_y * self._scale + else: + self._offset_x = self._content_x - min_x * self._scale + self._offset_y = self._content_y - min_y * self._scale + + # Flip Y axis (PDF origin is bottom-left, editor is top-left) + self._offset_y = self._page_height - self._offset_y + + def _estimate_node_size(self, machine: 'Machine') -> tuple[float, float]: + """Estimate the size of a machine node.""" + node_style = self._settings.node + + # Calculate based on ports + num_inputs = len(machine.inputs) + num_outputs = len(machine.outputs) + max_ports = max(num_inputs, num_outputs, 1) + + port_height = node_style.port_font_size + 4 + title_height = node_style.title_font_size + 8 + + width = max(node_style.min_width, 120) + height = max( + node_style.min_height, + title_height + max_ports * port_height + node_style.padding * 2 + ) + + return (width, height) + + def _transform_point(self, x: float, y: float) -> tuple[float, float]: + """Transform editor coordinates to PDF coordinates.""" + pdf_x = x * self._scale + self._offset_x + pdf_y = self._offset_y - y * self._scale # Flip Y + return (pdf_x, pdf_y) + + def _set_metadata(self) -> None: + """Set PDF document metadata.""" + c = self._canvas + + title = self._settings.pdf_title or self._synoptik.name + author = self._settings.pdf_author or self._synoptik.title_block.author + subject = self._settings.pdf_subject or self._synoptik.description + + c.setTitle(title) + c.setAuthor(author) + c.setSubject(subject) + c.setCreator("Synodetik PDF Export") + + def _draw_background(self) -> None: + """Draw page background.""" + c = self._canvas + bg_color = hex_to_color(self._settings.page.background_color) + + c.setFillColor(bg_color) + c.rect(0, 0, self._page_width, self._page_height, fill=1, stroke=0) + + def _draw_grid(self) -> None: + """Draw background grid if enabled.""" + if not self._settings.page.show_grid: + return + + c = self._canvas + grid_color = hex_to_color(self._settings.page.grid_color) + spacing = self._settings.page.grid_spacing * mm + + c.setStrokeColor(grid_color) + c.setLineWidth(0.25) + + # Vertical lines + x = self._content_x + while x <= self._content_x + self._content_width: + c.line(x, self._content_y, x, self._content_y + self._content_height) + x += spacing + + # Horizontal lines + y = self._content_y + while y <= self._content_y + self._content_height: + c.line(self._content_x, y, self._content_x + self._content_width, y) + y += spacing + + def _draw_nodes(self) -> None: + """Draw all machine nodes.""" + if not self._synoptik: + return + + for machine in self._synoptik.machines: + self._draw_node(machine) + + def _draw_node(self, machine: 'Machine') -> None: + """Draw a single machine node.""" + c = self._canvas + style = self._settings.node + + # Get position and size + x, y = machine.position + width, height = self._estimate_node_size(machine) + + # Transform to PDF coordinates + pdf_x, pdf_y = self._transform_point(x, y) + scaled_w = width * self._scale + scaled_h = height * self._scale + + # Adjust Y for top-left origin + pdf_y -= scaled_h + + # Store bounds for connection routing + self._node_bounds[machine.id] = (pdf_x, pdf_y, scaled_w, scaled_h) + + # Determine background color + if style.use_machine_color and machine.color: + bg_color = hex_to_color(machine.color, style.background_opacity) + else: + bg_color = hex_to_color(style.background_color, style.background_opacity) + + border_color = hex_to_color(style.border_color) + + # Draw shadow + if style.shadow: + shadow_color = hex_to_color(style.shadow_color) + c.setFillColor(shadow_color) + shadow_x = pdf_x + style.shadow_offset[0] + shadow_y = pdf_y + style.shadow_offset[1] + if style.corner_radius > 0: + c.roundRect(shadow_x, shadow_y, scaled_w, scaled_h, + style.corner_radius * self._scale, fill=1, stroke=0) + else: + c.rect(shadow_x, shadow_y, scaled_w, scaled_h, fill=1, stroke=0) + + # Draw background + c.setFillColor(bg_color) + c.setStrokeColor(border_color) + c.setLineWidth(style.border_width) + + if style.corner_radius > 0: + c.roundRect(pdf_x, pdf_y, scaled_w, scaled_h, + style.corner_radius * self._scale, fill=1, stroke=1) + else: + c.rect(pdf_x, pdf_y, scaled_w, scaled_h, fill=1, stroke=1) + + # Draw title + title_color = hex_to_color(style.title_color) + c.setFillColor(title_color) + c.setFont(style.title_font_name, style.title_font_size * self._scale) + + title_x = pdf_x + style.padding * self._scale + title_y = pdf_y + scaled_h - (style.title_font_size + style.padding) * self._scale + c.drawString(title_x, title_y, machine.name) + + # Draw ports + self._draw_ports(machine, pdf_x, pdf_y, scaled_w, scaled_h) + + def _draw_ports( + self, + machine: 'Machine', + node_x: float, + node_y: float, + node_w: float, + node_h: float + ) -> None: + """Draw input and output ports for a node.""" + c = self._canvas + style = self._settings.node + + port_font_size = style.port_font_size * self._scale + port_spacing = (port_font_size + 4 * self._scale) + title_height = (style.title_font_size + style.padding * 2) * self._scale + + c.setFont(style.port_font_name, port_font_size) + port_color = hex_to_color(style.port_color) + + # Draw inputs (left side) + start_y = node_y + node_h - title_height - port_spacing + for i, port in enumerate(machine.inputs): + port_y = start_y - i * port_spacing + port_x = node_x + style.padding * self._scale + + # Store port position (center of indicator) + self._port_positions[port.id] = (node_x, port_y + port_font_size / 2) + + # Draw indicator + if style.show_port_indicators: + indicator_size = style.port_indicator_size * self._scale + flux_color = hex_to_color(port.flux_type.color) + c.setFillColor(flux_color) + c.circle( + node_x + indicator_size / 2, + port_y + port_font_size / 2, + indicator_size / 2, + fill=1, stroke=0 + ) + port_x += indicator_size + 2 * self._scale + + # Draw label + c.setFillColor(port_color) + c.drawString(port_x, port_y, port.name) + + # Draw outputs (right side) + for i, port in enumerate(machine.outputs): + port_y = start_y - i * port_spacing + + # Calculate text width for right alignment + text_width = c.stringWidth(port.name, style.port_font_name, port_font_size) + port_x = node_x + node_w - style.padding * self._scale - text_width + + # Store port position + self._port_positions[port.id] = (node_x + node_w, port_y + port_font_size / 2) + + # Draw indicator + if style.show_port_indicators: + indicator_size = style.port_indicator_size * self._scale + flux_color = hex_to_color(port.flux_type.color) + c.setFillColor(flux_color) + c.circle( + node_x + node_w - indicator_size / 2, + port_y + port_font_size / 2, + indicator_size / 2, + fill=1, stroke=0 + ) + port_x -= indicator_size + 2 * self._scale + + # Draw label + c.setFillColor(port_color) + c.drawString(port_x, port_y, port.name) + + def _draw_connections(self) -> None: + """Draw all connections.""" + if not self._synoptik: + return + + # First, calculate all node bounds and port positions + for machine in self._synoptik.machines: + x, y = machine.position + width, height = self._estimate_node_size(machine) + pdf_x, pdf_y = self._transform_point(x, y) + scaled_w = width * self._scale + scaled_h = height * self._scale + pdf_y -= scaled_h + + self._node_bounds[machine.id] = (pdf_x, pdf_y, scaled_w, scaled_h) + + # Calculate port positions + style = self._settings.node + port_font_size = style.port_font_size * self._scale + port_spacing = (port_font_size + 4 * self._scale) + title_height = (style.title_font_size + style.padding * 2) * self._scale + start_y = pdf_y + scaled_h - title_height - port_spacing + + for i, port in enumerate(machine.inputs): + port_y = start_y - i * port_spacing + self._port_positions[port.id] = (pdf_x, port_y + port_font_size / 2) + + for i, port in enumerate(machine.outputs): + port_y = start_y - i * port_spacing + self._port_positions[port.id] = (pdf_x + scaled_w, port_y + port_font_size / 2) + + # Now draw connections + for conn in self._synoptik.connections: + self._draw_connection(conn) + + def _draw_connection(self, connection: 'Connection') -> None: + """Draw a single connection.""" + c = self._canvas + style = self._settings.connection + + # Get port positions + src_pos = self._port_positions.get(connection.source_port_id) + tgt_pos = self._port_positions.get(connection.target_port_id) + + if not src_pos or not tgt_pos: + return + + # Determine color + if style.use_flux_color: + source_port = self._synoptik._find_port(connection.source_port_id) + if source_port: + line_color = hex_to_color(source_port.flux_type.color, style.opacity) + else: + line_color = hex_to_color(style.default_color, style.opacity) + else: + line_color = hex_to_color(style.default_color, style.opacity) + + c.setStrokeColor(line_color) + c.setLineWidth(style.line_width * self._scale) + + # Set line style + if style.line_style == ConnectionLineStyle.DASHED: + c.setDash([6, 3]) + elif style.line_style == ConnectionLineStyle.DOTTED: + c.setDash([2, 2]) + else: + c.setDash([]) + + x1, y1 = src_pos + x2, y2 = tgt_pos + + # Draw path based on style + if style.path_style == ConnectionPathStyle.STRAIGHT: + c.line(x1, y1, x2, y2) + elif style.path_style == ConnectionPathStyle.BEZIER: + # Create bezier curve + path = c.beginPath() + path.moveTo(x1, y1) + + # Control points + ctrl_offset = abs(x2 - x1) * 0.4 + ctrl1_x = x1 + ctrl_offset + ctrl2_x = x2 - ctrl_offset + + path.curveTo(ctrl1_x, y1, ctrl2_x, y2, x2, y2) + c.drawPath(path, fill=0, stroke=1) + else: # ORTHOGONAL + mid_x = (x1 + x2) / 2 + path = c.beginPath() + path.moveTo(x1, y1) + path.lineTo(mid_x, y1) + path.lineTo(mid_x, y2) + path.lineTo(x2, y2) + c.drawPath(path, fill=0, stroke=1) + + # Reset dash + c.setDash([]) + + # Draw arrow + if style.show_arrows: + self._draw_arrow(x2, y2, x1, y1, line_color) + + def _draw_arrow( + self, + x: float, + y: float, + from_x: float, + from_y: float, + color: Color + ) -> None: + """Draw an arrow head at the specified position.""" + import math + + c = self._canvas + size = self._settings.connection.arrow_size * self._scale + + # Calculate angle + angle = math.atan2(y - from_y, x - from_x) + + # Arrow points + x1 = x - size * math.cos(angle - math.pi / 6) + y1 = y - size * math.sin(angle - math.pi / 6) + x2 = x - size * math.cos(angle + math.pi / 6) + y2 = y - size * math.sin(angle + math.pi / 6) + + c.setFillColor(color) + path = c.beginPath() + path.moveTo(x, y) + path.lineTo(x1, y1) + path.lineTo(x2, y2) + path.close() + c.drawPath(path, fill=1, stroke=0) + + def _draw_legend(self) -> None: + """Draw flux type legend.""" + if not self._settings.legend.enabled or not self._synoptik: + return + + c = self._canvas + style = self._settings.legend + + # Collect unique flux types + flux_types = {} + for machine in self._synoptik.machines: + for port in machine.all_ports: + flux_types[port.flux_type.id] = port.flux_type + + if not flux_types: + return + + # Calculate legend size + num_items = len(flux_types) + item_height = style.font_size + 4 + legend_height = style.padding * 2 + style.font_size + 4 + num_items * item_height + legend_width = 120 # Will be calculated based on text + + # Calculate max text width + c.setFont(style.font_name, style.font_size) + max_text_width = max( + c.stringWidth(ft.name, style.font_name, style.font_size) + for ft in flux_types.values() + ) + legend_width = style.padding * 2 + style.indicator_size + 4 + max_text_width + + # Position legend + positions = { + "top-left": (self._content_x + 5, self._content_y + self._content_height - legend_height - 5), + "top-right": (self._content_x + self._content_width - legend_width - 5, + self._content_y + self._content_height - legend_height - 5), + "bottom-left": (self._content_x + 5, self._content_y + 5), + "bottom-right": (self._content_x + self._content_width - legend_width - 5, + self._content_y + 5), + } + leg_x, leg_y = positions.get(style.position, positions["top-right"]) + + # Draw background + bg_color = hex_to_color(style.background_color) + border_color = hex_to_color(style.border_color) + + c.setFillColor(bg_color) + c.setStrokeColor(border_color) + c.setLineWidth(style.border_width) + c.rect(leg_x, leg_y, legend_width, legend_height, fill=1, stroke=1) + + # Draw title + text_color = hex_to_color(style.text_color) + c.setFillColor(text_color) + c.setFont(style.font_name, style.font_size) + + title_y = leg_y + legend_height - style.padding - style.font_size + c.drawString(leg_x + style.padding, title_y, style.title) + + # Draw items + item_y = title_y - item_height + for flux_type in flux_types.values(): + # Color indicator + flux_color = hex_to_color(flux_type.color) + c.setFillColor(flux_color) + c.rect( + leg_x + style.padding, + item_y + 2, + style.indicator_size, + style.indicator_size, + fill=1, stroke=0 + ) + + # Label + c.setFillColor(text_color) + c.drawString( + leg_x + style.padding + style.indicator_size + 4, + item_y, + flux_type.name + ) + + item_y -= item_height + + def _draw_title_block(self) -> None: + """Draw the title block (cartouche) - ISO 7200 compliant.""" + if not self._settings.title_block.enabled or not self._synoptik: + return + + c = self._canvas + style = self._settings.title_block + tb = self._synoptik.title_block + page_style = self._settings.page + + # ISO 7200/5457: Title block in bottom-right corner + tb_width = min(style.width * mm, self._page_width - 2 * page_style.margin_left * mm) + tb_height = style.height * mm + + tb_x = self._page_width - page_style.margin_right * mm - tb_width + tb_y = page_style.margin_bottom * mm + + # Draw outer border + border_color = hex_to_color(style.border_color) + bg_color = hex_to_color(style.background_color) + + c.setFillColor(bg_color) + c.setStrokeColor(border_color) + c.setLineWidth(style.border_width * 2) # Outer border thicker + c.rect(tb_x, tb_y, tb_width, tb_height, fill=1, stroke=1) + + # ISO 7200 Layout Structure: + # +------------------+----------------------------------+--------+--------+ + # | | TITLE | REV | DATE | Row 3 + # | LOGO +----------------------------------+--------+--------+ + # | (Owner) | Document Number | Sheet | Scale | Row 2 + # +------------------+------------------+---------------+--------+--------+ + # | Created | Checked | Approved | Size |Doc Type| Row 1 + # +------------------+------------------+---------------+--------+--------+ + + # Define column widths (relative) + col_logo = 0.22 # Logo/Owner column + col_main = 0.43 # Main content (title, doc number) + col_small = 0.09 # Small columns (rev, date, sheet, scale, size) + col_medium = 0.13 # Medium columns (checked, approved) + col_doctype = 0.13 # Document type + + # Calculate absolute column positions + col_x = [ + tb_x, # Logo start + tb_x + tb_width * col_logo, # Main content start + tb_x + tb_width * (col_logo + col_main), # Small col 1 + tb_x + tb_width * (col_logo + col_main + col_small), # Small col 2 + tb_x + tb_width # End + ] + + # Row heights (from bottom) + row_height = tb_height / 3 + row_y = [ + tb_y, # Row 1 bottom + tb_y + row_height, # Row 2 bottom + tb_y + 2 * row_height, # Row 3 bottom + tb_y + tb_height # Top + ] + + c.setLineWidth(style.border_width) + + if style.show_grid_lines: + # Draw horizontal lines + for y in row_y[1:3]: + c.line(tb_x, y, tb_x + tb_width, y) + + # Draw vertical lines + # Logo column separator (full height) + c.line(col_x[1], tb_y, col_x[1], tb_y + tb_height) + # Right columns separators + c.line(col_x[2], tb_y, col_x[2], tb_y + tb_height) + c.line(col_x[3], tb_y, col_x[3], tb_y + tb_height) + + # Row 1: Additional separators for Created/Checked/Approved + mid1 = col_x[1] + (col_x[2] - col_x[1]) * 0.33 + mid2 = col_x[1] + (col_x[2] - col_x[1]) * 0.66 + c.line(mid1, row_y[0], mid1, row_y[1]) + c.line(mid2, row_y[0], mid2, row_y[1]) + + # Draw content + text_color = hex_to_color(style.text_color) + label_color = hex_to_color(style.label_color) + padding = 2 * mm + + # === ROW 3 (Top): Logo | Title | Rev | Date === + + # Logo/Owner + if style.show_logo and tb.logo_path: + try: + import os + if os.path.exists(tb.logo_path): + logo_w = col_x[1] - col_x[0] - 2 * padding + logo_h = row_height - 2 * padding + c.drawImage( + tb.logo_path, + col_x[0] + padding, + row_y[1] + padding, + width=logo_w, + height=row_height * 2 - 2 * padding, + preserveAspectRatio=True, + anchor='c' + ) + except Exception: + pass + + # Owner name (below logo or if no logo) + if tb.company_name: + c.setFillColor(text_color) + c.setFont(style.font_name, style.value_font_size) + self._draw_centered_text( + tb.company_name, + col_x[0], row_y[0], col_x[1] - col_x[0], row_height + ) + + # Title (large, prominent) - ISO mandatory + self._draw_labeled_cell( + "TITLE", tb.document_title or self._synoptik.name, + col_x[1], row_y[2], col_x[2] - col_x[1], row_height, + title_size=style.title_font_size + ) + + # Revision - ISO mandatory + self._draw_labeled_cell( + "REV", tb.revision, + col_x[2], row_y[2], col_x[3] - col_x[2], row_height + ) + + # Date of issue - ISO mandatory + date_str = tb.revision_date or self._synoptik.modified_at[:10] + self._draw_labeled_cell( + "DATE", date_str, + col_x[3], row_y[2], col_x[4] - col_x[3], row_height + ) + + # === ROW 2 (Middle): [Logo cont.] | Doc Number | Sheet | Scale === + + # Document identification number - ISO mandatory + doc_num = tb.document_number or self._synoptik.id[:12] + self._draw_labeled_cell( + "DOCUMENT NO.", doc_num, + col_x[1], row_y[1], col_x[2] - col_x[1], row_height + ) + + # Sheet number - ISO mandatory + self._draw_labeled_cell( + "SHEET", "1/1", + col_x[2], row_y[1], col_x[3] - col_x[2], row_height + ) + + # Scale (optional but common) + scale_str = f"1:{int(1/self._scale)}" if self._scale < 1 else "1:1" + self._draw_labeled_cell( + "SCALE", scale_str, + col_x[3], row_y[1], col_x[4] - col_x[3], row_height + ) + + # === ROW 1 (Bottom): Created | Checked | Approved | Size | Doc Type === + + # Calculate sub-column widths for row 1 + main_width = col_x[2] - col_x[1] + sub_col_w = main_width / 3 + + # Created by - ISO mandatory + self._draw_labeled_cell( + "CREATED", tb.author, + col_x[1], row_y[0], sub_col_w, row_height + ) + + # Checked by (optional) + self._draw_labeled_cell( + "CHECKED", tb.checker, + col_x[1] + sub_col_w, row_y[0], sub_col_w, row_height + ) + + # Approved by - ISO mandatory + self._draw_labeled_cell( + "APPROVED", tb.approver, + col_x[1] + 2 * sub_col_w, row_y[0], sub_col_w, row_height + ) + + # Size/Format - ISO mandatory + page_format = self._settings.page.format.value + self._draw_labeled_cell( + "SIZE", page_format, + col_x[2], row_y[0], col_x[3] - col_x[2], row_height + ) + + # Document type - ISO mandatory + doc_type = "DIAGRAM" # Default for synoptik + self._draw_labeled_cell( + "DOC TYPE", doc_type, + col_x[3], row_y[0], col_x[4] - col_x[3], row_height + ) + + def _draw_labeled_cell( + self, + label: str, + value: str, + x: float, + y: float, + width: float, + height: float, + title_size: int = None + ) -> None: + """ + Draw a title block cell with label at top and value centered. + + ISO 7200 style: small label at top, larger value below. + """ + c = self._canvas + style = self._settings.title_block + padding = 1.5 * mm + + label_color = hex_to_color(style.label_color) + text_color = hex_to_color(style.text_color) + + # Draw label (top-left, small) + c.setFillColor(label_color) + c.setFont(style.font_name, style.label_font_size) + c.drawString(x + padding, y + height - style.label_font_size - padding, label) + + # Draw value (centered) + if value: + c.setFillColor(text_color) + font_size = title_size or style.value_font_size + c.setFont(style.font_name, font_size) + + # Calculate vertical center (accounting for label) + label_space = style.label_font_size + padding + available_height = height - label_space - padding + value_y = y + padding + (available_height - font_size) / 2 + + # Truncate if too long + max_chars = int((width - 2 * padding) / (font_size * 0.5)) + display_value = value[:max_chars] if len(value) > max_chars else value + + # Center horizontally + text_width = c.stringWidth(display_value, style.font_name, font_size) + value_x = x + (width - text_width) / 2 + + c.drawString(value_x, value_y, display_value) + + def _draw_centered_text( + self, + text: str, + x: float, + y: float, + width: float, + height: float + ) -> None: + """Draw text centered in a cell.""" + c = self._canvas + style = self._settings.title_block + + font_size = style.value_font_size + text_width = c.stringWidth(text, style.font_name, font_size) + + text_x = x + (width - text_width) / 2 + text_y = y + (height - font_size) / 2 + + c.drawString(text_x, text_y, text) + + def _draw_border(self) -> None: + """Draw border around content area.""" + if not self._settings.page.show_border: + return + + c = self._canvas + c.setStrokeColor(black) + c.setLineWidth(1) + + # Full content area border (including title block space) + page_style = self._settings.page + x = page_style.margin_left * mm + y = page_style.margin_bottom * mm + w = self._page_width - (page_style.margin_left + page_style.margin_right) * mm + h = self._page_height - (page_style.margin_top + page_style.margin_bottom) * mm + + c.rect(x, y, w, h, fill=0, stroke=1) diff --git a/synodetik/modules/builtins/pdf_export/styles.py b/synodetik/modules/builtins/pdf_export/styles.py new file mode 100644 index 0000000..5243b64 --- /dev/null +++ b/synodetik/modules/builtins/pdf_export/styles.py @@ -0,0 +1,389 @@ +""" +PDF Export Styles. + +Defines the visual styles for PDF export including page settings, +node appearance, connection styling, and title block layout. +""" +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional + + +class PageFormat(Enum): + """Standard page formats.""" + A4 = "A4" + A3 = "A3" + A2 = "A2" + A1 = "A1" + A0 = "A0" + LETTER = "Letter" + LEGAL = "Legal" + TABLOID = "Tabloid" + + +class PageOrientation(Enum): + """Page orientation.""" + PORTRAIT = "Portrait" + LANDSCAPE = "Landscape" + + +class ConnectionLineStyle(Enum): + """Connection line styles.""" + SOLID = "solid" + DASHED = "dashed" + DOTTED = "dotted" + + +class ConnectionPathStyle(Enum): + """Connection path styles.""" + STRAIGHT = "straight" + BEZIER = "bezier" + ORTHOGONAL = "orthogonal" + + +@dataclass +class PageStyle: + """ + Page layout configuration. + + Attributes: + format: Page format (A4, A3, Letter, etc.) + orientation: Portrait or Landscape + margin_top: Top margin in mm + margin_bottom: Bottom margin in mm + margin_left: Left margin in mm + margin_right: Right margin in mm + show_border: Draw border around content area + show_grid: Show background grid + grid_spacing: Grid spacing in mm + grid_color: Grid line color + background_color: Page background color + """ + format: PageFormat = PageFormat.A4 + orientation: PageOrientation = PageOrientation.LANDSCAPE + margin_top: float = 15.0 + margin_bottom: float = 15.0 + margin_left: float = 15.0 + margin_right: float = 15.0 + show_border: bool = True + show_grid: bool = False + grid_spacing: float = 10.0 + grid_color: str = "#E0E0E0" + background_color: str = "#FFFFFF" + + def get_page_size_mm(self) -> tuple[float, float]: + """Get page size in millimeters (width, height).""" + sizes = { + PageFormat.A4: (210, 297), + PageFormat.A3: (297, 420), + PageFormat.A2: (420, 594), + PageFormat.A1: (594, 841), + PageFormat.A0: (841, 1189), + PageFormat.LETTER: (215.9, 279.4), + PageFormat.LEGAL: (215.9, 355.6), + PageFormat.TABLOID: (279.4, 431.8), + } + width, height = sizes.get(self.format, (210, 297)) + + if self.orientation == PageOrientation.LANDSCAPE: + return (height, width) + return (width, height) + + def get_content_area_mm(self) -> tuple[float, float, float, float]: + """Get content area (x, y, width, height) in mm.""" + page_w, page_h = self.get_page_size_mm() + x = self.margin_left + y = self.margin_bottom + w = page_w - self.margin_left - self.margin_right + h = page_h - self.margin_top - self.margin_bottom + return (x, y, w, h) + + +@dataclass +class NodeStyle: + """ + Node rendering style. + + Attributes: + background_color: Node background color (or None to use machine color) + background_opacity: Background opacity (0.0-1.0) + border_color: Border color + border_width: Border width in points + corner_radius: Corner radius in points + use_machine_color: If True, use the machine's color as background + title_font_name: Font for node title + title_font_size: Title font size in points + title_color: Title text color + title_bold: Whether title is bold + port_font_name: Font for port labels + port_font_size: Port label font size + port_color: Port label color + port_indicator_size: Size of port type indicator + show_port_indicators: Show colored indicators for port types + padding: Internal padding in points + min_width: Minimum node width in points + min_height: Minimum node height in points + shadow: Draw drop shadow + shadow_color: Shadow color + shadow_offset: Shadow offset (x, y) + """ + background_color: Optional[str] = "#2D2D2D" + background_opacity: float = 1.0 + border_color: str = "#555555" + border_width: float = 1.0 + corner_radius: float = 6.0 + use_machine_color: bool = True + + title_font_name: str = "Helvetica-Bold" + title_font_size: int = 10 + title_color: str = "#FFFFFF" + title_bold: bool = True + + port_font_name: str = "Helvetica" + port_font_size: int = 8 + port_color: str = "#CCCCCC" + port_indicator_size: float = 6.0 + show_port_indicators: bool = True + + padding: float = 8.0 + min_width: float = 100.0 + min_height: float = 40.0 + + shadow: bool = False + shadow_color: str = "#00000040" + shadow_offset: tuple[float, float] = (2.0, -2.0) + + +@dataclass +class ConnectionStyle: + """ + Connection rendering style. + + Attributes: + line_width: Line width in points + line_style: Solid, dashed, or dotted + path_style: Straight, bezier, or orthogonal + use_flux_color: If True, color connection by flux type + default_color: Default color if not using flux color + show_arrows: Show directional arrows + arrow_size: Arrow head size in points + opacity: Line opacity (0.0-1.0) + """ + line_width: float = 1.5 + line_style: ConnectionLineStyle = ConnectionLineStyle.SOLID + path_style: ConnectionPathStyle = ConnectionPathStyle.BEZIER + use_flux_color: bool = True + default_color: str = "#888888" + show_arrows: bool = True + arrow_size: float = 6.0 + opacity: float = 1.0 + + +@dataclass +class TitleBlockStyle: + """ + Title block (cartouche) rendering style - ISO 7200 compliant. + + Attributes: + enabled: Whether to show title block + height: Height of title block in mm (ISO recommends 55-65mm) + width: Width of title block in mm (ISO minimum 180mm) + position: Position ('bottom-right' per ISO 5457) + background_color: Background color + border_color: Border color + border_width: Border width in points + font_name: Font for text + title_font_size: Main title font size + label_font_size: Label font size + value_font_size: Value font size + text_color: Text color + label_color: Label text color + show_logo: Whether to show company logo + logo_max_height: Maximum logo height in mm + show_grid_lines: Show internal grid lines + """ + enabled: bool = True + height: float = 55.0 # ISO standard height + width: float = 180.0 # ISO minimum width + position: str = "bottom-right" # ISO 5457 requirement + background_color: str = "#FFFFFF" + border_color: str = "#000000" + border_width: float = 0.5 + + font_name: str = "Helvetica" + title_font_size: int = 14 + label_font_size: int = 6 + value_font_size: int = 9 + text_color: str = "#000000" + label_color: str = "#666666" + + show_logo: bool = True + logo_max_height: float = 20.0 + show_grid_lines: bool = True + + +@dataclass +class LegendStyle: + """ + Flux type legend style. + + Attributes: + enabled: Whether to show legend + position: Position ('top-left', 'top-right', 'bottom-left', 'bottom-right') + background_color: Background color + border_color: Border color + border_width: Border width + font_name: Font for text + font_size: Font size + text_color: Text color + indicator_size: Size of color indicator + padding: Internal padding + title: Legend title text + """ + enabled: bool = True + position: str = "top-right" + background_color: str = "#FFFFFF" + border_color: str = "#CCCCCC" + border_width: float = 0.5 + font_name: str = "Helvetica" + font_size: int = 8 + text_color: str = "#333333" + indicator_size: float = 8.0 + padding: float = 6.0 + title: str = "Signal Types" + + +@dataclass +class ExportSettings: + """ + Complete export settings container. + + Groups all style configurations for PDF export. + """ + page: PageStyle = field(default_factory=PageStyle) + node: NodeStyle = field(default_factory=NodeStyle) + connection: ConnectionStyle = field(default_factory=ConnectionStyle) + title_block: TitleBlockStyle = field(default_factory=TitleBlockStyle) + legend: LegendStyle = field(default_factory=LegendStyle) + + # Scaling options + auto_fit: bool = True # Auto-scale to fit page + scale: float = 1.0 # Manual scale factor (if auto_fit is False) + center_content: bool = True # Center content on page + + # Metadata + pdf_title: str = "" + pdf_author: str = "" + pdf_subject: str = "" + + +# ============================================================================= +# Style Presets +# ============================================================================= + +def get_preset_dark() -> ExportSettings: + """Dark theme preset - similar to editor appearance.""" + settings = ExportSettings() + settings.page.background_color = "#1E1E1E" + settings.page.grid_color = "#333333" + settings.node.background_color = "#2D2D2D" + settings.node.border_color = "#555555" + settings.node.title_color = "#FFFFFF" + settings.node.port_color = "#CCCCCC" + settings.connection.default_color = "#888888" + settings.title_block.background_color = "#2D2D2D" + settings.title_block.border_color = "#555555" + settings.title_block.text_color = "#FFFFFF" + settings.title_block.label_color = "#999999" + settings.legend.background_color = "#2D2D2D" + settings.legend.border_color = "#555555" + settings.legend.text_color = "#FFFFFF" + return settings + + +def get_preset_light() -> ExportSettings: + """Light theme preset - clean white background.""" + settings = ExportSettings() + settings.page.background_color = "#FFFFFF" + settings.page.grid_color = "#E0E0E0" + settings.node.background_color = "#F5F5F5" + settings.node.border_color = "#CCCCCC" + settings.node.title_color = "#333333" + settings.node.port_color = "#666666" + settings.connection.default_color = "#999999" + return settings + + +def get_preset_blueprint() -> ExportSettings: + """Blueprint style - technical drawing look.""" + settings = ExportSettings() + settings.page.background_color = "#1A3A5C" + settings.page.show_grid = True + settings.page.grid_color = "#2A4A6C" + settings.node.background_color = "#1A3A5C" + settings.node.border_color = "#FFFFFF" + settings.node.border_width = 1.5 + settings.node.title_color = "#FFFFFF" + settings.node.port_color = "#CCDDEE" + settings.node.use_machine_color = False + settings.connection.default_color = "#FFFFFF" + settings.connection.use_flux_color = False + settings.title_block.background_color = "#1A3A5C" + settings.title_block.border_color = "#FFFFFF" + settings.title_block.text_color = "#FFFFFF" + settings.title_block.label_color = "#AABBCC" + settings.legend.background_color = "#1A3A5C" + settings.legend.border_color = "#FFFFFF" + settings.legend.text_color = "#FFFFFF" + return settings + + +def get_preset_minimal() -> ExportSettings: + """Minimal preset - clean with thin lines.""" + settings = ExportSettings() + settings.page.background_color = "#FFFFFF" + settings.page.show_border = False + settings.node.background_color = "#FFFFFF" + settings.node.border_color = "#333333" + settings.node.border_width = 0.5 + settings.node.corner_radius = 0 + settings.node.shadow = False + settings.node.title_color = "#000000" + settings.node.port_color = "#333333" + settings.connection.line_width = 1.0 + settings.connection.show_arrows = False + settings.title_block.border_width = 0.5 + settings.legend.border_width = 0.5 + return settings + + +def get_preset_print() -> ExportSettings: + """Print-optimized preset - high contrast for printing.""" + settings = ExportSettings() + settings.page.background_color = "#FFFFFF" + settings.node.background_color = "#FFFFFF" + settings.node.border_color = "#000000" + settings.node.border_width = 1.0 + settings.node.title_color = "#000000" + settings.node.title_bold = True + settings.node.port_color = "#000000" + settings.node.use_machine_color = True + settings.node.background_opacity = 0.3 + settings.connection.default_color = "#000000" + settings.connection.line_width = 1.0 + return settings + + +PRESETS = { + "dark": get_preset_dark, + "light": get_preset_light, + "blueprint": get_preset_blueprint, + "minimal": get_preset_minimal, + "print": get_preset_print, +} + + +def get_preset(name: str) -> ExportSettings: + """Get a preset by name.""" + factory = PRESETS.get(name.lower(), get_preset_light) + return factory()