Merge pull request #2971 from yueguobin/feat/mcp-text-drawing-tool

feat(mcp): Web UI compatible drawing creation (text tool + shared SVG builders)
This commit is contained in:
Jeremy Grossmann 2026-10-10 12:15:27 +02:00 committed by GitHub
commit d50b1996e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1031 additions and 56 deletions

View File

@ -187,7 +187,9 @@ All subsequent tool handler REST API calls use this JWT → zero extra bcrypt
|------|-------------|
| `drawing_list` | List drawings on canvas |
| `drawing_get` | Get drawing details |
| `drawing_create` | Create drawing (SVG label/shape/image) |
| `drawing_create_text` | Create a text label (SVG assembled server-side, Web UI compatible) |
| `drawing_create_rectangle` | Create a rectangle annotation |
| `drawing_create_ellipse` | Create an ellipse/circle annotation |
| `drawing_update` | Update drawing (position, rotation, SVG) |
| `drawing_delete` | Delete a drawing |

View File

@ -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

View File

@ -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}

View File

@ -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",

View File

@ -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 <https://www.gnu.org/licenses/>.
#
# 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)

View File

@ -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,9 @@ def generate_ellipse_svg(
svg_width: int,
svg_height: int,
) -> str:
"""Generate SVG for ellipse."""
return f"""<svg width="{svg_width}" height="{svg_height}"><ellipse cx="{rx}" cy="{ry}" rx="{rx}" ry="{ry}" fill="{color_scheme["fill"]}" fill-opacity="{color_scheme["fill_opacity"]}"/></svg>"""
"""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 +254,21 @@ def generate_rectangle_svg(
height: int,
color_scheme: dict[str, Any],
) -> str:
"""Generate SVG for rectangle."""
return f"""<svg width="{width}" height="{height}"><rect x="0" y="0" width="{width}" height="{height}" fill="{color_scheme["fill"]}" fill-opacity="{color_scheme["fill_opacity"]}"/></svg>"""
"""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"""<svg width="{text_width}" height="{text_height}"><text font-family="TypeWriter" font-size="{DEFAULT_FONT_SIZE}.0" font-weight="bold" fill="{color_scheme["stroke"]}" text-anchor="middle" x="{text_width / 2}" y="{text_height / 2 + 4}">{text}</text></svg>"""
"""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:

View File

@ -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,
@ -109,7 +112,6 @@ from .device_config import (
vpcs_config_set_handler,
)
from .drawings import (
create_drawing_handler,
delete_drawing_handler,
get_drawing_handler,
get_drawings_handler,
@ -1539,41 +1541,142 @@ async def drawing_list(
@mcp.tool()
async def drawing_create(
async def drawing_create_text(
project_id: Annotated[str, Field(description="Project name or UUID")],
svg: Annotated[str, Field(description="SVG content for the drawing")],
x: Annotated[int, Field(description="X coordinate (default: 0)")] = 0,
y: Annotated[int, Field(description="Y coordinate (default: 0)")] = 0,
z: Annotated[int, Field(description="Z layer (default: 0)")] = 0,
locked: Annotated[bool, Field(description="Lock the drawing (default: false)")] = False,
rotation: Annotated[int, Field(description="Rotation angle in degrees, -359 to 359 (default: 0)")] = 0,
x: Annotated[int, Field(description="X coordinate of the text top-left corner on the canvas")],
y: Annotated[int, Field(description="Y coordinate of the text top-left corner on the canvas")],
text: Annotated[str, Field(description="Label text; newlines (\\n) start additional lines")],
font_size: Annotated[int, Field(description="Font size, 1-200 (default: 13)")] = 13,
color: Annotated[str, Field(description="Text color, hex like #RRGGBB (default: #000000)")] = "#000000",
bold: Annotated[bool, Field(description="Bold text (default: false)")] = False,
font_family: Annotated[str, Field(description="Font family name (default: monospace)")] = "monospace",
z: Annotated[int, Field(description="Z layer; 1 keeps text above links (default: 1)")] = 1,
) -> list[dict[str, Any]]:
"""Create a new drawing (label, shape, or image) on a project canvas.
"""Add a text label to a project canvas.
GNS3 SVG rendering notes:
- <rect> MUST have a solid fill color (e.g. fill=\"#FF0000\") to render.
fill=\"none\" or fill=\"transparent\" will be invisible in the GUI.
- <ellipse> works correctly with or without fill.
- <line> and <text> work normally.
The SVG is assembled server-side in the Web UI compatible format
(validation, escaping and sizing included).
SVG examples:
Text label: <svg><text x=\"10\" y=\"20\" font-size=\"14\">R1</text></svg>
Rectangle: <svg><rect x=\"10\" y=\"10\" width=\"80\" height=\"50\" fill=\"#4A90D9\" stroke=\"black\"/></svg>
Ellipse: <svg><ellipse cx=\"50\" cy=\"50\" rx=\"40\" ry=\"20\" fill=\"red\" stroke=\"black\"/></svg>
Line: <svg><line x1=\"0\" y1=\"0\" x2=\"100\" y2=\"100\" stroke=\"black\" stroke-width=\"2\"/></svg>
Dashed line: <svg><line x1=\"0\" y1=\"0\" x2=\"100\" y2=\"100\" stroke=\"black\" stroke-dasharray=\"5,5\"/></svg>
- XML characters (& < >) are escaped automatically.
- Multi-line: embed newlines (\\n) in text; each line renders below the previous one.
- The drawing width/height are computed from the text and returned, so labels
can be laid out without guessing the selection box size.
- Constraints: color must be hex like #RRGGBB; font_family accepts plain names
(letters, digits, spaces, . ' -); text capped at 500 characters.
Example: bold red label at (100, 80):
drawing_create_text(project_id="demo", x=100, y=80, text="R1 management", color="#CC0000", bold=True)
"""
return await asyncio.to_thread(
_run_handler_sync,
create_drawing_handler,
create_text_drawing_handler,
{
"project_id": project_id,
"svg": svg,
"text": text,
"x": x,
"y": y,
"z": z,
"locked": locked,
"rotation": rotation,
"font_size": font_size,
"color": color,
"bold": bold,
"font_family": font_family,
},
)
@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
(validation included).
- 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,
},
)

