Architecture diagramming library with Python DSL that outputs SVG.
specplot/
├── __init__.py # Public API exports
├── models.py # Data models (Node, Edge, Diagram, ShowAs, EdgeStyle)
├── icons.py # Material Symbols icon fetcher with caching
├── layout.py # Layout algorithms, fixed-width nodes, grid positioning
├── renderer.py # SVG rendering with drawsvg, themes, edge curves
├── pathfinding.py # A* grid-based edge routing with obstacle avoidance
└── dsl.py # Python DSL (context managers, >> operator for edges)
- Nodes: Fixed-width boxes with icon, label, optional description
- ShowAs modes:
"group"(nested child nodes) or"outline"(bullet list) - use string literals - Edges: Connect nodes with
>>operator, labels with| "text" - Grid layout:
grid=(rows, cols)for arranging children - DSL flexibility: Context managers optional for nodes without children (
node(...)vswith node(...):)
uv run python main.py # generates example.svg- Fixed node width (200px) for consistent appearance
- Descriptions: Fixed 2-line box (38px height) with word-boundary-aware wrapping
- Text truncation: Labels 20 chars, outline items 24 chars; descriptions wrap to 2 lines then truncate
- Text width estimation: Uses
char_width_avg(4.8px) for 11px sans-serif font - Edges from OUTLINE children connect from parent node's edge at the item's y-position
- OUTLINE parent nodes connect edges from header area (not center)
- Smooth bezier curves with horizontal exit/entry (
control_offset = max(50, dx * 0.5)) - Material Symbols icons fetched from Google CDN, cached locally (coordinate system detection for transform)
- Edges rendered on top of nodes (arrowheads visible)
- No native text-overflow with ellipsis - must pre-calculate truncation
- No native text wrapping - manually split into multiple
<text>elements foreignObjectwith HTML/CSS works in browsers but fails in vector editors
The pathfinding system (pathfinding.py) routes edges around obstacles using A* search on a virtual grid. It replaces simple bezier curves with intelligent paths that avoid crossing through nodes.
-
VirtualGrid: NetworkX graph overlaid on the diagram
- Grid spacing configurable (default 15px)
- Nodes mark cells as obstacles with configurable margin
- Edges connect to "snapping points" on node borders
-
Snapping Points: Connection points on node boundaries
- Distributed along each side (left, right, top, bottom)
- Gaussian-weighted preference for center positions
- Support
no_entryflag for restricted areas (e.g., group header zones on right side)
-
EdgeRouter (
renderer.py): Orchestrates edge routing- Groups edges by node+side for distributed point assignment
- Uses A* to find paths between snapping points
- Applies Douglas-Peucker simplification for smooth rendering
For each node, snapping points are created on all four sides:
- Left/Bottom: Fully available for connections
- Right: Points in header zone of GROUP nodes marked
no_entry=True - Top: Available for all nodes (header zone protected by obstacle grid)
Points use Gaussian weighting (sigma = side_length / 6) to prefer center positions.
Nodes are marked as obstacles in the grid with margins:
- Regular nodes: Full bounds + margin
- GROUP nodes: Header zone marked separately as obstacle
- OUTLINE nodes: Treated as regular nodes (children are list items, not boxes)
A* search with custom heuristics:
diagonal_penalty: Prefers orthogonal movement (default 1.5x)turn_penalty: Discourages direction changes (default 2.0)proximity_penalty_weight: Penalizes paths near obstacles
- smooth: Douglas-Peucker simplification + rounded corners at waypoints
- orthogonal: Strict horizontal/vertical segments only
The DSL returns NodeContext wrappers from node(). When creating edges:
- Context manager (
with node(...) as x) returns the actualNode - Direct call (
x = node(...)) returnsNodeContext
Node.__rshift__, __lshift__, __sub__ use duck-typing to unwrap:
if hasattr(other, '_node'):
other = other._nodePathfindingConfig(
enabled=True, # Toggle pathfinding
grid_spacing=15.0, # Grid cell size
path_style="smooth", # "smooth" or "orthogonal"
diagonal_penalty=1.5, # Prefer orthogonal paths
turn_penalty=2.0, # Prefer straight paths
node_margin=1.0, # Obstacle margin (grid cells)
layout_spacing_multiplier=2.0, # Extra space for routing
debug=False, # Render grid points
)Set debug=True to render virtual grid points in the SVG:
- Blue dots: Regular grid points
- Red dots: Blocked/obstacle points
- Green dots: Snapping points on node borders