From 9b4cc3f551834d3292624968333d5337a0b0481d Mon Sep 17 00:00:00 2001 From: Ange-Marie MAURIN Date: Sat, 17 Jan 2026 21:42:30 +0100 Subject: [PATCH] Adding export with customizable ISO 7200 cartouche --- README.md | 169 ++++ gl_audiovisual_power.png | Bin 0 -> 17141 bytes requirements.txt | 1 + synodetik/__init__.py | 14 +- synodetik/core/__init__.py | 30 +- synodetik/core/commands/__init__.py | 104 ++ synodetik/core/commands/base.py | 391 ++++++++ synodetik/core/commands/connection.py | 130 +++ synodetik/core/commands/machine.py | 202 ++++ synodetik/core/synoptik.py | 84 ++ synodetik/gui/app.py | 193 +++- .../modules/builtins/pdf_export/__init__.py | 83 ++ .../modules/builtins/pdf_export/dialog.py | 810 +++++++++++++++ .../modules/builtins/pdf_export/module.py | 133 +++ .../modules/builtins/pdf_export/renderer.py | 935 ++++++++++++++++++ .../modules/builtins/pdf_export/styles.py | 389 ++++++++ 16 files changed, 3626 insertions(+), 42 deletions(-) create mode 100644 gl_audiovisual_power.png create mode 100644 synodetik/core/commands/__init__.py create mode 100644 synodetik/core/commands/base.py create mode 100644 synodetik/core/commands/connection.py create mode 100644 synodetik/core/commands/machine.py create mode 100644 synodetik/modules/builtins/pdf_export/__init__.py create mode 100644 synodetik/modules/builtins/pdf_export/dialog.py create mode 100644 synodetik/modules/builtins/pdf_export/module.py create mode 100644 synodetik/modules/builtins/pdf_export/renderer.py create mode 100644 synodetik/modules/builtins/pdf_export/styles.py 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 0000000000000000000000000000000000000000..bb664bc85c76dd965756eb4bcca9bccaf2b37c57 GIT binary patch literal 17141 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 n%Yw7yI7;P*7N* literal 0 HcmV?d00001 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()