View File

@ -51,24 +51,6 @@ def get_drawings_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> di
return {"drawings": drawings, "count": len(drawings)}
def create_drawing_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
svg = params.get("svg")
if not project_id or not svg:
return {"error": "project_id and svg are required"}
conn = _get_connector(gns3_ctx)
data = {
"svg": svg,
"x": params.get("x", 0),
"y": params.get("y", 0),
"z": params.get("z", 0),
"locked": params.get("locked", False),
"rotation": params.get("rotation", 0),
}
result = conn.http_call("post", f"{conn.base_url}/projects/{project_id}/drawings", json_data=data).json()
return {"message": "Drawing created", "drawing": result}
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")

View File

@ -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 <http://www.gnu.org/licenses/>.
"""
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 <text> SVG for a canvas label.
The output mirrors what the Web UI itself serializes for text drawings:
root <svg height width> with one <text> 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'<svg height="{height}" width="{width}">'
f'<text fill="{color}" fill-opacity="1.0" '
f'font-family="{font_family}" font-size="{font_size}" '
f'font-weight="{weight}">{escape(text)}</text></svg>'
)
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 <rect> 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'<svg height="{height}" width="{width}">'
f"<rect {_style_attrs(fill, fill_opacity, stroke, stroke_width, dash)} "
f'height="{height}" width="{width}" rx="{rx}" ry="{rx}"/></svg>'
)
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 <ellipse> 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'<svg height="{2 * ry}" width="{2 * rx}">'
f"<ellipse {_style_attrs(fill, fill_opacity, stroke, stroke_width, dash)} "
f'cx="{rx}" cy="{ry}" rx="{rx}" ry="{ry}"/></svg>'
)
ET.fromstring(svg)
return svg, 2 * rx, 2 * ry

View File

@ -211,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"]
@ -234,7 +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_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"]

View File

@ -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<serif>", "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 & <backup>"}, ctx)
svg = conn.http_call.call_args.kwargs["json_data"]["svg"]
assert "R1 &amp; &lt;backup&gt;" 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"] == (
'<svg height="50" width="80">'
'<rect fill="#4A90D9" fill-opacity="1.0" height="50" width="80" rx="0" ry="0"/></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"] == (
'<svg height="160" width="160">'
'<ellipse fill="#5AA9DD" fill-opacity="1.0" cx="80" cy="80" rx="80" ry="80"/></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)

View File

@ -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",
@ -80,7 +83,6 @@ HANDLER_FILES = {
"delete_snapshot_handler": "snapshots.py",
"restore_snapshot_handler": "snapshots.py",
"get_drawings_handler": "drawings.py",
"create_drawing_handler": "drawings.py",
"get_drawing_handler": "drawings.py",
"update_drawing_handler": "drawings.py",
"delete_drawing_handler": "drawings.py",

View File

@ -0,0 +1,194 @@
"""
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 == (
'<svg height="25" width="21">'
'<text fill="#000000" fill-opacity="1.0" font-family="monospace" '
'font-size="13" font-weight="normal">R1</text></svg>'
)
# 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<b&c", font_size=10)
root = ET.fromstring(svg)
# round-trips through the parser back to the original text
assert root.find("text").text == "a<b&c"
assert "a&lt;b&amp;c" in svg
def test_multi_line(self):
svg, width, height, line_height = build_text_svg("hello\nworld!", font_size=10)
# longest line is 6 chars x 10 x 0.8 = 48; two lines of ceil(19)
assert width == 48
assert height == 38
assert line_height == 19
assert "\n" in ET.fromstring(svg).find("text").text
def test_newline_normalization(self):
svg, _, height, _ = build_text_svg("a\r\nb\rc", font_size=10)
assert "\r" not in svg
assert height == 3 * 19
def test_proportional_font_wider(self):
mono_w, _, _, _ = build_text_svg("same", font_size=10, font_family="monospace")
prop_w, _, _, _ = build_text_svg("same", font_size=10, font_family="Noto Sans")
assert prop_w > 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'<svg height="{height}" width="{width}"><text fill=')
assert svg.endswith("</text></svg>")
@pytest.mark.parametrize("text", [None, "", "\n\n"])
def test_empty_text_rejected(self, text):
with pytest.raises(ValueError):
build_text_svg(text)
def test_text_too_long(self):
with pytest.raises(ValueError):
build_text_svg("x" * 501)
def test_control_characters_rejected(self):
with pytest.raises(ValueError):
build_text_svg("a\x00b")
@pytest.mark.parametrize("font_size", [0, 201, "13", 13.5, True])
def test_bad_font_size(self, font_size):
with pytest.raises(ValueError):
build_text_svg("R1", font_size=font_size)
@pytest.mark.parametrize("color", ["red", "123456", "#GGGGGG", "#12345", 'black" onload="x'])
def test_bad_color(self, color):
with pytest.raises(ValueError):
build_text_svg("R1", color=color)
@pytest.mark.parametrize("font_family", ['mono" onload="x', "sans<serif>", "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 == (
'<svg height="50" width="80">'
'<rect fill="#4A90D9" fill-opacity="1.0" height="50" width="80" rx="0" ry="0"/></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 == (
'<svg height="60" width="100">'
'<ellipse fill="#5AA9DD" fill-opacity="0.8" cx="50" cy="30" rx="50" ry="30"/></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)