Adding export with customizable ISO 7200 cartouche
This commit is contained in:
1 parent
1d78637578
commit
9b4cc3f551
16 files changed
+3626
-42
No files matched your search
@@ -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,2 +1,3 @@
|
||||
dearpygui>=1.9.0
|
||||
pyyaml>=6.0
|
||||
reportlab
|
||||
+13
-1
@@ -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',
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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
@@ -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()
|
||||
Reference in new issue
Block a user