feat(agent): share Web UI compatible drawing builders between MCP and copilot

The text drawing tool was MCP-only; the copilot needs the same
capability and its area-annotation helper carried the same Web UI
compat bugs. Split into three layers with a single implementation:

- gns3server/utils/drawings.py: pure SVG builders (text, rectangle,
  ellipse) with full input validation (ValueError) — the Web UI
  format knowledge lives in core utils next to picture.py
- api_handlers.py: shared REST handlers (create_text_drawing_handler
  moved here, create_rectangle/ellipse_drawing_handler added) — the
  layer both consumers already share
- thin shells: MCP tools (drawing_create_text unchanged, plus new
  drawing_create_rectangle/_ellipse) and three LangChain tools
  (tools_v2/gns3_create_drawing.py) registered in both copilot mode
  lists

Shape builder details:

- rectangle: solid fill or "none", stroke block emitted only when a
  stroke is given, rx corner radius, dasharray validated against the
  Web UI's Qt remap table (QtDasharrayFixer rewrites those patterns)
- ellipse: bounding-box API; cx/cy derived as offsets inside the
  drawing box (the Web UI treats them as box-relative, not canvas
  coordinates); odd sizes round down and the actual size is returned

gns3_drawing_utils now delegates to the shared builders, fixing the
px-blind sizing, the ignored text-anchor/x/y centering and the
missing XML escaping in the area-annotation path.
This commit is contained in:
YueGuobin committed 2026-10-09 11:12:18 +08:00
1 parent 73cbb0387c
commit 5e8272fdd8
13 files changed
+1003 -265

No files matched your search

@@ -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
@@ -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}
@@ -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",
@@ -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)
@@ -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"""<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 +256,23 @@ 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:
+100 -1
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,
@@ -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")],
-99
View File
@@ -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 <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
"""
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>'
)
# 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")