mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-12 13:35:31 +03:00
249 lines
9.7 KiB
Python
249 lines
9.7 KiB
Python
#!/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
|