1from __future__ import annotations23import html4import math5import re6from dataclasses import dataclass, field78from protocol import BACKGROUND, KNOWN_OPTIONS, ProtocolError91011MAX_SVG_LENGTH = 4_194_30412131415class Port:16 name: str17 direction: str18 data_type: str = ""19 bus: bool = False20 clock: bool = False21 bubble: bool = False22 group: str | None = None23242526class Component:27 name: str28 generics: list[Port] = field(default_factory=list)29 ports: list[Port] = field(default_factory=list)303132GROUP = re.compile(r"\{\{\s*([^|}]+)?\s*\|\s*([^}]+)?\s*\}\}")33VERILOG_MODULE = re.compile(r"\bmodule\s+([A-Za-z_$][\w$]*)", re.IGNORECASE)34VHDL_ENTITY = re.compile(r"\b(?:entity|component)\s+([A-Za-z_]\w*)\s+is", re.IGNORECASE)353637def _group(line: str) -> str | None:38 match = GROUP.search(line)39 if not match:40 return None41 return (match.group(2) or match.group(1) or "").strip() or None424344def _clean(text: str) -> str:45 text = re.sub(r"/\*.*?\*/", " ", text, flags=re.S)46 text = re.sub(r"//[^\n]*", "\n", text)47 return text484950def _split_names(value: str) -> list[str]:51 return [part.strip() for part in value.split(",") if re.fullmatch(r"[A-Za-z_$][\w$]*", part.strip())]525354def _verilog(source: str) -> list[Component]:55 components: list[Component] = []56 for module_match in VERILOG_MODULE.finditer(source):57 start = module_match.start()58 end = re.search(r"\bendmodule\b", source[module_match.end():], re.IGNORECASE)59 body = source[module_match.end(): module_match.end() + end.start() if end else len(source)]60 component = Component(module_match.group(1))61 pending_group: str | None = None62 for raw in body.splitlines():63 candidate_group = _group(raw)64 if candidate_group is not None:65 pending_group = candidate_group66 continue67 line = re.sub(r"\s+", " ", raw).strip()68 parameter = re.match(r"parameter(?:\s+\w+)?\s+([A-Za-z_$][\w$]*)", line, re.I)69 if parameter:70 component.generics.append(Port(parameter.group(1), "in", "", group=pending_group))71 pending_group = None72 continue73 # Header-style declarations may be comma-separated on one line;74 # split only at commas that introduce another direction so ranges75 # and ordinary name lists remain intact.76 for declaration in re.split(r",(?=\s*(?:input|output|inout)\b)", line, flags=re.I):77 declaration = declaration.strip().lstrip("(").rstrip(");").strip()78 port = re.match(79 r"(?:input|output|inout)\s+(?:(?:wire|logic|reg|tri|signed|unsigned)\s+)*(?:(\[[^]]+\])\s*)?(.+?);?$",80 declaration,81 re.I,82 )83 if not port:84 continue85 direction = re.match(r"(input|output|inout)", declaration, re.I).group(1).lower()86 direction = {"input": "in", "output": "out"}.get(direction, direction)87 data_type = port.group(1) or ""88 for name in _split_names(port.group(2).strip().rstrip(";")):89 component.ports.append(Port(name, direction, data_type, bool(data_type), bool(re.search(r"clock", name, re.I)), bool(re.search(r"_[nb]$", name, re.I)), pending_group))90 pending_group = None91 components.append(component)92 return components939495def _vhdl(source: str) -> list[Component]:96 components: list[Component] = []97 for match in VHDL_ENTITY.finditer(source):98 tail = source[match.end():]99 end = re.search(r"\bend\s+(?:entity|component)?\s*\w*\s*;", tail, re.I)100 body = tail[: end.start()] if end else tail101 component = Component(match.group(1))102 pending_group: str | None = None103 section = "ports"104 # Normalize compact declarations into the same statement stream as105 # the usual multiline VHDL style. Comments remain available for the106 # grouped-port form used by Symbolator.107 body = re.sub(r"\b(generic|port)\s*\(", r"\1 (\n", body, flags=re.I)108 body = body.replace(")", "\n)")109 for raw in body.splitlines():110 candidate_group = _group(raw)111 if candidate_group is not None:112 pending_group = candidate_group113 continue114 if re.search(r"\bgeneric\s*\(", raw, re.I):115 section = "generics"116 continue117 if re.search(r"\bport\s*\(", raw, re.I):118 section = "ports"119 continue120 line = re.sub(r"--[^\n]*", "", raw).strip().rstrip(";,)")121 item = re.match(r"(.+?)\s*:\s*(?:(in|out|inout|buffer)\s+)?(.+)$", line, re.I)122 if not item:123 continue124 names, direction, data_type = item.groups()125 direction = (direction or "in").lower()126 for name in _split_names(names.replace(" ", "")):127 is_bus = "(" in data_type or "vector" in data_type.lower()128 port = Port(name, direction, data_type.strip(), is_bus, bool(re.search(r"clock", name, re.I)), bool(re.search(r"_[nb]$", name, re.I)), pending_group)129 (component.generics if section == "generics" else component.ports).append(port)130 pending_group = None131 components.append(component)132 return components133134135def parse_components(source: str) -> list[Component]:136 clean = _clean(source)137 components = _verilog(source)138 return components or _vhdl(source)139140141def _text_width(text: str, size: float = 12) -> float:142 return max(1.0, len(re.sub(r"<[^>]+>", "", text)) * size * 0.62)143144145def _esc(text: str) -> str:146 return html.escape(text, quote=True)147148149def _text(x: float, y: float, value: str, anchor: str = "start", cls: str = "fnt1") -> str:150 return f'<text class="{cls}" x="{x:g}" y="{y:g}" text-anchor="{anchor}" dominant-baseline="middle">{_esc(value)}</text>'151152153def render_svg(154 source: str,155 options: dict[str, str] | None = None,156 *,157 title_text: str = "",158 description: str = "",159 presentation_background: str = "",160 presentation_padding: int = 0,161 presentation_frame: bool = False,162) -> tuple[str, str]:163 options = options or {}164 unknown = set(options) - KNOWN_OPTIONS165 if unknown:166 raise ProtocolError(400, "invalid_options", f"Unknown renderer option: {sorted(unknown)[0]}.")167 if "library-name" in options and "title" not in options:168 raise ProtocolError(400, "invalid_options", "options.library-name requires the title option.")169 if presentation_background and not BACKGROUND.fullmatch(presentation_background):170 raise ProtocolError(400, "invalid_presentation", "presentation.background must be an RGB hex color.")171 components = parse_components(source)172 if not components:173 raise ProtocolError(422, "render_failed", "No Verilog module or VHDL entity/component was found.")174 requested = options.get("component", "")175 if requested:176 components = [item for item in components if item.name == requested]177 if not components:178 raise ProtocolError(422, "render_failed", f"Component {requested} was not found.")179 component = components[-1]180 no_type = "no-type" in options181 title = "title" in options182 library = options.get("library-name", "")183 try:184 scale = float(options.get("scale", "1.0"))185 except ValueError as error:186 raise ProtocolError(400, "invalid_options", "options.scale must be numeric.") from error187 if not math.isfinite(scale) or scale <= 0 or scale > 16:188 raise ProtocolError(400, "invalid_options", "options.scale must be greater than zero and at most 16.")189190 groups: list[tuple[str | None, list[Port]]] = []191 for port in component.ports:192 if groups and groups[-1][0] == port.group:193 groups[-1][1].append(port)194 else:195 groups.append((port.group, [port]))196 groups = [(name, ports) for name, ports in groups if ports]197 left = [port for _, ports in groups for port in ports if port.direction == "in"]198 right = [port for _, ports in groups for port in ports if port.direction in ("out", "inout", "buffer")]199 max_label = max([_text_width(port.name) for port in left + right] + [0])200 max_type = 0 if no_type else max([_text_width(port.data_type) for port in left + right] + [0])201 width = max(180, 120 + max_label * 2 + max_type * 2)202 y = 30203 parts: list[str] = []204 sections: list[tuple[float, float, float, float, str | None]] = []205 if component.generics:206 height = max(38, 22 * len(component.generics) + 16)207 sections.append((0, y, width, height, None))208 for index, port in enumerate(component.generics):209 parts.append(_text(20, y + 22 + index * 22, port.name))210 y += height211 row = 0212 port_sections: list[tuple[float, list[Port], str | None]] = []213 for group_name, ports in groups:214 rows = max(len([p for p in ports if p.direction == "in"]), len([p for p in ports if p.direction != "in"]))215 height = max(42, 22 * rows + (24 if group_name else 0) + 12)216 sections.append((0, y, width, height, group_name))217 port_sections.append((y, ports, group_name))218 if group_name:219 parts.append(_text(width / 2, y + 12, group_name, "middle", "fnt2"))220 y += height221 row += rows222 height_total = max(100, y + 30)223 for sx, sy, sw, sh, group_name in sections:224 fill = "#e8e8e8" if group_name is None else f"hsl({(sections.index((sx, sy, sw, sh, group_name)) * 77) % 360} 75% 88%)"225 stroke = "#666" if group_name is None else "#222"226 parts.insert(0, f'<rect x="{sx:g}" y="{sy:g}" width="{sw:g}" height="{sh:g}" fill="{fill}" stroke="{stroke}" stroke-width="2"/>')227 for group_y, ports, group_name in port_sections:228 top = group_y + (24 if group_name else 0) + 18229 for index, port in enumerate([p for p in ports if p.direction == "in"]):230 py = top + index * 22231 parts.append(f'<line x1="-35" y1="{py:g}" x2="0" y2="{py:g}" stroke="#111" stroke-width="{3 if port.bus else 1}"/>')232 if port.clock:233 parts.append(f'<path d="M -35 {py-5:g} L -27 {py:g} L -35 {py+5:g}" fill="none" stroke="#111"/>')234 if port.bubble:235 parts.append(f'<circle cx="-35" cy="{py:g}" r="4" fill="white" stroke="#111"/>')236 label = port.name if no_type or not port.data_type else f"{port.name}"237 parts.append(_text(12, py, label))238 if not no_type and port.data_type:239 parts.append(_text(-43, py, port.data_type, "end", "fnt3"))240 for index, port in enumerate([p for p in ports if p.direction != "in"]):241 py = top + index * 22242 parts.append(f'<line x1="{width:g}" y1="{py:g}" x2="{width+35:g}" y2="{py:g}" stroke="#111" stroke-width="{3 if port.bus else 1}"/>')243 if port.bubble:244 parts.append(f'<circle cx="{width+35:g}" cy="{py:g}" r="4" fill="white" stroke="#111"/>')245 parts.append(_text(width - 12, py, port.name, "end"))246 if not no_type and port.data_type:247 parts.append(_text(width + 43, py, port.data_type, "start", "fnt3"))248 if title:249 if library:250 parts.append(_text(width / 2, -24, library, "middle", "fnt4"))251 parts.append(_text(width / 2, -8, component.name, "middle", "fnt4"))252 else:253 parts.append(_text(width / 2, -12, component.name, "middle", "fnt4"))254 transparent = "transparent" in options255 title_height = 40 if title and library else 25 if title else 0256 view_x, view_y = -max_type - 50, -35 if title else -10257 view_w, view_h = width + max_type * 2 + 100, height_total + title_height258 padding = float(presentation_padding)259 view_x -= padding260 view_y -= padding261 view_w += padding * 2262 view_h += padding * 2263 styles = """.fnt1 { fill:#000; font-family:Sans,sans-serif; font-size:12pt; } .fnt2 { fill:#000; font-family:Sans,sans-serif; font-size:9pt; font-weight:bold; } .fnt3 { fill:#969696; font-family:Sans,sans-serif; font-size:12pt; } .fnt4 { fill:#000; font-family:Sans,sans-serif; font-size:12pt; font-weight:bold; }"""264 background_color = presentation_background or ("" if transparent else "white")265 background = f'<rect x="{view_x:g}" y="{view_y:g}" width="{view_w:g}" height="{view_h:g}" fill="{background_color}"/>' if background_color else ""266 if title_text:267 parts.insert(0, _text(view_x + 8, view_y + 12, title_text, "start", "fnt2"))268 if description:269 parts.insert(1 if title_text else 0, _text(view_x + 8, view_y + 28, description, "start", "fnt3"))270 if presentation_frame:271 parts.append(f'<rect x="{view_x + 0.5:g}" y="{view_y + 0.5:g}" width="{max(0, view_w - 1):g}" height="{max(0, view_h - 1):g}" fill="none" stroke="#000" stroke-width="1" vector-effect="non-scaling-stroke"/>')272 svg = f'<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="{view_w*scale:g}" height="{view_h*scale:g}" viewBox="{view_x:g} {view_y:g} {view_w:g} {view_h:g}"><style>{styles}</style>{background}{"".join(parts)}</svg>'273 if len(svg.encode("utf-8")) > MAX_SVG_LENGTH:274 raise ProtocolError(413, "render_too_large", "Rendered SVG is too large.")275 return svg, "1.2.2-compatible-translation"