From d7c4a11520888757a130aaf7425730d041d33d83 Mon Sep 17 00:00:00 2001
From: Eatham532 <78714349+Eatham532@users.noreply.github.com>
Date: Tue, 28 Oct 2025 16:00:28 +1100
Subject: [PATCH 1/3] feat: Custom Structure chart init
Signed-off-by: Eatham532 <78714349+Eatham532@users.noreply.github.com>
---
docs/structure-chart-examples.md | 141 ++++++
extensions/structure_chart.py | 735 +++++++++++++++++++++++++++++++
mkdocs.yml | 5 +
test_structure_chart.py | 419 ++++++++++++++++++
4 files changed, 1300 insertions(+)
create mode 100644 docs/structure-chart-examples.md
create mode 100644 extensions/structure_chart.py
create mode 100644 test_structure_chart.py
diff --git a/docs/structure-chart-examples.md b/docs/structure-chart-examples.md
new file mode 100644
index 00000000..9623b471
--- /dev/null
+++ b/docs/structure-chart-examples.md
@@ -0,0 +1,141 @@
+# Structure Chart Examples
+
+This page demonstrates the custom structure chart syntax and capabilities.
+
+## Basic Example
+
+A simple structure chart with a control module and sub-modules:
+
+```structure-chart
+module main "Grade Calculator"
+ module input "Get Input"
+ module calc "Calculate Grade"
+ module output "Display Result"
+
+main -> input
+main -> calc
+main -> output
+```
+
+## With Connection Types
+
+Shows different types of connections (data flow, control flow, conditional):
+
+```structure-chart
+module main "Process Order"
+ module validate "Validate Order"
+ module check "Check Stock"
+ module process "Process Payment"
+ module ship "Ship Items"
+
+main -> validate data "order details"
+validate -> check control
+validate -> process conditional
+check -> ship data "items"
+```
+
+## Library Modules
+
+Library modules are shown with an underline and can be called from multiple places:
+
+```structure-chart
+module app "Main App"
+ module userInput "User Input"
+ module processData "Process Data"
+ library logger "Log Message"
+
+app -> userInput
+app -> processData
+userInput -> logger data "user action"
+processData -> logger data "result"
+```
+
+## With Loop
+
+A loop indicator shows repetitive execution:
+
+```structure-chart
+module main "Process Files"
+ module readFile "Read File"
+ module parseData "Parse Data"
+ module saveData "Save Data"
+
+main -> readFile
+readFile -> parseData
+parseData -> saveData
+
+loop over readFile parseData saveData
+```
+
+## With Storage
+
+Physical storage elements (databases, files):
+
+```structure-chart
+module app "Student System"
+ module add "Add Student"
+ module view "View Students"
+storage db "Student Database"
+
+app -> add
+app -> view
+add -> db data "student record"
+view -> db data "query"
+```
+
+## Complete Example
+
+A comprehensive example showing all features:
+
+```structure-chart
+# Grade Management System
+module main "Grade Manager"
+ module input "Input Grades"
+ module calc "Calculate Average"
+ module validate "Validate Data"
+ module report "Generate Report"
+ library util "Utility Functions"
+storage gradeDB "Grade Database"
+
+main -> input
+main -> validate conditional
+input -> calc data "grades"
+calc -> validate control
+validate -> report data "validated grades"
+input -> util data "format check"
+calc -> util data "math operations"
+report -> gradeDB data "save results"
+
+loop over input calc validate
+```
+
+## Syntax Reference
+
+### Modules
+
+```text
+module id "Label" # Regular module
+library id "Label" # Library module (underlined, reusable)
+storage id "Label" # Physical storage (curved rectangle)
+```
+
+### Connections
+
+```text
+from -> to # Simple connection
+from -> to data "label" # Data flow (empty circle)
+from -> to control # Control flow (filled circle)
+from -> to conditional # Conditional call (diamond)
+```
+
+### Loops
+
+```text
+loop over module1 module2 module3 # Curved arrow over modules
+```
+
+### Layout
+
+- Indentation (2 spaces per level) determines hierarchy
+- Modules at the same indentation appear on the same level
+- Parent-child relationships are shown through connections
diff --git a/extensions/structure_chart.py b/extensions/structure_chart.py
new file mode 100644
index 00000000..3c3dae08
--- /dev/null
+++ b/extensions/structure_chart.py
@@ -0,0 +1,735 @@
+"""
+Custom Structure Chart Extension for MkDocs
+Generates SVG structure charts from a simple text-based syntax.
+
+Structure Chart Elements:
+1. Module - Rectangle with name
+ - Control Module: Branches to multiple submodules
+ - Sub Module: Child of another module
+ - Library Module: Reusable, invokable from any module (underlined)
+2. Conditional Call - Diamond on connection line
+3. Loop - Curved arrow covering multiple modules
+4. Data Flow - Arrow with empty circle
+5. Control Flow - Arrow with filled circle
+6. Physical Storage - Curved rectangle
+"""
+
+from markdown.extensions import Extension
+from markdown.preprocessors import Preprocessor
+import re
+import hashlib
+from xml.etree import ElementTree as etree
+
+
+class StructureChartGenerator:
+ """Generates SVG structure charts from text definitions."""
+
+ # Layout constants
+ MODULE_WIDTH = 120
+ MODULE_HEIGHT = 50
+ MODULE_SPACING_X = 40
+ MODULE_SPACING_Y = 80
+ LIBRARY_MODULE_COLOR = "#e3f2fd" # Light blue for library modules
+ MODULE_COLOR = "#ffffff"
+ STROKE_COLOR = "#333333"
+ STORAGE_COLOR = "#fff9c4" # Light yellow for storage
+
+ def __init__(self, chart_def: str):
+ """
+ Initialize with chart definition.
+
+ Args:
+ chart_def: Text definition of the structure chart
+ """
+ self.chart_def = chart_def
+ self.modules = {} # id -> module data
+ self.connections = [] # list of connection data
+ self.conditionals = [] # list of conditional gates (diamond with multiple outputs)
+ self.loops = [] # list of loop definitions
+ self.storages = [] # list of storage elements
+ self.levels = {} # level -> list of module ids
+
+ def parse(self):
+ """Parse the chart definition."""
+ lines = self.chart_def.strip().split('\n')
+ current_level = 0
+
+ for line in lines:
+ line = line.rstrip()
+ if not line or line.strip().startswith('#'):
+ continue
+
+ # Calculate indentation level (2 spaces = 1 level)
+ indent = len(line) - len(line.lstrip())
+ level = indent // 2
+ content = line.strip()
+
+ # Parse different element types
+ if content.startswith('module '):
+ self._parse_module(content, level)
+ elif content.startswith('library '):
+ self._parse_library(content, level)
+ elif content.startswith('storage '):
+ self._parse_storage(content, level)
+ elif content.startswith('conditional '):
+ self._parse_conditional(content)
+ elif content.startswith('loop '):
+ self._parse_loop(content)
+ elif '->' in content:
+ self._parse_connection(content)
+
+ def _parse_module(self, content: str, level: int):
+ """Parse a module definition: module id "Label" """
+ match = re.match(r'module\s+(\w+)\s+"([^"]+)"', content)
+ if match:
+ module_id = match.group(1)
+ label = match.group(2)
+ self.modules[module_id] = {
+ 'id': module_id,
+ 'label': label,
+ 'level': level,
+ 'type': 'module'
+ }
+ if level not in self.levels:
+ self.levels[level] = []
+ self.levels[level].append(module_id)
+
+ def _parse_library(self, content: str, level: int):
+ """Parse a library module: library id "Label" """
+ match = re.match(r'library\s+(\w+)\s+"([^"]+)"', content)
+ if match:
+ module_id = match.group(1)
+ label = match.group(2)
+ self.modules[module_id] = {
+ 'id': module_id,
+ 'label': label,
+ 'level': level,
+ 'type': 'library'
+ }
+ if level not in self.levels:
+ self.levels[level] = []
+ self.levels[level].append(module_id)
+
+ def _parse_storage(self, content: str, level: int):
+ """Parse a storage element: storage id "Label" """
+ match = re.match(r'storage\s+(\w+)\s+"([^"]+)"', content)
+ if match:
+ storage_id = match.group(1)
+ label = match.group(2)
+ self.storages.append({
+ 'id': storage_id,
+ 'label': label,
+ 'level': level
+ })
+
+ def _parse_conditional(self, content: str):
+ """
+ Parse a conditional gate: conditional from to_id1 to_id2 to_id3
+ Example: conditional main optionA optionB optionC
+ """
+ parts = content.split()[1:] # Skip 'conditional' keyword
+ if len(parts) < 2:
+ return
+
+ from_id = parts[0]
+ to_ids = parts[1:]
+
+ self.conditionals.append({
+ 'from': from_id,
+ 'to_list': to_ids
+ })
+
+ def _parse_loop(self, content: str):
+ """Parse a loop definition: loop over id1 id2 id3"""
+ match = re.match(r'loop\s+over\s+(.+)', content)
+ if match:
+ module_ids = match.group(1).split()
+ self.loops.append(module_ids)
+
+ def _parse_connection(self, content: str):
+ """
+ Parse a connection: from -> to [type] [direction] [label]
+ Types: data, control, normal
+ Direction: forward (default), backward (for arrows pointing opposite to connection)
+ Examples:
+ a -> b
+ a -> b data "user input"
+ a -> b data forward "user input"
+ a -> b data backward "response"
+ a -> b control
+ a -> b control backward
+ """
+ parts = content.split('->')
+ if len(parts) != 2:
+ return
+
+ from_id = parts[0].strip()
+ rest = parts[1].strip().split(None, 3)
+
+ to_id = rest[0]
+ conn_type = 'normal'
+ direction = 'forward' # default direction
+ label = ''
+
+ if len(rest) > 1:
+ conn_type = rest[1]
+ if len(rest) > 2:
+ # Check if next item is direction or label
+ if rest[2] in ['forward', 'backward']:
+ direction = rest[2]
+ if len(rest) > 3:
+ label = rest[3].strip('"')
+ else:
+ label = rest[2].strip('"')
+
+ self.connections.append({
+ 'from': from_id,
+ 'to': to_id,
+ 'type': conn_type,
+ 'direction': direction,
+ 'label': label
+ })
+
+ def _calculate_positions(self):
+ """Calculate x, y positions for all modules based on hierarchy."""
+ # Build parent-child relationships from connections AND conditionals
+ children = {} # parent_id -> list of child_ids
+ parents = {} # child_id -> parent_id
+
+ for conn in self.connections:
+ parent = conn['from']
+ child = conn['to']
+ if parent not in children:
+ children[parent] = []
+ children[parent].append(child)
+ parents[child] = parent
+
+ # Add conditional connections - children go below the conditional parent
+ for conditional in self.conditionals:
+ parent = conditional['from']
+ for child in conditional['to_list']:
+ if parent not in children:
+ children[parent] = []
+ if child not in children[parent]: # Avoid duplicates
+ children[parent].append(child)
+ if child not in parents: # Don't override if already has a parent
+ parents[child] = parent
+
+ # Find root modules (no parent)
+ roots = [mid for mid in self.modules.keys() if mid not in parents]
+
+ # Position modules using tree layout
+ self._position_tree(roots, children, 0, 0)
+
+ def _position_tree(self, module_ids, children, x_offset, y_level):
+ """Recursively position modules in a tree structure."""
+ if not module_ids:
+ return x_offset
+
+ current_x = x_offset
+
+ for module_id in module_ids:
+ if module_id not in self.modules:
+ continue
+
+ # Get children of this module
+ child_ids = children.get(module_id, [])
+
+ if child_ids:
+ # Position children first to determine width needed
+ child_start_x = current_x
+ child_end_x = self._position_tree(
+ child_ids,
+ children,
+ child_start_x,
+ y_level + 1
+ )
+
+ # Center parent over children
+ parent_x = (child_start_x + child_end_x - self.MODULE_WIDTH) / 2
+ current_x = child_end_x
+ else:
+ # Leaf node - just place it
+ parent_x = current_x
+ current_x += self.MODULE_WIDTH + self.MODULE_SPACING_X
+
+ # Set position
+ self.modules[module_id]['x'] = parent_x
+ self.modules[module_id]['y'] = y_level * (self.MODULE_HEIGHT + self.MODULE_SPACING_Y)
+ self.modules[module_id]['level'] = y_level
+
+ return current_x
+
+ def _draw_module(self, module: dict) -> str:
+ """Draw a single module as SVG."""
+ x, y = module['x'], module['y']
+ label = module['label']
+ is_library = module['type'] == 'library'
+
+ fill_color = self.LIBRARY_MODULE_COLOR if is_library else self.MODULE_COLOR
+
+ # Split long labels into multiple lines with better logic
+ words = label.split()
+ lines = []
+ current_line = []
+ max_chars = 16 # Maximum characters per line
+
+ for word in words:
+ test_line = ' '.join(current_line + [word])
+ if len(test_line) > max_chars and current_line:
+ lines.append(' '.join(current_line))
+ current_line = [word]
+ else:
+ current_line.append(word)
+ if current_line:
+ lines.append(' '.join(current_line))
+
+ svg = f'\n'
+
+ # Add subtle drop shadow for depth
+ svg += f' \n'
+
+ # Main module rectangle
+ svg += f' \n'
+
+ # Add text (centered, with better vertical spacing)
+ line_height = 14
+ total_text_height = len(lines) * line_height
+ text_start_y = y + (self.MODULE_HEIGHT - total_text_height) / 2 + line_height / 2
+
+ for i, line in enumerate(lines):
+ line_y = text_start_y + i * line_height
+ svg += f' {line}\n'
+
+ # Add underline for library modules (positioned below text)
+ if is_library:
+ underline_y = text_start_y + (len(lines) - 1) * line_height + 8
+ svg += f' \n'
+
+ svg += '\n'
+ return svg
+
+ def _draw_connection(self, conn: dict, offset_multiplier: float = 0) -> str:
+ """Draw a connection between modules.
+
+ Args:
+ conn: Connection dictionary with from, to, type, etc.
+ offset_multiplier: Multiplier for offsetting multiple arrows (-1, 0, 1, etc.)
+ """
+ if conn['from'] not in self.modules or conn['to'] not in self.modules:
+ return ''
+
+ from_module = self.modules[conn['from']]
+ to_module = self.modules[conn['to']]
+
+ # Tree-like structure: all connections exit from center bottom of parent
+ from_center_x = from_module['x'] + self.MODULE_WIDTH / 2
+ from_bottom_y = from_module['y'] + self.MODULE_HEIGHT
+
+ to_center_x = to_module['x'] + self.MODULE_WIDTH / 2
+ to_top_y = to_module['y']
+
+ # For parent-child relationships, use simple vertical connection from center
+ x1 = from_center_x
+ y1 = from_bottom_y
+ x2 = to_center_x
+ y2 = to_top_y
+
+ svg = '\n'
+
+ # Add data/control indicators ALONG the line (back-to-back for multiples)
+ if conn['type'] in ['data', 'control']:
+ import math
+
+ # Calculate line angle and perpendicular
+ angle = math.atan2(y2 - y1, x2 - x1)
+ perp_angle = angle + math.pi / 2
+
+ # Position indicators ALONG the line at different positions for multiples
+ # First arrow at 30% along line, second at 50%, third at 70%, etc.
+ line_positions = [0.3, 0.5, 0.7, 0.85]
+ position_index = min(int(offset_multiplier), len(line_positions) - 1)
+ line_position = line_positions[position_index]
+
+ # Calculate position along the line
+ indicator_center_x = x1 + (x2 - x1) * line_position
+ indicator_center_y = y1 + (y2 - y1) * line_position
+
+ # Offset slightly to the side so not directly on main line
+ side_offset = 12 # Small offset to the side
+ indicator_center_x += math.cos(perp_angle) * side_offset
+ indicator_center_y += math.sin(perp_angle) * side_offset
+
+ # Arrow properties (length is 2x the arrowhead which is ~10px)
+ arrow_length = 20 # Total length of the arrow
+
+ # Determine arrow direction (forward = same as line, backward = opposite)
+ if conn.get('direction', 'forward') == 'backward':
+ arrow_angle = angle + math.pi # Point opposite direction
+ else:
+ arrow_angle = angle # Point same direction as line
+
+ # Calculate arrow positions
+ circle_x = indicator_center_x - math.cos(arrow_angle) * arrow_length / 2
+ circle_y = indicator_center_y - math.sin(arrow_angle) * arrow_length / 2
+ arrow_tip_x = indicator_center_x + math.cos(arrow_angle) * arrow_length / 2
+ arrow_tip_y = indicator_center_y + math.sin(arrow_angle) * arrow_length / 2
+
+ # Draw the arrow LINE FIRST (before circles, so circles cover it)
+ svg += f' \n'
+
+ if conn['type'] == 'data':
+ # Data flow: Circle with arrow (empty circle with white fill)
+ # Draw circle AFTER line so it covers the line
+ svg += f' \n'
+
+ elif conn['type'] == 'control':
+ # Control flow: Filled circle with arrow (solid circle)
+ # Draw filled circle AFTER line so it covers the line
+ svg += f' \n'
+
+ # Draw main connection line (always present) - AFTER arrows so it doesn't cover them
+ svg += f' \n'
+
+ # Add label if present (positioned beside the arrow along the line)
+ if conn['label'] and conn['type'] in ['data', 'control']:
+ import math
+ # Position label next to the arrow indicator
+ angle = math.atan2(y2 - y1, x2 - x1)
+ perp_angle = angle + math.pi / 2
+
+ # Use same position along line as the arrow
+ line_positions = [0.3, 0.5, 0.7, 0.85]
+ position_index = min(int(offset_multiplier), len(line_positions) - 1)
+ line_position = line_positions[position_index]
+
+ # Position along the line
+ label_line_x = x1 + (x2 - x1) * line_position
+ label_line_y = y1 + (y2 - y1) * line_position
+
+ # Estimate text width and position to the side
+ label_text = conn['label']
+ text_width = len(label_text) * 6
+
+ # Position label to the side (offset from line)
+ # arrow is at 12px to the side, so label goes further out
+ label_offset = 12 + 20 + 8 + text_width / 2
+
+ label_x = label_line_x + math.cos(perp_angle) * label_offset
+ label_y = label_line_y + math.sin(perp_angle) * label_offset
+
+ svg += f' {label_text}\n'
+ elif conn['label']:
+ # For connections without data/control, place label to the side
+ import math
+ angle = math.atan2(y2 - y1, x2 - x1)
+ perp_angle = angle + math.pi / 2
+
+ mid_x = (x1 + x2) / 2
+ mid_y = (y1 + y2) / 2
+ label_offset = 15
+ label_x = mid_x + math.cos(perp_angle) * label_offset
+ label_y = mid_y + math.sin(perp_angle) * label_offset
+
+ svg += f' {conn["label"]}\n'
+
+ svg += '\n'
+ return svg
+
+ def _draw_loop(self, module_ids: list) -> str:
+ """Draw a simple circular loop indicator beneath the parent module."""
+ if not module_ids:
+ return ''
+
+ # Get positions of all modules in loop
+ modules = [self.modules[mid] for mid in module_ids if mid in self.modules]
+ if not modules:
+ return ''
+
+ # Find the ACTUAL parent module by checking connections
+ parent_module = None
+ for conn in self.connections:
+ if conn['to'] in module_ids:
+ potential_parent = conn['from']
+ if potential_parent not in module_ids and potential_parent in self.modules:
+ parent_module = self.modules[potential_parent]
+ break
+
+ # Fallback: if no parent found, use the first module's parent level
+ if not parent_module:
+ loop_level = min(m.get('level', 0) for m in modules)
+ if loop_level > 0:
+ for mid, m in self.modules.items():
+ if m.get('level', 0) == loop_level - 1:
+ parent_module = m
+ break
+
+ if not parent_module:
+ parent_module = modules[0]
+
+ # Calculate parent module bounds
+ parent_center_x = parent_module['x'] + self.MODULE_WIDTH / 2
+ parent_bottom_y = parent_module['y'] + self.MODULE_HEIGHT
+
+ # Larger, more visible circular loop below the parent module
+ loop_radius = 25 # Increased from 15 for better visibility
+ loop_center_y = parent_bottom_y + loop_radius + 10 # Moved down more (was +5)
+
+ # Draw a circular arc with arrow pointing back up
+ svg = '\n'
+
+ # Draw almost a complete circle - start and end near the top
+ # Start point (left side, near parent)
+ start_x = parent_center_x - loop_radius + 5
+ start_y = parent_bottom_y + 8
+
+ # End point (right side, near parent - arrow will point here)
+ end_x = parent_center_x + loop_radius - 5
+ end_y = parent_bottom_y + 8
+
+ # Create a large circular arc that shows most of the circle
+ svg += f' \n'
+ svg += '\n'
+ return svg
+
+ def _draw_storage(self, storage: dict, position: int) -> str:
+ """Draw a storage element (curved rectangle with database styling)."""
+ # Position storage elements at the bottom
+ max_level = max(self.levels.keys()) if self.levels else 0
+ x = -self.MODULE_WIDTH / 2 + position * (self.MODULE_WIDTH + self.MODULE_SPACING_X)
+ y = (max_level + 1) * (self.MODULE_HEIGHT + self.MODULE_SPACING_Y)
+
+ svg = '\n'
+
+ # Add shadow for depth
+ svg += f' \n'
+
+ # Create a rounded rectangle that looks like a database
+ svg += f' \n'
+
+ # Add a top "cap" line to make it look more like a cylinder/database
+ cap_height = 8
+ svg += f' \n'
+
+ # Add text (centered)
+ svg += f' {storage["label"]}\n'
+ svg += '\n'
+ return svg
+
+ def _draw_conditional(self, conditional: dict) -> str:
+ """Draw a conditional gate (diamond) with multiple output connections."""
+ if conditional['from'] not in self.modules:
+ return ''
+
+ from_module = self.modules[conditional['from']]
+ from_x = from_module['x'] + self.MODULE_WIDTH / 2
+ from_y = from_module['y'] + self.MODULE_HEIGHT
+
+ # Position diamond below the from module
+ diamond_size = 20
+ diamond_x = from_x
+ diamond_y = from_y + 30
+
+ svg = '\n'
+
+ # Add shadow for depth
+ shadow_offset = 2
+ svg += f' \n'
+
+ # Draw diamond with gradient-like fill
+ svg += f' \n'
+
+ # Draw line from module to diamond
+ svg += f' \n'
+
+ # Draw lines from diamond to each destination module
+ for to_id in conditional['to_list']:
+ if to_id in self.modules:
+ to_module = self.modules[to_id]
+ to_x = to_module['x'] + self.MODULE_WIDTH / 2
+ to_y = to_module['y']
+
+ # Line from diamond to module
+ svg += f' \n'
+
+ svg += '\n'
+ return svg
+
+ def generate_svg(self) -> str:
+ """Generate the complete SVG diagram."""
+ self.parse()
+ self._calculate_positions()
+
+ if not self.modules:
+ return ''
+
+ # Calculate SVG dimensions
+ all_x = [m['x'] for m in self.modules.values()]
+ all_y = [m['y'] for m in self.modules.values()]
+
+ min_x = min(all_x) - 50
+ max_x = max(all_x) + self.MODULE_WIDTH + 50
+ min_y = min(all_y) - 80 # Extra space for loops
+ max_y = max(all_y) + self.MODULE_HEIGHT + 50
+
+ # Add space for storage if present
+ if self.storages:
+ max_level = max(self.levels.keys()) if self.levels else 0
+ max_y = (max_level + 1) * (self.MODULE_HEIGHT + self.MODULE_SPACING_Y) + self.MODULE_HEIGHT + 50
+
+ width = max_x - min_x
+ height = max_y - min_y
+
+ # Build SVG
+ svg = f''
+ return svg
+
+
+class StructureChartPreprocessor(Preprocessor):
+ """Preprocessor to convert structure-chart fenced blocks to SVG."""
+
+ def run(self, lines):
+ new_lines = []
+ in_structure_chart = False
+ chart_lines = []
+
+ i = 0
+ while i < len(lines):
+ line = lines[i]
+
+ # Check for structure chart fence start
+ if line.strip() == '```structure-chart':
+ in_structure_chart = True
+ chart_lines = []
+ i += 1
+ continue
+
+ # Check for fence end
+ if in_structure_chart and line.strip() == '```':
+ # Generate SVG
+ chart_def = '\n'.join(chart_lines)
+ generator = StructureChartGenerator(chart_def)
+ svg = generator.generate_svg()
+
+ # Generate a unique ID for the diagram
+ diagram_id = hashlib.md5(chart_def.encode()).hexdigest()[:8]
+
+ # Wrap in diagram container for modal support
+ new_lines.append(f'
')
+ new_lines.append(svg)
+ new_lines.append(f'')
+ new_lines.append('
')
+ new_lines.append('')
+
+ in_structure_chart = False
+ chart_lines = []
+ i += 1
+ continue
+
+ # Accumulate chart lines or pass through
+ if in_structure_chart:
+ chart_lines.append(line)
+ else:
+ new_lines.append(line)
+
+ i += 1
+
+ return new_lines
+
+
+class StructureChartExtension(Extension):
+ """MkDocs extension for structure charts."""
+
+ def extendMarkdown(self, md):
+ md.preprocessors.register(
+ StructureChartPreprocessor(md),
+ 'structure_chart',
+ priority=175 # Before fenced_code
+ )
+
+
+def makeExtension(**kwargs):
+ return StructureChartExtension(**kwargs)
diff --git a/mkdocs.yml b/mkdocs.yml
index a7732769..96310aa3 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -95,6 +95,7 @@ markdown_extensions:
- footnotes
- extensions.glossary_extension
- extensions.kroki_wrapper
+ - extensions.structure_chart
- md_in_html
- smarty
- tables
@@ -107,6 +108,10 @@ markdown_extensions:
- pymdownx.tasklist
- pymdownx.superfences:
custom_fences:
+ # Structure Charts
+ - name: structure-chart
+ class: structure-chart
+ format: !!python/name:extensions.structure_chart.format_structure_chart
# Python
- name: python-exec
class: python-exec
diff --git a/test_structure_chart.py b/test_structure_chart.py
new file mode 100644
index 00000000..547e2d6a
--- /dev/null
+++ b/test_structure_chart.py
@@ -0,0 +1,419 @@
+"""
+Test script for structure chart generation.
+Generates SVG files directly without running MkDocs server.
+"""
+
+import sys
+from pathlib import Path
+
+# Add project root to path to import extensions
+project_root = Path(__file__).parent
+sys.path.insert(0, str(project_root))
+
+from extensions.structure_chart import StructureChartGenerator
+
+
+def save_svg(svg_content: str, filename: str):
+ """Save SVG to file and print result."""
+ output_path = project_root / "test_output" / filename
+ output_path.parent.mkdir(exist_ok=True)
+ output_path.write_text(svg_content, encoding='utf-8')
+ print(f"ā
Generated: {output_path}")
+
+
+def test_basic():
+ """Test basic structure chart."""
+ chart_def = """
+module main "Grade Calculator"
+ module input "Get Input"
+ module calc "Calculate Grade"
+ module output "Display Result"
+
+main -> input
+main -> calc
+main -> output
+"""
+ generator = StructureChartGenerator(chart_def)
+ svg = generator.generate_svg()
+ save_svg(svg, "test_basic.svg")
+ return svg
+
+
+def test_connection_types():
+ """Test different connection types."""
+ chart_def = """
+module main "Process Order"
+ module validate "Validate Order"
+ module check "Check Stock"
+ module process "Process Payment"
+ module ship "Ship Items"
+
+main -> validate data "order details"
+validate -> check control "initiate"
+validate -> process data backward "status"
+check -> ship data "items"
+process -> ship control backward "confirmation"
+"""
+ generator = StructureChartGenerator(chart_def)
+ svg = generator.generate_svg()
+ save_svg(svg, "test_connections.svg")
+ return svg
+
+
+def test_library():
+ """Test library modules."""
+ chart_def = """
+module app "Main App"
+ module userInput "User Input"
+ module processData "Process Data"
+ library logger "Log Message"
+
+app -> userInput
+app -> processData
+userInput -> logger data "user action"
+processData -> logger data "result"
+"""
+ generator = StructureChartGenerator(chart_def)
+ svg = generator.generate_svg()
+ save_svg(svg, "test_library.svg")
+ return svg
+
+
+def test_loop():
+ """Test loop indicator."""
+ chart_def = """
+module main "Process Files"
+ module readFile "Read File"
+ module parseData "Parse Data"
+ module saveData "Save Data"
+
+main -> readFile
+readFile -> parseData
+parseData -> saveData
+
+loop over readFile parseData saveData
+"""
+ generator = StructureChartGenerator(chart_def)
+ svg = generator.generate_svg()
+ save_svg(svg, "test_loop.svg")
+ return svg
+
+
+def test_partial_loop():
+ """Test loop over subset of child modules."""
+ chart_def = """
+module main "Data Processor"
+ module fetch "Fetch Data"
+ module transform "Transform Data"
+ module validate "Validate Data"
+ module save "Save Results"
+
+main -> fetch
+main -> transform data "raw data"
+main -> validate control "check quality"
+main -> save
+
+loop over fetch transform
+"""
+ generator = StructureChartGenerator(chart_def)
+ svg = generator.generate_svg()
+ save_svg(svg, "test_partial_loop.svg")
+ return svg
+
+
+def test_multiple_arrows():
+ """Test multiple data/control flows on same connection."""
+ chart_def = """
+module process "Order Processor"
+ module validate "Validate Order"
+ module execute "Execute Order"
+
+process -> validate data "order data"
+process -> validate data "customer info"
+process -> validate control "validation rules"
+validate -> execute data "validated order"
+validate -> execute control "proceed signal"
+"""
+ generator = StructureChartGenerator(chart_def)
+ svg = generator.generate_svg()
+ save_svg(svg, "test_multiple_arrows.svg")
+ return svg
+
+
+def test_storage():
+ """Test storage elements."""
+ chart_def = """
+module app "Student System"
+ module add "Add Student"
+ module view "View Students"
+storage db "Student Database"
+
+app -> add
+app -> view
+add -> db data "student record"
+view -> db data "query"
+"""
+ generator = StructureChartGenerator(chart_def)
+ svg = generator.generate_svg()
+ save_svg(svg, "test_storage.svg")
+ return svg
+
+
+def test_conditional_gate():
+ """Test conditional gate with multiple branches."""
+ chart_def = """
+module main "User Login"
+ module checkA "Admin Access"
+ module checkB "User Access"
+ module checkC "Guest Access"
+
+conditional main checkA checkB checkC
+"""
+ generator = StructureChartGenerator(chart_def)
+ svg = generator.generate_svg()
+ save_svg(svg, "test_conditional.svg")
+ return svg
+
+
+def test_complete():
+ """Test complete example with all features."""
+ chart_def = """
+# Grade Management System
+module main "Grade Manager"
+ module input "Input Grades"
+ module readFile "Read from File"
+ module keyboard "Keyboard Entry"
+ module process "Process Grades"
+ module validate "Validate Data"
+ module calculate "Calculate Average"
+ module output "Generate Report"
+ library logger "Log Events"
+storage gradeDB "Grade Database"
+
+main -> input
+main -> process
+main -> output
+input -> readFile conditional
+input -> keyboard conditional
+process -> validate control
+validate -> calculate data "valid grades"
+output -> gradeDB data "save report"
+input -> logger data "input event"
+process -> logger data "process event"
+output -> logger data "output event"
+"""
+ generator = StructureChartGenerator(chart_def)
+ svg = generator.generate_svg()
+ save_svg(svg, "test_complete.svg")
+ return svg
+
+
+def create_html_viewer():
+ """Create an HTML file to view all generated SVGs."""
+ html = """
+
+
+
+
+ Structure Chart Test Results
+
+
+
+ Structure Chart Test Results
+ ā
All tests completed successfully!
+
+
+
1. Basic Structure Chart
+
Simple hierarchy with control module and sub-modules.
+
+
+
+
+
+
+
2. Connection Types
+
Different connection types: data flow (empty circle), control flow (filled circle), conditional (diamond).
+
+
+
+
+
+
+
3. Library Module
+
Reusable library module (underlined) called from multiple places.
+
+
+
+
+
+
+
4. Loop Indicator
+
Curved arrow showing repetitive execution over multiple modules.
+
+
+
+
+
+
+
5. Partial Loop (2 of 4 modules)
+
Loop over subset of child modules - demonstrates selective iteration.
+
+
+
+
+
+
+
6. Multiple Arrows (same connection)
+
Multiple data/control flows on the same connection line appear side-by-side.
+
+
+
+
+
+
+
7. Storage Element
+
Physical storage (database) with curved rectangle.
+
+
+
+
+
+
+
8. Conditional Gate
+
Diamond-shaped decision point with multiple output branches.
+
+
+
+
+
+
+
9. Complete Example
+
Comprehensive example showing all features together.
+
+
+
+
+
+
+
Syntax Reference
+
+# Modules
+module id "Label" # Regular module
+library id "Label" # Library module (underlined, reusable)
+storage id "Label" # Physical storage (curved rectangle)
+
+# Connections
+from -> to # Simple connection
+from -> to data "label" # Data flow (empty circle)
+from -> to control # Control flow (filled circle)
+from -> to conditional # Conditional call (diamond)
+
+# Loops
+loop over module1 module2 module3 # Curved arrow over modules
+
+# Layout
+# - Indentation (2 spaces per level) determines hierarchy
+# - Modules at same indentation appear on same level
+
+
+
+
+"""
+
+ output_path = project_root / "test_output" / "index.html"
+ output_path.write_text(html, encoding='utf-8')
+ print(f"ā
Created viewer: {output_path}")
+ print(f"\nš Open in browser: file:///{output_path}")
+
+
+def main():
+ """Run all tests."""
+ print("=" * 60)
+ print("Structure Chart Generator Tests")
+ print("=" * 60)
+ print()
+
+ tests = [
+ ("Basic Structure Chart", test_basic),
+ ("Connection Types", test_connection_types),
+ ("Library Module", test_library),
+ ("Loop Indicator", test_loop),
+ ("Partial Loop (2 of 4 modules)", test_partial_loop),
+ ("Multiple Arrows (same connection)", test_multiple_arrows),
+ ("Storage Element", test_storage),
+ ("Conditional Gate", test_conditional_gate),
+ ("Complete Example", test_complete),
+ ]
+
+ for name, test_func in tests:
+ print(f"\nš Testing: {name}")
+ try:
+ svg = test_func()
+ # Basic validation
+ assert '