Adding export with customizable ISO 7200 cartouche

This commit is contained in:
ange committed 2026-01-17 21:42:30 +01:00
1 parent 1d78637578
commit 9b4cc3f551
16 files changed
+3626 -42

No files matched your search

+169
View File
@@ -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)
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+1
View File
@@ -1,2 +1,3 @@
dearpygui>=1.9.0
pyyaml>=6.0
reportlab
+13 -1
View File
@@ -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',
]
+28 -2
View File
@@ -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",
]
+104
View File
@@ -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",
]
+391
View File
@@ -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
+130
View File
@@ -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)
+202
View File
@@ -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))
+84
View File
@@ -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
+154 -39
View File
@@ -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)
@@ -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",
]
@@ -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)
@@ -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()
)
@@ -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)
@@ -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()