diff --git a/gns3server/agent/gns3_copilot/agent/gns3_copilot.py b/gns3server/agent/gns3_copilot/agent/gns3_copilot.py index 148ccac63..822c54b20 100644 --- a/gns3server/agent/gns3_copilot/agent/gns3_copilot.py +++ b/gns3server/agent/gns3_copilot/agent/gns3_copilot.py @@ -77,7 +77,10 @@ from gns3server.agent.gns3_copilot.skills.registry import get_prompt from gns3server.agent.gns3_copilot.tools_v2 import ( ExecuteMultipleDeviceCommands, ExecuteMultipleDeviceConfigCommands, + GNS3CreateEllipseDrawingTool, GNS3CreateNodeTool, + GNS3CreateRectangleDrawingTool, + GNS3CreateTextDrawingTool, GNS3LinkTool, GNS3PacketFilterTool, GNS3StartNodeTool, @@ -103,6 +106,9 @@ TEACHING_ASSISTANT_MODE_TOOLS = [ GNS3TemplateTool(), # Get GNS3 node templates GNS3CreateNodeTool(), # Create new nodes in GNS3 GNS3LinkTool(), # Create links between nodes + GNS3CreateTextDrawingTool(), # Add text labels to the canvas + GNS3CreateRectangleDrawingTool(), # Add rectangle annotations to the canvas + GNS3CreateEllipseDrawingTool(), # Add ellipse annotations to the canvas GNS3StartNodeTool(), # Start GNS3 nodes (returns immediately) GNS3WaitTool(), # Wait for nodes to boot (pair with start_gns3_node) GNS3UpdateNodeNameTool(), # Update node name @@ -118,6 +124,9 @@ LAB_AUTOMATION_ASSISTANT_MODE_TOOLS = [ GNS3TemplateTool(), # Get GNS3 node templates GNS3CreateNodeTool(), # Create new nodes in GNS3 GNS3LinkTool(), # Create links between nodes + GNS3CreateTextDrawingTool(), # Add text labels to the canvas + GNS3CreateRectangleDrawingTool(), # Add rectangle annotations to the canvas + GNS3CreateEllipseDrawingTool(), # Add ellipse annotations to the canvas GNS3StartNodeTool(), # Start GNS3 nodes (returns immediately) GNS3WaitTool(), # Wait for nodes to boot (pair with start_gns3_node) GNS3StopNodeTool(), # Stop GNS3 nodes diff --git a/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py b/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py index ba3fca6c7..44e03688b 100644 --- a/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py +++ b/gns3server/agent/gns3_copilot/gns3_client/api_handlers.py @@ -55,6 +55,7 @@ from typing import Any from gns3server.agent.gns3_copilot.gns3_client.connector import Gns3Connector from gns3server.services import access_ticket_service from gns3server.services.access_tickets import DEFAULT_TICKET_TTL +from gns3server.utils.drawings import build_ellipse_svg, build_rectangle_svg, build_text_svg log = logging.getLogger(__name__) @@ -1076,3 +1077,95 @@ def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) # action == "delete" conn.http_call("delete", url) return {"message": f"Marker definition '{def_name}' deleted", "project_id": project_id, "def_name": def_name} + + +# ── Drawing handlers ────────────────────────────────────────────────────── +# SVG assembly and validation live in gns3server.utils.drawings (Web UI +# compatible format); these handlers only carry params and do the REST call. + + +def create_text_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + """Create a canvas text label; the SVG is assembled from validated params.""" + + project_id = params.get("project_id") + if not project_id: + return {"error": "project_id is required"} + try: + svg, width, height, line_height = build_text_svg( + params.get("text"), + font_size=params.get("font_size", 13), + color=params.get("color", "#000000"), + bold=params.get("bold", False), + font_family=params.get("font_family", "monospace"), + ) + except ValueError as e: + return {"error": str(e)} + conn = _get_connector(gns3_ctx) + data = {"svg": svg, "x": params.get("x", 0), "y": params.get("y", 0), "z": params.get("z", 1)} + result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/drawings", json_data=data).json() + return { + "message": "Text drawing created", + "drawing": result, + "width": width, + "height": height, + "line_height": line_height, + } + + +def create_rectangle_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + """Create a canvas rectangle; the SVG is assembled from validated params.""" + + project_id = params.get("project_id") + if not project_id: + return {"error": "project_id is required"} + width = params.get("width") + height = params.get("height") + if width is None or height is None: + return {"error": "width and height are required"} + try: + svg, width, height = build_rectangle_svg( + width, + height, + fill=params.get("fill", "#FFFFFF"), + fill_opacity=params.get("fill_opacity", 1.0), + stroke=params.get("stroke"), + stroke_width=params.get("stroke_width", 1), + dashed=params.get("dashed", False), + dasharray=params.get("dasharray"), + rx=params.get("rx", 0), + ) + except ValueError as e: + return {"error": str(e)} + conn = _get_connector(gns3_ctx) + data = {"svg": svg, "x": params.get("x", 0), "y": params.get("y", 0), "z": params.get("z", 0)} + result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/drawings", json_data=data).json() + return {"message": "Rectangle drawing created", "drawing": result, "width": width, "height": height} + + +def create_ellipse_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + """Create a canvas ellipse from a bounding box; the SVG is assembled from validated params.""" + + project_id = params.get("project_id") + if not project_id: + return {"error": "project_id is required"} + width = params.get("width") + height = params.get("height") + if width is None or height is None: + return {"error": "width and height are required"} + try: + svg, width, height = build_ellipse_svg( + width, + height, + fill=params.get("fill", "#FFFFFF"), + fill_opacity=params.get("fill_opacity", 1.0), + stroke=params.get("stroke"), + stroke_width=params.get("stroke_width", 1), + dashed=params.get("dashed", False), + dasharray=params.get("dasharray"), + ) + except ValueError as e: + return {"error": str(e)} + conn = _get_connector(gns3_ctx) + data = {"svg": svg, "x": params.get("x", 0), "y": params.get("y", 0), "z": params.get("z", 0)} + result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/drawings", json_data=data).json() + return {"message": "Ellipse drawing created", "drawing": result, "width": width, "height": height} diff --git a/gns3server/agent/gns3_copilot/tools_v2/__init__.py b/gns3server/agent/gns3_copilot/tools_v2/__init__.py index 0818bd81c..078105ada 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/__init__.py +++ b/gns3server/agent/gns3_copilot/tools_v2/__init__.py @@ -40,6 +40,7 @@ Main modules: - vpcs_tools_netmiko: VPCS device configuration tool using Netmiko - gns3_create_node: GNS3 node creation tool - gns3_create_link: GNS3 link creation tool +- gns3_create_drawing: GNS3 canvas drawing tools (text, rectangle, ellipse) - gns3_start_node: GNS3 node startup tool (immediate return, no boot wait) - gns3_wait: Wait timer tool (pair with node start to let devices boot) - gns3_get_node_temp: GNS3 template retrieval tool @@ -54,6 +55,11 @@ Author: Yue Guobin (岳国宾) # Import main tool classes from .config_tools_nornir import ExecuteMultipleDeviceConfigCommands from .display_tools_nornir import ExecuteMultipleDeviceCommands +from .gns3_create_drawing import ( + GNS3CreateEllipseDrawingTool, + GNS3CreateRectangleDrawingTool, + GNS3CreateTextDrawingTool, +) from .gns3_create_link import GNS3LinkTool from .gns3_create_node import GNS3CreateNodeTool from .gns3_get_node_temp import GNS3TemplateTool @@ -81,7 +87,10 @@ __url__ = "https://github.com/yueguobin/gns3-copilot" __all__ = [ "ExecuteMultipleDeviceCommands", "ExecuteMultipleDeviceConfigCommands", + "GNS3CreateEllipseDrawingTool", "GNS3CreateNodeTool", + "GNS3CreateRectangleDrawingTool", + "GNS3CreateTextDrawingTool", "GNS3LinkTool", "GNS3PacketFilterTool", "GNS3StartNodeQuickTool", diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_create_drawing.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_drawing.py new file mode 100644 index 000000000..7f3f688fe --- /dev/null +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_create_drawing.py @@ -0,0 +1,162 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# +# GNS3-Copilot - AI-powered Network Lab Assistant for GNS3 +# +# This file is part of GNS3-Copilot project. +# +# GNS3-Copilot is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation, either version 3 of the License, or (at your +# option) any later version. +# +# GNS3-Copilot is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY +# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License +# for more details. +# +# You should have received a copy of the GNU General Public License +# along with GNS3-Copilot. If not, see . +# +# Copyright (C) 2025 Yue Guobin (岳国宾) +# Author: Yue Guobin (岳国宾) +# +# Project Home: https://github.com/yueguobin/gns3-copilot +# + +""" +GNS3 canvas drawing tools for text, rectangle and ellipse annotations. + +Thin LangChain wrappers over the shared REST handlers in +``gns3_client.api_handlers``; the Web UI compatible SVG is assembled and +validated by ``gns3server.utils.drawings`` (single implementation shared +with the MCP drawing tools). +""" + +import json +import logging +from typing import Any + +from langchain.tools import BaseTool +from langchain_core.callbacks import CallbackManagerForToolRun + +from gns3server.agent.gns3_copilot.gns3_client.api_handlers import ( + build_gns3_ctx, + create_ellipse_drawing_handler, + create_rectangle_drawing_handler, + create_text_drawing_handler, +) + +# Configure logging +logger = logging.getLogger(__name__) + + +class _GNS3CreateDrawingToolBase(BaseTool): + """Shared plumbing: parse JSON input, build ctx, dispatch to a handler.""" + + def _dispatch(self, input_data: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + raise NotImplementedError + + def _run( + self, + tool_input: str, + run_manager: CallbackManagerForToolRun | None = None, + ) -> dict[str, Any]: + try: + input_data = json.loads(tool_input) + + gns3_ctx = build_gns3_ctx() + if gns3_ctx is None: + logger.error("Failed to create GNS3 connector") + return {"error": "Failed to connect to GNS3 server. Please check your configuration."} + + return self._dispatch(input_data, gns3_ctx) + except json.JSONDecodeError as e: + logger.error("Invalid JSON input: %s", e) + return {"error": f"Invalid JSON input: {e}"} + except Exception as e: + logger.exception("Drawing creation failed") + return {"error": str(e)} + + +class GNS3CreateTextDrawingTool(_GNS3CreateDrawingToolBase): + """ + A LangChain tool to add a text label to the GNS3 project canvas. + + **Input**: + A JSON object with project_id, x, y (top-left corner on the canvas) + and text; optional font_size (1-200, default 13), color (#RRGGBB, + default #000000), bold (default false), font_family (default + monospace) and z (default 1, keeps text above links). + Newlines in text start additional lines; XML characters are escaped + automatically; the computed width/height are returned. + + **Output**: + A dictionary with the created drawing, its drawing_id and the + computed width/height/line_height, or an "error" key on failure. + """ + + name: str = "create_gns3_text_drawing" + description: str = """ + Adds a text label to the GNS3 project canvas. + Input: JSON with project_id, x, y (top-left corner), text and optional + font_size, color, bold, font_family, z. + Returns: the created drawing with its computed width and height. + """ + + def _dispatch(self, input_data: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + return create_text_drawing_handler(input_data, gns3_ctx) + + +class GNS3CreateRectangleDrawingTool(_GNS3CreateDrawingToolBase): + """ + A LangChain tool to add a rectangle to the GNS3 project canvas. + + **Input**: + A JSON object with project_id, x, y (top-left corner), width and + height (1-2000); optional fill (#RRGGBB or "none", default #FFFFFF), + fill_opacity (0-1, default 1.0), stroke (#RRGGBB, no border by + default), stroke_width (1-20), dashed (bool), dasharray (like "4,2", + requires stroke) and rx (corner radius, default 0). z defaults to 0 + so shapes stay behind nodes and text. + + **Output**: + A dictionary with the created drawing, or an "error" key on failure. + """ + + name: str = "create_gns3_rectangle_drawing" + description: str = """ + Adds a rectangle to the GNS3 project canvas. + Input: JSON with project_id, x, y, width, height and optional fill, + fill_opacity, stroke, stroke_width, dashed, dasharray, rx, z. + Returns: the created drawing with its width and height. + """ + + def _dispatch(self, input_data: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + return create_rectangle_drawing_handler(input_data, gns3_ctx) + + +class GNS3CreateEllipseDrawingTool(_GNS3CreateDrawingToolBase): + """ + A LangChain tool to add an ellipse (or circle) to the GNS3 canvas. + + **Input**: + A JSON object with project_id, x, y (bounding box top-left corner), + width and height (2-2000, equal values give a circle); the same + optional style fields as the rectangle tool. cx/cy are derived from + the bounding box; for odd width/height the drawing is 1 px smaller + than requested and the actual size is returned. + + **Output**: + A dictionary with the created drawing, or an "error" key on failure. + """ + + name: str = "create_gns3_ellipse_drawing" + description: str = """ + Adds an ellipse or circle to the GNS3 project canvas. + Input: JSON with project_id, x, y, width, height (bounding box) and + optional fill, fill_opacity, stroke, stroke_width, dashed, dasharray, z. + Returns: the created drawing with its actual width and height. + """ + + def _dispatch(self, input_data: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: + return create_ellipse_drawing_handler(input_data, gns3_ctx) diff --git a/gns3server/agent/gns3_copilot/utils/gns3_drawing_utils.py b/gns3server/agent/gns3_copilot/utils/gns3_drawing_utils.py index 50456cbf5..540e401ea 100644 --- a/gns3server/agent/gns3_copilot/utils/gns3_drawing_utils.py +++ b/gns3server/agent/gns3_copilot/utils/gns3_drawing_utils.py @@ -33,6 +33,8 @@ Supports ellipse and rectangle shapes for two-node annotations. import math from typing import Any, Literal +from gns3server.utils.drawings import build_ellipse_svg, build_rectangle_svg, build_text_svg + # Default parameters DEFAULT_DEVICE_WIDTH = 50 DEFAULT_DEVICE_HEIGHT = 50 @@ -144,10 +146,16 @@ def calculate_two_node_shape( center_y = (node1_center_y + node2_center_y) / 2 color_scheme = _get_color_scheme(area_name) - text_svg = generate_text_svg(area_name, color_scheme) - text_svg_width = len(area_name) * 8 + 20 - text_svg_height = DEFAULT_FONT_SIZE + 16 + # Shared builder: Web UI compatible SVG plus the computed label size + # (the old len * 8 + 20 estimate ignored the Web UI's pt-based rendering). + text_svg, text_svg_width, text_svg_height, _ = build_text_svg( + area_name, + font_size=DEFAULT_FONT_SIZE, + color=color_scheme["stroke"], + bold=True, + font_family="TypeWriter", + ) if shape_type == "ellipse": rx = distance / 2 @@ -236,8 +244,11 @@ def generate_ellipse_svg( svg_width: int, svg_height: int, ) -> str: - """Generate SVG for ellipse.""" - return f"""""" + """Generate SVG for ellipse (delegates to the shared Web UI compatible builder).""" + svg, _, _ = build_ellipse_svg( + rx * 2, ry * 2, fill=color_scheme["fill"], fill_opacity=color_scheme["fill_opacity"] + ) + return svg def generate_rectangle_svg( @@ -245,16 +256,23 @@ def generate_rectangle_svg( height: int, color_scheme: dict[str, Any], ) -> str: - """Generate SVG for rectangle.""" - return f"""""" + """Generate SVG for rectangle (delegates to the shared Web UI compatible builder).""" + svg, _, _ = build_rectangle_svg( + width, height, fill=color_scheme["fill"], fill_opacity=color_scheme["fill_opacity"] + ) + return svg def generate_text_svg(text: str, color_scheme: dict[str, Any]) -> str: - """Generate SVG for text label.""" - text_width = len(text) * 8 + 20 - text_height = DEFAULT_FONT_SIZE + 16 - - return f"""{text}""" + """Generate SVG for text label (delegates to the shared Web UI compatible builder).""" + svg, _, _, _ = build_text_svg( + text, + font_size=DEFAULT_FONT_SIZE, + color=color_scheme["stroke"], + bold=True, + font_family="TypeWriter", + ) + return svg def _hsv_to_hex(h: int, s: int, v: int) -> str: diff --git a/gns3server/agent/mcp/__init__.py b/gns3server/agent/mcp/__init__.py index 05d8603c7..96de2b828 100644 --- a/gns3server/agent/mcp/__init__.py +++ b/gns3server/agent/mcp/__init__.py @@ -45,8 +45,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from gns3server.agent.gns3_copilot.gns3_client.api_handlers import ( available_filters_handler, + create_ellipse_drawing_handler, create_link_handler, create_node_handler, + create_rectangle_drawing_handler, + create_text_drawing_handler, delete_link_handler, delete_node_file_handler, delete_node_handler, @@ -110,7 +113,6 @@ from .device_config import ( ) from .drawings import ( create_drawing_handler, - create_text_drawing_handler, delete_drawing_handler, get_drawing_handler, get_drawings_handler, @@ -1627,6 +1629,103 @@ async def drawing_create_text( ) +@mcp.tool() +async def drawing_create_rectangle( + project_id: Annotated[str, Field(description="Project name or UUID")], + x: Annotated[int, Field(description="X coordinate of the rectangle top-left corner on the canvas")], + y: Annotated[int, Field(description="Y coordinate of the rectangle top-left corner on the canvas")], + width: Annotated[int, Field(description="Rectangle width in px, 1-2000")], + height: Annotated[int, Field(description="Rectangle height in px, 1-2000")], + fill: Annotated[str, Field(description='Fill color, hex like #RRGGBB or "none" (default: #FFFFFF)')] = "#FFFFFF", + fill_opacity: Annotated[float, Field(description="Fill opacity, 0-1 (default: 1.0)")] = 1.0, + stroke: Annotated[str | None, Field(description="Border color, hex like #RRGGBB; omit for no border")] = None, + stroke_width: Annotated[int, Field(description="Border width, 1-20 (default: 1)")] = 1, + dashed: Annotated[bool, Field(description="Dashed border, uses the default 10,6 pattern (default: false)")] = False, + dasharray: Annotated[str | None, Field(description='Custom dash pattern like "4,2"; requires stroke')] = None, + rx: Annotated[int, Field(description="Corner radius, 0 to min(width, height)/2 (default: 0)")] = 0, + z: Annotated[int, Field(description="Z layer; 0 keeps shapes behind nodes and text (default: 0)")] = 0, +) -> list[dict[str, Any]]: + """Add a rectangle to a project canvas. + + The SVG is assembled server-side in the Web UI compatible format; prefer this + over hand-writing SVG for drawing_create. + + - fill "none" renders an outline-only box (stroke recommended then). + - Border attributes are only emitted when stroke is given. + - dasharray must be plain "on,off" numbers; the Qt-remapped patterns + ("25, 25", "5, 25", ...) are rejected because the Web UI rewrites them. + + Example: semi-transparent outline box at (200, 150): + drawing_create_rectangle(project_id="demo", x=200, y=150, width=400, height=200, + fill="none", stroke="#333333", dashed=True) + """ + return await asyncio.to_thread( + _run_handler_sync, + create_rectangle_drawing_handler, + { + "project_id": project_id, + "x": x, + "y": y, + "z": z, + "width": width, + "height": height, + "fill": fill, + "fill_opacity": fill_opacity, + "stroke": stroke, + "stroke_width": stroke_width, + "dashed": dashed, + "dasharray": dasharray, + "rx": rx, + }, + ) + + +@mcp.tool() +async def drawing_create_ellipse( + project_id: Annotated[str, Field(description="Project name or UUID")], + x: Annotated[int, Field(description="X coordinate of the bounding box top-left corner on the canvas")], + y: Annotated[int, Field(description="Y coordinate of the bounding box top-left corner on the canvas")], + width: Annotated[int, Field(description="Bounding box width in px, 2-2000; equal to height gives a circle")], + height: Annotated[int, Field(description="Bounding box height in px, 2-2000")], + fill: Annotated[str, Field(description='Fill color, hex like #RRGGBB or "none" (default: #FFFFFF)')] = "#FFFFFF", + fill_opacity: Annotated[float, Field(description="Fill opacity, 0-1 (default: 1.0)")] = 1.0, + stroke: Annotated[str | None, Field(description="Border color, hex like #RRGGBB; omit for no border")] = None, + stroke_width: Annotated[int, Field(description="Border width, 1-20 (default: 1)")] = 1, + dashed: Annotated[bool, Field(description="Dashed border, uses the default 10,6 pattern (default: false)")] = False, + dasharray: Annotated[str | None, Field(description='Custom dash pattern like "4,2"; requires stroke')] = None, + z: Annotated[int, Field(description="Z layer; 0 keeps shapes behind nodes and text (default: 0)")] = 0, +) -> list[dict[str, Any]]: + """Add an ellipse (or circle) to a project canvas. + + The SVG is assembled server-side in the Web UI compatible format. cx/cy are + derived from the bounding box (the Web UI treats them as offsets inside the + drawing, not canvas coordinates); for odd width/height the drawing is 1 px + smaller than requested and the actual size is returned. + + Example: circle of diameter 160 at (300, 300): + drawing_create_ellipse(project_id="demo", x=300, y=300, width=160, height=160, + fill="#5AA9DD", fill_opacity=0.8) + """ + return await asyncio.to_thread( + _run_handler_sync, + create_ellipse_drawing_handler, + { + "project_id": project_id, + "x": x, + "y": y, + "z": z, + "width": width, + "height": height, + "fill": fill, + "fill_opacity": fill_opacity, + "stroke": stroke, + "stroke_width": stroke_width, + "dashed": dashed, + "dasharray": dasharray, + }, + ) + + @mcp.tool() async def drawing_get( project_id: Annotated[str, Field(description="Project name or UUID")], diff --git a/gns3server/agent/mcp/drawings.py b/gns3server/agent/mcp/drawings.py index 4cd88c4fe..7ca6daeda 100644 --- a/gns3server/agent/mcp/drawings.py +++ b/gns3server/agent/mcp/drawings.py @@ -20,11 +20,7 @@ MCP tool handlers for GNS3 drawing management. """ import logging -import re -from math import ceil from typing import Any -from xml.etree import ElementTree as ET -from xml.sax.saxutils import escape log = logging.getLogger(__name__) @@ -73,101 +69,6 @@ def create_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> return {"message": "Drawing created", "drawing": result} -# ── Text drawing ─────────────────────────────────────────────────────────── - -# The Web UI renders font-size as pt (1pt = 4/3 px) and monospace glyphs advance -# ~0.6em, so one character is ~0.8 x font_size in canvas pixels. Proportional -# fonts get a wider worst-case factor so the selection box does not clip the text. -_MONOSPACE_CHAR_FACTOR = 0.8 -_PROPORTIONAL_CHAR_FACTOR = 1.0 -# The Web UI stacks lines at dy=1.4em, i.e. 1.4 x 4/3 ~ 1.87 px per pt of font size. -_LINE_HEIGHT_FACTOR = 1.9 -_MAX_TEXT_LENGTH = 500 - -_COLOR_RE = re.compile(r"^#(?:[0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$") -_FONT_FAMILY_RE = re.compile(r"^[A-Za-z0-9 ,.'-]+$") - - -def build_text_svg( - text: str, font_size: int = 13, color: str = "#000000", bold: bool = False, font_family: str = "monospace" -) -> tuple[str, int, int, int]: - """ - Build a Web UI compatible single SVG for a canvas label. - - The output mirrors what the Web UI itself serializes for text drawings: - root with one child and no x/y (the Web UI parser - ignores them and positions the text itself). - - :returns: (svg, width, height, line_height) with the computed drawing size - """ - - lines = text.split("\n") - factor = _MONOSPACE_CHAR_FACTOR if "monospace" in font_family.lower() else _PROPORTIONAL_CHAR_FACTOR - width = max(1, ceil(max(len(line) for line in lines) * font_size * factor)) - line_height = ceil(font_size * _LINE_HEIGHT_FACTOR) - height = len(lines) * line_height - weight = "bold" if bold else "normal" - svg = ( - f'' - f'{escape(text)}' - ) - # Fail loudly here: the controller silently keeps the previous (empty) SVG - # when handed an unparseable string of 500+ characters. - ET.fromstring(svg) - return svg, width, height, line_height - - -def create_text_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: - project_id = params.get("project_id") - text = params.get("text") - if not project_id: - return {"error": "project_id is required"} - if not text: - return {"error": "text is required"} - text = text.replace("\r\n", "\n").replace("\r", "\n").strip("\n") - if not text: - return {"error": "text is required"} - if len(text) > _MAX_TEXT_LENGTH: - return {"error": f"text is too long (max {_MAX_TEXT_LENGTH} characters)"} - if any(ord(ch) < 32 and ch != "\n" for ch in text) or "\x7f" in text: - return {"error": "text contains control characters (only newlines are allowed)"} - - font_size = params.get("font_size", 13) - if isinstance(font_size, bool) or not isinstance(font_size, int) or not 1 <= font_size <= 200: - return {"error": "font_size must be an integer between 1 and 200"} - color = params.get("color", "#000000") - if not _COLOR_RE.match(color): - return {"error": "color must be a hex value like #RRGGBB"} - font_family = params.get("font_family", "monospace") - if not _FONT_FAMILY_RE.match(font_family): - return {"error": "font_family contains invalid characters (allowed: letters, digits, spaces, . ' -)"} - - try: - svg, width, height, line_height = build_text_svg( - text, font_size=font_size, color=color, bold=params.get("bold", False), font_family=font_family - ) - except ET.ParseError as e: - return {"error": f"generated SVG is not valid XML: {e}"} - - conn = _get_connector(gns3_ctx) - data = { - "svg": svg, - "x": params.get("x", 0), - "y": params.get("y", 0), - "z": params.get("z", 1), - } - result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/drawings", json_data=data).json() - return { - "message": "Text drawing created", - "drawing": result, - "width": width, - "height": height, - "line_height": line_height, - } - - def get_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]: project_id = params.get("project_id") drawing_id = params.get("drawing_id") diff --git a/gns3server/utils/drawings.py b/gns3server/utils/drawings.py new file mode 100644 index 000000000..6c8c48a0d --- /dev/null +++ b/gns3server/utils/drawings.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +Web UI compatible drawing SVG assembly. + +The GNS3 Web UI does not render drawing SVG as-is: it re-parses it into its own +element model with ONE recognized child element per drawing (text, image, rect, +line, ellipse or path) and reads the root width/height for the selection box. +These helpers emit exactly that format, mirroring the attribute sets the Web UI +itself serializes, and validate inputs so callers fail loudly instead of hitting +the controller's silent drop of unparseable SVG strings of 500+ characters. + +Shared by the MCP tools (agent/mcp) and the GNS3 copilot tools (tools_v2). +""" + +import re +from math import ceil +from typing import Any +from xml.etree import ElementTree as ET +from xml.sax.saxutils import escape + +# The Web UI renders font-size as pt (1pt = 4/3 px) and monospace glyphs advance +# ~0.6em, so one character is ~0.8 x font_size in canvas pixels. Proportional +# fonts get a wider worst-case factor so the selection box does not clip the text. +_MONOSPACE_CHAR_FACTOR = 0.8 +_PROPORTIONAL_CHAR_FACTOR = 1.0 +# The Web UI stacks lines at dy=1.4em, i.e. 1.4 x 4/3 ~ 1.87 px per pt of font size. +_LINE_HEIGHT_FACTOR = 1.9 +_MAX_TEXT_LENGTH = 500 + +_COLOR_RE = re.compile(r"^#(?:[0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$") +_FONT_FAMILY_RE = re.compile(r"^[A-Za-z0-9 ,.'-]+$") +_DASHARRAY_RE = re.compile(r"^[0-9][0-9, ]*$") +# The Web UI remaps these Qt-style dash patterns to different values +# (QtDasharrayFixer); reject them so what you send is what renders. +_QT_REMAPPED_DASHARRAYS = {"25, 25", "5, 25", "5, 25, 25", "25, 25, 5, 25, 5"} +_DEFAULT_DASHARRAY = "10,6" + + +def _checked_text(text: Any) -> str: + if text is None: + raise ValueError("text is required") + if not isinstance(text, str): + raise ValueError("text must be a string") + text = text.replace("\r\n", "\n").replace("\r", "\n").strip("\n") + if not text: + raise ValueError("text is required") + if len(text) > _MAX_TEXT_LENGTH: + raise ValueError(f"text is too long (max {_MAX_TEXT_LENGTH} characters)") + if any(ord(ch) < 32 and ch != "\n" for ch in text) or "\x7f" in text: + raise ValueError("text contains control characters (only newlines are allowed)") + return text + + +def _checked_font_size(font_size: Any) -> int: + if isinstance(font_size, bool) or not isinstance(font_size, int) or not 1 <= font_size <= 200: + raise ValueError("font_size must be an integer between 1 and 200") + return font_size + + +def _checked_font_family(font_family: Any) -> str: + if not isinstance(font_family, str) or not _FONT_FAMILY_RE.match(font_family): + raise ValueError("font_family contains invalid characters (allowed: letters, digits, spaces, . ' -)") + return font_family + + +def _checked_color(value: Any, name: str) -> str: + if not isinstance(value, str) or not _COLOR_RE.match(value): + raise ValueError(f"{name} must be a hex value like #RRGGBB") + return value + + +def _checked_dimension(value: Any, name: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 2000: + raise ValueError(f"{name} must be an integer between 1 and 2000") + return value + + +def _checked_radius(value: Any, max_radius: int) -> int: + if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= max_radius: + raise ValueError(f"rx must be an integer between 0 and {max_radius}") + return value + + +def _checked_style( + fill: Any, fill_opacity: Any, stroke: Any, stroke_width: Any, dashed: Any, dasharray: Any +) -> tuple[str, Any, str | None, int, str | None]: + """ + Validate the style block shared by rectangle and ellipse drawings. + + :returns: (fill, fill_opacity, stroke, stroke_width, dasharray-or-None) + """ + + if fill != "none" and (not isinstance(fill, str) or not _COLOR_RE.match(fill)): + raise ValueError('fill must be a hex value like #RRGGBB or "none"') + if isinstance(fill_opacity, bool) or not isinstance(fill_opacity, (int, float)) or not 0 <= fill_opacity <= 1: + raise ValueError("fill_opacity must be a number between 0 and 1") + + dash = dasharray if dasharray is not None else (_DEFAULT_DASHARRAY if dashed else None) + if dash is not None: + if not isinstance(dash, str) or not _DASHARRAY_RE.match(dash) or dash in _QT_REMAPPED_DASHARRAYS: + raise ValueError( + 'dasharray must be plain "on,off" number pairs and must not be one of ' + f"the Qt-remapped patterns {sorted(_QT_REMAPPED_DASHARRAYS)}" + ) + if stroke is None: + raise ValueError("stroke is required when dasharray is set") + + if stroke is not None: + stroke = _checked_color(stroke, "stroke") + if isinstance(stroke_width, bool) or not isinstance(stroke_width, int) or not 1 <= stroke_width <= 20: + raise ValueError("stroke_width must be an integer between 1 and 20") + + return fill, fill_opacity, stroke, stroke_width, dash + + +def _style_attrs(fill: str, fill_opacity: Any, stroke: str | None, stroke_width: int, dash: str | None) -> str: + """Render the validated style block as SVG attributes (Web UI attribute order).""" + + attrs = f'fill="{fill}" fill-opacity="{fill_opacity}"' + if stroke is not None: + attrs += f' stroke="{stroke}" stroke-width="{stroke_width}"' + if dash is not None: + attrs += f' stroke-dasharray="{dash}"' + return attrs + + +def build_text_svg( + text: Any, font_size: int = 13, color: str = "#000000", bold: bool = False, font_family: str = "monospace" +) -> tuple[str, int, int, int]: + """ + Build a Web UI compatible single SVG for a canvas label. + + The output mirrors what the Web UI itself serializes for text drawings: + root with one child and no x/y (the Web UI parser + ignores them and positions the text itself). + + :returns: (svg, width, height, line_height) with the computed drawing size + """ + + text = _checked_text(text) + font_size = _checked_font_size(font_size) + color = _checked_color(color, "color") + font_family = _checked_font_family(font_family) + + lines = text.split("\n") + factor = _MONOSPACE_CHAR_FACTOR if "monospace" in font_family.lower() else _PROPORTIONAL_CHAR_FACTOR + width = max(1, ceil(max(len(line) for line in lines) * font_size * factor)) + line_height = ceil(font_size * _LINE_HEIGHT_FACTOR) + height = len(lines) * line_height + weight = "bold" if bold else "normal" + svg = ( + f'' + f'{escape(text)}' + ) + ET.fromstring(svg) + return svg, width, height, line_height + + +def build_rectangle_svg( + width: int, + height: int, + fill: str = "#FFFFFF", + fill_opacity: float = 1.0, + stroke: str | None = None, + stroke_width: int = 1, + dashed: bool = False, + dasharray: str | None = None, + rx: int = 0, +) -> tuple[str, int, int]: + """ + Build a Web UI compatible single SVG. + + The rect sits at the drawing origin (the Web UI ignores rect x/y), so the + caller positions the drawing itself and the root size equals the rect size. + + :returns: (svg, width, height) + """ + + width = _checked_dimension(width, "width") + height = _checked_dimension(height, "height") + rx = _checked_radius(rx, min(width, height) // 2) + fill, fill_opacity, stroke, stroke_width, dash = _checked_style( + fill, fill_opacity, stroke, stroke_width, dashed, dasharray + ) + + svg = ( + f'' + f'' + ) + ET.fromstring(svg) + return svg, width, height + + +def build_ellipse_svg( + width: int, + height: int, + fill: str = "#FFFFFF", + fill_opacity: float = 1.0, + stroke: str | None = None, + stroke_width: int = 1, + dashed: bool = False, + dasharray: str | None = None, +) -> tuple[str, int, int]: + """ + Build a Web UI compatible single SVG from a bounding box. + + The radii and center are derived (rx = width // 2, cx = rx) because the Web UI + treats cx/cy as offsets inside the drawing box, not canvas coordinates. For + odd width/height the drawing is 1 px smaller than requested. + + :returns: (svg, width, height) with the actual (possibly rounded-down) size + """ + + width = _checked_dimension(width, "width") + height = _checked_dimension(height, "height") + rx, ry = width // 2, height // 2 + if rx < 1 or ry < 1: + raise ValueError("width and height must be at least 2") + fill, fill_opacity, stroke, stroke_width, dash = _checked_style( + fill, fill_opacity, stroke, stroke_width, dashed, dasharray + ) + + svg = ( + f'' + f'' + ) + ET.fromstring(svg) + return svg, 2 * rx, 2 * ry diff --git a/pyproject.toml b/pyproject.toml index 9defd027d..44df93d8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -165,8 +165,6 @@ extend-immutable-calls = [ "gns3server/agent/gns3_copilot/utils/parse_tool_content.py" = ["C901", "DTZ003"] "gns3server/agent/mcp/__init__.py" = ["RUF003"] "gns3server/agent/mcp/device_config.py" = ["E741"] -# Parses self-generated SVG (validated inputs) like controller/drawing.py does. -"gns3server/agent/mcp/drawings.py" = ["S314"] "gns3server/agent/web_wireshark/manager.py" = ["C901"] "gns3server/agent/web_wireshark/setup_wireshark_image.py" = ["PLW1510", "S603", "S607"] "gns3server/agent/web_wireshark/stats.py" = ["ASYNC221", "PLW1510", "S603", "S607"] @@ -213,6 +211,8 @@ extend-immutable-calls = [ "gns3server/db/tasks.py" = ["RUF006", "RUF019"] "gns3server/db_migrations/versions/ec4b7b198555_convert_pickletype_fields_to_json.py" = ["S301", "S608"] "gns3server/logger.py" = ["PLW0127"] +# Parses self-generated SVG (validated inputs) like controller/drawing.py does. +"gns3server/utils/drawings.py" = ["S314"] "gns3server/schemas/controller/settings.py" = ["S105"] "gns3server/schemas/controller/templates/docker_templates.py" = ["W292"] "gns3server/schemas/controller/tokens.py" = ["S105"] @@ -236,8 +236,9 @@ extend-immutable-calls = [ "scripts/extract_mermaid.py" = ["C901", "PLW1510", "S603", "S607"] "scripts/tag.py" = ["S603", "S607"] "scripts/welcome.py" = ["C901", "E722", "PLW1510", "S108", "S112", "S603", "S605", "S606", "S607"] -"tests/agent/mcp/test_drawings.py" = ["S314"] +"tests/agent/mcp/test_drawing_handlers.py" = ["S314"] "tests/agent/mcp/test_handlers.py" = ["B905"] +"tests/utils/test_drawings.py" = ["S314"] "tests/agent/mcp/test_tool_params.py" = ["C901"] "tests/api/test_openapi_spec.py" = ["S603"] "tests/api/routes/controller/test_images.py" = ["S324", "W292"] diff --git a/tests/agent/mcp/test_drawing_handlers.py b/tests/agent/mcp/test_drawing_handlers.py new file mode 100644 index 000000000..5a4498830 --- /dev/null +++ b/tests/agent/mcp/test_drawing_handlers.py @@ -0,0 +1,153 @@ +""" +MCP drawing handler tests: text/rectangle/ellipse creation via the shared +api_handlers layer (SVG building units live in tests/utils/test_drawings.py). +""" + +from unittest.mock import MagicMock, patch +from xml.etree import ElementTree as ET + +import pytest + +from gns3server.agent.gns3_copilot.gns3_client.api_handlers import ( + create_ellipse_drawing_handler, + create_rectangle_drawing_handler, + create_text_drawing_handler, +) + +AH = "gns3server.agent.gns3_copilot.gns3_client.api_handlers" + + +def _mock_conn(json_result=None): + """Create a mocked Gns3Connector with base_url and http_call.""" + conn = MagicMock() + conn.base_url = "http://192.168.1.3:3080/v3" + conn.http_call.return_value.json.return_value = json_result or {"status": "ok"} + return conn + + +@pytest.fixture +def ctx(): + return {"server_url": "http://192.168.1.3:3080", "jwt_token": "token", "jwt_username": "admin"} + + +class TestCreateTextDrawingHandler: + def test_success(self, ctx): + with patch(f"{AH}._get_connector") as m: + conn = _mock_conn({"drawing_id": "d1", "x": 100, "y": 80}) + m.return_value = conn + result = create_text_drawing_handler( + {"project_id": "p1", "text": "R1", "x": 100, "y": 80, "bold": True}, ctx + ) + assert result["drawing"]["drawing_id"] == "d1" + assert result["width"] == 21 + assert result["height"] == 25 + # z defaults to 1 (above links) + body = conn.http_call.call_args.kwargs["json_data"] + assert body["z"] == 1 + assert body["x"] == 100 + assert 'font-weight="bold"' in body["svg"] + + def test_missing_project_id(self, ctx): + assert "error" in create_text_drawing_handler({"text": "R1"}, ctx) + + def test_missing_text(self, ctx): + assert "error" in create_text_drawing_handler({"project_id": "p1"}, ctx) + + def test_blank_text(self, ctx): + assert "error" in create_text_drawing_handler({"project_id": "p1", "text": "\n\n"}, ctx) + + def test_newline_normalization(self, ctx): + with patch(f"{AH}._get_connector") as m: + conn = _mock_conn({"drawing_id": "d1"}) + m.return_value = conn + create_text_drawing_handler({"project_id": "p1", "text": "a\r\nb"}, ctx) + svg = conn.http_call.call_args.kwargs["json_data"]["svg"] + assert "\r" not in svg + + @pytest.mark.parametrize("font_size", [0, 201, "13", 13.5, True]) + def test_bad_font_size(self, ctx, font_size): + result = create_text_drawing_handler({"project_id": "p1", "text": "R1", "font_size": font_size}, ctx) + assert "error" in result + + @pytest.mark.parametrize("color", ["red", "123456", "#GGGGGG", "#12345", 'black" onload="x']) + def test_bad_color(self, ctx, color): + result = create_text_drawing_handler({"project_id": "p1", "text": "R1", "color": color}, ctx) + assert "error" in result + + @pytest.mark.parametrize("font_family", ['mono" onload="x', "sans", "a&b", ""]) + def test_bad_font_family(self, ctx, font_family): + result = create_text_drawing_handler({"project_id": "p1", "text": "R1", "font_family": font_family}, ctx) + assert "error" in result + + def test_xml_entities_survive_round_trip(self, ctx): + with patch(f"{AH}._get_connector") as m: + conn = _mock_conn({"drawing_id": "d1"}) + m.return_value = conn + create_text_drawing_handler({"project_id": "p1", "text": "R1 & "}, ctx) + svg = conn.http_call.call_args.kwargs["json_data"]["svg"] + assert "R1 & <backup>" in svg + ET.fromstring(svg) + + +class TestCreateRectangleDrawingHandler: + def test_success(self, ctx): + with patch(f"{AH}._get_connector") as m: + conn = _mock_conn({"drawing_id": "d2"}) + m.return_value = conn + result = create_rectangle_drawing_handler( + {"project_id": "p1", "x": 200, "y": 150, "width": 80, "height": 50, "fill": "#4A90D9"}, ctx + ) + assert result["drawing"]["drawing_id"] == "d2" + assert result["width"] == 80 + assert result["height"] == 50 + body = conn.http_call.call_args.kwargs["json_data"] + # z defaults to 0 (shapes behind text and nodes) + assert body["z"] == 0 + assert body["svg"] == ( + '' + '' + ) + + def test_missing_dimensions(self, ctx): + assert "error" in create_rectangle_drawing_handler({"project_id": "p1"}, ctx) + + def test_bad_fill_returns_error(self, ctx): + result = create_rectangle_drawing_handler({"project_id": "p1", "width": 80, "height": 50, "fill": "red"}, ctx) + assert "error" in result + + def test_qt_remapped_dasharray_rejected(self, ctx): + result = create_rectangle_drawing_handler( + {"project_id": "p1", "width": 80, "height": 50, "stroke": "#333333", "dasharray": "25, 25"}, ctx + ) + assert "error" in result + + +class TestCreateEllipseDrawingHandler: + def test_success(self, ctx): + with patch(f"{AH}._get_connector") as m: + conn = _mock_conn({"drawing_id": "d3"}) + m.return_value = conn + result = create_ellipse_drawing_handler( + {"project_id": "p1", "x": 300, "y": 300, "width": 160, "height": 160, "fill": "#5AA9DD"}, ctx + ) + assert result["drawing"]["drawing_id"] == "d3" + body = conn.http_call.call_args.kwargs["json_data"] + assert body["z"] == 0 + assert body["svg"] == ( + '' + '' + ) + + def test_odd_dimensions_report_actual_size(self, ctx): + with patch(f"{AH}._get_connector") as m: + conn = _mock_conn({"drawing_id": "d3"}) + m.return_value = conn + result = create_ellipse_drawing_handler({"project_id": "p1", "width": 101, "height": 61}, ctx) + assert (result["width"], result["height"]) == (100, 60) + + def test_too_small(self, ctx): + result = create_ellipse_drawing_handler({"project_id": "p1", "width": 1, "height": 60}, ctx) + assert "error" in result + + def test_missing_dimensions(self, ctx): + assert "error" in create_ellipse_drawing_handler({"project_id": "p1", "width": 100}, ctx) diff --git a/tests/agent/mcp/test_drawings.py b/tests/agent/mcp/test_drawings.py deleted file mode 100644 index 0979ecb04..000000000 --- a/tests/agent/mcp/test_drawings.py +++ /dev/null @@ -1,149 +0,0 @@ -""" -MCP text drawing tests: SVG builder units and handler behavior. -""" - -from unittest.mock import MagicMock, patch -from xml.etree import ElementTree as ET - -import pytest - -from gns3server.agent.mcp.drawings import build_text_svg, create_text_drawing_handler - -BASE = "gns3server.agent.mcp" - - -def _mock_conn(json_result=None): - """Create a mocked Gns3Connector with base_url and http_call.""" - conn = MagicMock() - conn.base_url = "http://192.168.1.3:3080/v3" - conn.http_call.return_value.json.return_value = json_result or {"status": "ok"} - return conn - - -@pytest.fixture -def ctx(): - return {"server_url": "http://192.168.1.3:3080", "jwt_token": "token", "jwt_username": "admin"} - - -class TestBuildTextSvg: - def test_single_line(self): - svg, width, height, line_height = build_text_svg("R1", font_size=13) - assert svg == ( - '' - 'R1' - ) - # 2 chars x 13 x 0.8 = 20.8 -> 21; one line of 13 x 1.9 = 24.7 -> 25 - assert width == 21 - assert height == 25 - assert line_height == 25 - - def test_bold_and_color(self): - svg, _, _, _ = build_text_svg("R1", font_size=13, color="#CC0000", bold=True) - assert 'fill="#CC0000"' in svg - assert 'font-weight="bold"' in svg - - def test_escapes_xml_characters(self): - svg, _, _, _ = build_text_svg("a mono_w - - def test_empty_line_kept(self): - # a blank line still occupies a line box - _, _, height, line_height = build_text_svg("a\n\nb", font_size=10) - assert height == 3 * line_height - - def test_matches_web_ui_serializer_shape(self): - # byte-for-byte structural parity with the Web UI's own text output - svg, width, height, _ = build_text_svg("demo", font_size=11) - assert svg.startswith(f'", "a&b", ""]) - def test_bad_font_family(self, ctx, font_family): - result = create_text_drawing_handler({"project_id": "p1", "text": "R1", "font_family": font_family}, ctx) - assert "error" in result - - def test_font_family_with_spaces_accepted(self, ctx): - with patch(f"{BASE}.drawings._get_connector") as m: - conn = _mock_conn({"drawing_id": "d1"}) - m.return_value = conn - result = create_text_drawing_handler( - {"project_id": "p1", "text": "R1", "font_family": "DejaVu Sans Mono"}, ctx - ) - assert "error" not in result - - def test_xml_entities_survive_round_trip(self, ctx): - with patch(f"{BASE}.drawings._get_connector") as m: - conn = _mock_conn({"drawing_id": "d1"}) - m.return_value = conn - create_text_drawing_handler({"project_id": "p1", "text": "R1 & "}, ctx) - svg = conn.http_call.call_args.kwargs["json_data"]["svg"] - assert "R1 & <backup>" in svg - ET.fromstring(svg) diff --git a/tests/agent/mcp/test_tool_params.py b/tests/agent/mcp/test_tool_params.py index 29d266191..050ba58ea 100644 --- a/tests/agent/mcp/test_tool_params.py +++ b/tests/agent/mcp/test_tool_params.py @@ -67,6 +67,9 @@ HANDLER_FILES = { "download_capture_file_handler": API_HANDLERS_FILE, "link_marker_handler": API_HANDLERS_FILE, "marker_definition_handler": API_HANDLERS_FILE, + "create_text_drawing_handler": API_HANDLERS_FILE, + "create_rectangle_drawing_handler": API_HANDLERS_FILE, + "create_ellipse_drawing_handler": API_HANDLERS_FILE, "list_templates_handler": "templates.py", "get_template_handler": "templates.py", "create_template_handler": "templates.py", @@ -81,7 +84,6 @@ HANDLER_FILES = { "restore_snapshot_handler": "snapshots.py", "get_drawings_handler": "drawings.py", "create_drawing_handler": "drawings.py", - "create_text_drawing_handler": "drawings.py", "get_drawing_handler": "drawings.py", "update_drawing_handler": "drawings.py", "delete_drawing_handler": "drawings.py", diff --git a/tests/utils/test_drawings.py b/tests/utils/test_drawings.py new file mode 100644 index 000000000..58735ddd2 --- /dev/null +++ b/tests/utils/test_drawings.py @@ -0,0 +1,192 @@ +""" +Web UI compatible drawing SVG builder tests. +""" + +from xml.etree import ElementTree as ET + +import pytest + +from gns3server.utils.drawings import build_ellipse_svg, build_rectangle_svg, build_text_svg + + +class TestBuildTextSvg: + def test_single_line(self): + svg, width, height, line_height = build_text_svg("R1", font_size=13) + assert svg == ( + '' + 'R1' + ) + # 2 chars x 13 x 0.8 = 20.8 -> 21; one line of 13 x 1.9 = 24.7 -> 25 + assert width == 21 + assert height == 25 + assert line_height == 25 + + def test_bold_and_color(self): + svg, _, _, _ = build_text_svg("R1", font_size=13, color="#CC0000", bold=True) + assert 'fill="#CC0000"' in svg + assert 'font-weight="bold"' in svg + + def test_escapes_xml_characters(self): + svg, _, _, _ = build_text_svg("a mono_w + + def test_empty_line_kept(self): + # a blank line still occupies a line box + _, _, height, line_height = build_text_svg("a\n\nb", font_size=10) + assert height == 3 * line_height + + def test_matches_web_ui_serializer_shape(self): + # byte-for-byte structural parity with the Web UI's own text output + svg, width, height, _ = build_text_svg("demo", font_size=11) + assert svg.startswith(f'", "a&b", ""]) + def test_bad_font_family(self, font_family): + with pytest.raises(ValueError): + build_text_svg("R1", font_family=font_family) + + def test_font_family_with_spaces_accepted(self): + svg, _, _, _ = build_text_svg("R1", font_family="DejaVu Sans Mono") + assert 'font-family="DejaVu Sans Mono"' in svg + + +class TestBuildRectangleSvg: + def test_plain_fill(self): + svg, width, height = build_rectangle_svg(80, 50, fill="#4A90D9") + assert svg == ( + '' + '' + ) + assert (width, height) == (80, 50) + # no stroke requested -> no stroke attributes (Web UI serializer parity) + assert "stroke" not in svg + + def test_stroke_and_dashed(self): + svg, _, _ = build_rectangle_svg(80, 50, fill="none", fill_opacity=0.5, stroke="#333333", stroke_width=2, dashed=True) + assert 'fill="none"' in svg + assert 'fill-opacity="0.5"' in svg + assert 'stroke="#333333" stroke-width="2" stroke-dasharray="10,6"' in svg + + def test_custom_dasharray(self): + svg, _, _ = build_rectangle_svg(80, 50, stroke="#000000", dasharray="4,2") + assert 'stroke-dasharray="4,2"' in svg + + def test_corner_radius(self): + svg, _, _ = build_rectangle_svg(100, 60, rx=8) + assert 'rx="8" ry="8"' in svg + + def test_fill_none_renders_unfilled(self): + # the Web UI renders fill="none" as an outline-only box + svg, _, _ = build_rectangle_svg(80, 50, fill="none", stroke="#333333") + ET.fromstring(svg) + + @pytest.mark.parametrize("width,height", [(0, 50), (80, 0), (-1, 50), (2001, 50), ("80", 50)]) + def test_bad_dimensions(self, width, height): + with pytest.raises(ValueError): + build_rectangle_svg(width, height) + + def test_radius_too_large(self): + with pytest.raises(ValueError): + build_rectangle_svg(80, 50, rx=26) + + @pytest.mark.parametrize("fill", ["red", 123, "transparent"]) + def test_bad_fill(self, fill): + with pytest.raises(ValueError): + build_rectangle_svg(80, 50, fill=fill) + + @pytest.mark.parametrize("fill_opacity", [-0.1, 1.1, "0.5", True]) + def test_bad_fill_opacity(self, fill_opacity): + with pytest.raises(ValueError): + build_rectangle_svg(80, 50, fill_opacity=fill_opacity) + + def test_bad_stroke(self): + with pytest.raises(ValueError): + build_rectangle_svg(80, 50, stroke="blue") + + def test_bad_stroke_width(self): + with pytest.raises(ValueError): + build_rectangle_svg(80, 50, stroke="#333333", stroke_width=0) + + @pytest.mark.parametrize("dasharray", ["25, 25", "5, 25", "abc", "5;5", '5,5" onload="x']) + def test_bad_dasharray(self, dasharray): + with pytest.raises(ValueError): + build_rectangle_svg(80, 50, stroke="#333333", dasharray=dasharray) + + def test_dasharray_without_stroke(self): + with pytest.raises(ValueError): + build_rectangle_svg(80, 50, dasharray="5,5") + + +class TestBuildEllipseSvg: + def test_bounding_box_derivation(self): + svg, width, height = build_ellipse_svg(100, 60, fill="#5AA9DD", fill_opacity=0.8) + assert svg == ( + '' + '' + ) + assert (width, height) == (100, 60) + + def test_odd_dimensions_round_down(self): + svg, width, height = build_ellipse_svg(101, 61) + assert (width, height) == (100, 60) + assert 'cx="50" cy="30" rx="50" ry="30"' in svg + + def test_circle(self): + svg, width, height = build_ellipse_svg(80, 80, stroke="#333333") + assert width == height == 80 + assert 'rx="40" ry="40"' in svg + + def test_too_small(self): + with pytest.raises(ValueError): + build_ellipse_svg(1, 60) + + def test_stroke_and_dashed(self): + svg, _, _ = build_ellipse_svg(100, 60, fill="none", stroke="#E74C3C", stroke_width=2, dashed=True) + assert 'stroke-dasharray="10,6"' in svg + ET.fromstring(svg)