"""SVG primitives and the main graphical renderer.
The drawing pipeline is two-stage: :func:`build_primitives` emits
style-agnostic geometric primitives in *logical* coordinates (x = time axis,
y = wire axis); :func:`serialize_svg` maps them to SVG text, handling the
optional vertical orientation (transpose) and scale. Keeping these separate
is what lets the matplotlib backend (mpl/png) reuse the exact same drawing.
"""
from __future__ import annotations
import html as _html
import math
from .labels import classify, cop_label, label_lines, param_strings, tooltip
from .layout import estimate_text_width, layout_circuit
from .model import FLOW_OPS, DrawCircuit, DrawOp
from .options import RenderOptions
from .styles import Style, get_style
__all__ = ["build_primitives", "serialize_svg", "render_svg", "RenderMeta"]
# ---------------------------------------------------------------------------
# Primitive constructors (plain dicts; coordinates are logical)
# ---------------------------------------------------------------------------
def _line(x1, y1, x2, y2, **kw):
return {"t": "line", "x1": x1, "y1": y1, "x2": x2, "y2": y2, **kw}
def _rect(x, y, w, h, **kw):
return {"t": "rect", "x": x, "y": y, "w": w, "h": h, **kw}
def _circ(cx, cy, r, **kw):
return {"t": "circle", "cx": cx, "cy": cy, "r": r, **kw}
def _text(x, y, txt, **kw):
return {"t": "text", "x": x, "y": y, "txt": str(txt), **kw}
def _tri(pts, **kw):
return {"t": "tri", "pts": pts, **kw}
def _arc(cx, cy, r, a1, a2, **kw):
return {"t": "arc", "cx": cx, "cy": cy, "r": r, "a1": a1, "a2": a2, **kw}
def _gbegin(**kw):
return {"t": "g", **kw}
_GEND = {"t": "/g"}
# ---------------------------------------------------------------------------
# Semantic op drawing
# ---------------------------------------------------------------------------
def _ctrl_dot(pr, x, y, st, th):
pr.append(_circ(x, y, st.ctrl_r, fill=th.ink))
def _target_circ(pr, x, y, st, th):
r = st.targ_r
pr.append(_circ(x, y, r, fill="none", stroke=th.ink, **{"stroke-width": st.sw + 0.2}))
pr.append(_line(x - r - 3, y, x + r + 3, y, stroke=th.ink, **{"stroke-width": st.sw + 0.2}))
pr.append(_line(x, y - r - 3, x, y + r + 3, stroke=th.ink, **{"stroke-width": st.sw + 0.2}))
def _swap_cross(pr, x, y, st, th, s=6.0):
pr.append(_line(x - s, y - s, x + s, y + s, stroke=th.ink, **{"stroke-width": st.sw + 0.3}))
pr.append(_line(x - s, y + s, x + s, y - s, stroke=th.ink, **{"stroke-width": st.sw + 0.3}))
def _connector(pr, x, ys, st, th):
pr.append(_line(x, min(ys), x, max(ys), stroke=th.ink, **{"stroke-width": st.sw + 0.1}))
def _family(op: DrawOp) -> str:
if op.op == "CHANNEL":
return "channel"
if op.op == "QRAM":
return "qram"
if op.op == "DEF":
return "def"
if op.op == "COP":
return "cop"
if op.op == "MEASURE":
return "measure"
if op.op == "RESET":
return "reset"
if op.op == "BARRIER" or op.op in FLOW_OPS:
return "meta"
if op.params:
return "grot"
if len(op.qubits) > 1 or op.controls:
return "g2"
return "g1"
def _draw_gate_box(pr, cx, cy_top, cy_bot, op, st: Style, th, opts: RenderOptions, *, fam=None, hatch_id=""):
name, params = label_lines(op, opts.param_mode)
look = st.look(th, fam or _family(op))
w = (
max(
estimate_text_width(name, st.fs),
estimate_text_width(params, st.pfs) if params else 0.0,
)
+ st.pad
)
x0 = cx - w / 2
fill = look.fill
if fill == "hatch":
fill = f"url(#{hatch_id})"
single = cy_bot == cy_top
text_h = (st.fs + st.pfs + 6) if params else (st.fs + 8)
half_h = max(st.box_h / 2, text_h / 2 + 4)
y0 = cy_top - half_h if single else min(cy_top - st.box_h * 0.5, (cy_top + cy_bot) / 2 - text_h / 2 - 4)
y1 = cy_top + half_h if single else max(cy_bot + st.box_h * 0.5, (cy_top + cy_bot) / 2 + text_h / 2 + 4)
pr.append(
_rect(x0, y0, w, y1 - y0, fill=fill, stroke=look.stroke, rx=st.rx, **{"stroke-width": st.sw, "class": "gbox"})
)
yc = cy_top if single else (cy_top + cy_bot) / 2
ny = yc - st.fs * 0.42 if params else yc
pr.append(
_text(
cx,
ny,
name,
fs=st.fs,
fill=look.ink,
**{"font-family": st.font, "dominant-baseline": "central", "class": "gtext"},
)
)
if params:
pr.append(
_text(
cx,
yc + st.pfs * 0.9,
params,
fs=st.pfs,
fill=look.ink,
opacity=0.92,
**{"font-family": st.font, "dominant-baseline": "central", "class": "gtext"},
)
)
def _draw_op(pr, op: DrawOp, x, geom, st: Style, th, opts: RenderOptions):
yq, yc = geom["yq"], geom["yc"]
kind = classify(op)
pr.append(_gbegin(**{"class": "op", "data-i": op.index, "tip": tooltip(op)}))
ys = [yq(q) for q in op.qubits]
y_all = ys + [yq(q) for q in op.controls]
if kind in ("box1", "span"):
if ys:
_draw_gate_box(pr, x, min(ys), max(ys), op, st, th, opts, hatch_id=geom["hatch_id"])
elif kind in ("cx", "mcx"):
ctrls = [op.qubits[0]] if kind == "cx" else list(op.controls)
targ = op.qubits[1] if kind == "cx" else op.qubits[0]
_connector(pr, x, [yq(targ)] + [yq(q) for q in ctrls], st, th)
for q in ctrls:
_ctrl_dot(pr, x, yq(q), st, th)
_target_circ(pr, x, yq(targ), st, th)
elif kind == "ccx":
_connector(pr, x, ys, st, th)
_ctrl_dot(pr, x, yq(op.qubits[0]), st, th)
_ctrl_dot(pr, x, yq(op.qubits[1]), st, th)
_target_circ(pr, x, yq(op.qubits[2]), st, th)
elif kind == "cz":
_connector(pr, x, ys, st, th)
_ctrl_dot(pr, x, yq(op.qubits[0]), st, th)
_ctrl_dot(pr, x, yq(op.qubits[1]), st, th)
ps = param_strings(op, opts.param_mode)
if ps:
pr.append(_text(x, min(ys) - 8, ",".join(ps), fs=st.pfs, fill=th.dim, **{"font-family": st.font}))
elif kind == "cswap":
_connector(pr, x, ys, st, th)
_ctrl_dot(pr, x, yq(op.qubits[0]), st, th)
_swap_cross(pr, x, yq(op.qubits[1]), st, th)
_swap_cross(pr, x, yq(op.qubits[2]), st, th)
elif kind == "swap":
_connector(pr, x, ys, st, th)
_swap_cross(pr, x, yq(op.qubits[0]), st, th)
_swap_cross(pr, x, yq(op.qubits[1]), st, th)
elif kind == "cbox":
_connector(pr, x, y_all, st, th)
for q in op.controls:
_ctrl_dot(pr, x, yq(q), st, th)
_draw_gate_box(pr, x, min(ys), max(ys), op, st, th, opts, hatch_id=geom["hatch_id"])
elif kind == "measure":
y = yq(op.qubits[0])
look = st.look(th, "measure")
bw, bh = max(30.0, st.box_h * 0.9), st.box_h
pr.append(
_rect(
x - bw / 2,
y - bh / 2,
bw,
bh,
fill=look.fill,
stroke=look.stroke,
rx=st.rx,
**{"stroke-width": st.sw, "class": "gbox"},
)
)
gy, gr = y + bh * 0.22, bh * 0.26
pr.append(_arc(x, gy, gr, 180, 360, stroke=look.ink, fill="none", **{"stroke-width": st.sw}))
pr.append(_line(x - gr - 3, gy, x + gr + 3, gy, stroke=look.ink, **{"stroke-width": st.sw * 0.8}))
na = -52 * 3.141592653589793 / 180
pr.append(
_line(
x,
gy,
x + gr * 0.92 * math.cos(na),
gy + gr * 0.92 * math.sin(na),
stroke=look.ink,
**{"stroke-width": st.sw},
)
)
cj = op.cbits[0] if op.cbits else None
if opts.show_clbits and cj is not None and geom["c_exists"](cj):
ycj = yc(cj)
pr.append(
_line(x, y + bh / 2, x, ycj - 10, stroke=th.dim, **{"stroke-width": 1.2, "stroke-dasharray": "4 3"})
)
pr.append(_tri([(x - 3.5, ycj - 10), (x + 3.5, ycj - 10), (x, ycj - 4)], fill=th.dim))
elif kind == "reset":
_draw_gate_box(
pr, x, yq(op.qubits[0]), yq(op.qubits[0]), DrawOp(op="|0⟩", qubits=op.qubits), st, th, opts, fam="reset"
)
elif kind == "barrier":
mn, mx = min(ys) - 13, max(ys) + 13
pr.append(_line(x, mn, x, mx, stroke=th.dim, **{"stroke-width": 2, "stroke-dasharray": "6 4"}))
for y in ys:
pr.append(_rect(x - 2.6, y - 2.6, 5.2, 5.2, fill=th.dim))
elif kind == "channel":
_draw_gate_box(pr, x, min(ys), max(ys), op, st, th, opts, fam="channel", hatch_id=geom["hatch_id"])
elif kind == "qram":
y_top, y_bot = min(ys) - 14, max(ys) + 14
w = max(74.0, estimate_text_width(f"QRAM {op.name}", st.fs) + 20)
x0 = x - w / 2
look = st.look(th, "qram")
fill = look.fill if look.fill != "hatch" else f"url(#{geom['hatch_id']})"
pr.append(
_rect(
x0,
y_top,
w,
y_bot - y_top,
fill=fill,
stroke=look.stroke,
rx=st.rx or 3,
**{"stroke-width": st.sw + 0.3, "class": "gbox"},
)
)
n_addr = op.addr or len(op.qubits) // 2
y_div = (yq(op.qubits[n_addr - 1]) + yq(op.qubits[n_addr])) / 2
pr.append(
_line(x0, y_div, x0 + w, y_div, stroke=look.stroke, **{"stroke-width": 1.1, "stroke-dasharray": "4 3"})
)
y_mid = (y_top + y_bot) / 2
pr.append(
_text(
x,
y_mid - 8,
"QRAM",
fs=st.fs,
fill=look.ink,
**{"font-family": st.font, "dominant-baseline": "central"},
)
)
pr.append(
_text(
x,
y_mid + 9,
op.name or "",
fs=st.pfs,
fill=look.ink,
opacity=0.85,
**{"font-family": st.font, "dominant-baseline": "central"},
)
)
pr.append(
_text(
x0 + 5,
yq(op.qubits[0]),
"addr",
fs=8.5,
fill=th.dim,
anchor="start",
**{"font-family": st.font, "dominant-baseline": "central"},
)
)
pr.append(
_text(
x0 + 5,
yq(op.qubits[-1]),
"data",
fs=8.5,
fill=th.dim,
anchor="start",
**{"font-family": st.font, "dominant-baseline": "central"},
)
)
elif kind == "def":
y_mid0 = (min(ys) + max(ys)) / 2
y_top, y_bot = min(min(ys) - 16, y_mid0 - 26), max(max(ys) + 16, y_mid0 + 26)
w = max(62.0, estimate_text_width(f"DEF {op.name}", st.fs) + 22)
x0 = x - w / 2
look = st.look(th, "def")
pr.append(
_rect(
x0,
y_top,
w,
y_bot - y_top,
fill=look.fill,
stroke=look.stroke,
rx=st.rx or 2,
**{"stroke-width": st.sw, "class": "gbox"},
)
)
pr.append(
_rect(
x0 + 3,
y_top + 3,
w - 6,
y_bot - y_top - 6,
fill="none",
stroke=look.stroke,
rx=max(0, (st.rx or 2) - 2),
**{"stroke-width": 0.9},
)
)
y_mid = (y_top + y_bot) / 2
pr.append(
_text(
x,
y_mid - 6,
f"DEF {op.name}",
fs=st.fs,
fill=look.ink,
**{"font-family": st.font, "dominant-baseline": "central"},
)
)
if op.note:
pr.append(
_text(
x,
y_mid + 9,
op.note,
fs=st.pfs - 1,
fill=th.dim,
**{"font-family": st.font, "dominant-baseline": "central"},
)
)
elif kind == "cop":
y = yc(op.cbits[0])
lbl = cop_label(op)
w = estimate_text_width(lbl, 11) + 16
look = st.look(th, "cop")
pr.append(
_rect(
x - w / 2,
y - 13,
w,
26,
fill=look.fill,
stroke=look.stroke,
rx=4,
**{"stroke-width": 1.2, "class": "gbox"},
)
)
pr.append(_text(x, y, lbl, fs=11, fill=th.ink, **{"font-family": st.font, "dominant-baseline": "central"}))
# "flow" markers are drawn as brackets in build_primitives
pr.append(_GEND)
# ---------------------------------------------------------------------------
# Primitive-level scene construction (shared by svg + mpl)
# ---------------------------------------------------------------------------
[docs]
def build_primitives(draw: DrawCircuit, opts: RenderOptions) -> tuple[list, float, float, RenderMeta, Style]:
"""Lay out *draw* and emit drawing primitives; returns (prims, W, H, meta, style)."""
st = get_style(opts.style)
th = st.theme(opts.theme)
layout = layout_circuit(draw, st, opts.param_mode, opts.fold, mode=opts.mode or "svg")
# Decision ⑨: classical wires hidden by default; but a circuit with
# classical instructions (COP) would lose content — auto-enable then.
need_clanes = opts.show_clbits or any(op.op == "COP" for op in draw.ops)
n_q, n_c = draw.n_qubits, draw.n_cbits if need_clanes else 0
asc = opts.qubit_order != "desc"
label_w, top = 54.0, 44.0
# Deterministic per-render id: pattern contents are identical everywhere,
# so a fixed id is safe even when several SVGs share one HTML page.
hatch_id = "hp0"
def yq(i):
return top + (i if asc else n_q - 1 - i) * st.wire_gap
def yc(j):
return top + n_q * st.wire_gap + 22 + j * 28
geom = {"yq": yq, "yc": yc, "c_exists": lambda j: j < n_c, "hatch_id": hatch_id}
prims: list[dict] = []
width = 0.0
for bi, (c_start, c_end) in enumerate(layout.bands):
bx0 = 12 + label_w
sum_w = sum(layout.col_widths[c_start : c_end + 1])
band_w = bx0 + sum_w + 18
width = max(width, band_w)
def col_x(c, _s=c_start, _bx0=bx0):
return _bx0 + sum(layout.col_widths[_s:c]) + layout.col_widths[c] / 2
y_bot_q = top + (n_q - 1) * st.wire_gap
band_h = (yc(n_c - 1) + 16) if n_c else (y_bot_q + 18)
for q in range(n_q):
y = yq(q)
prims.append(
_text(
10,
y,
f"q[{q}]",
fs=12,
fill=th.dim,
anchor="start",
**{"font-family": st.font, "dominant-baseline": "central"},
)
)
prims.append(_line(bx0 - 10, y, band_w - 10, y, stroke=th.wire, **{"stroke-width": st.wire_w}))
for c in range(n_c):
y = yc(c)
prims.append(
_text(
10,
y,
f"c[{c}]",
fs=11,
fill=th.dim,
anchor="start",
**{"font-family": st.font, "dominant-baseline": "central"},
)
)
prims.append(_line(bx0 - 10, y - 2.4, band_w - 10, y - 2.4, stroke=th.clane, **{"stroke-width": 1.2}))
prims.append(_line(bx0 - 10, y + 2.4, band_w - 10, y + 2.4, stroke=th.clane, **{"stroke-width": 1.2}))
if bi > 0:
prims.append(
_text(
bx0,
14,
f"↳ 续 {bi + 1}/{len(layout.bands)}(折叠自上一行末尾)",
fs=10.5,
fill=th.hint,
anchor="start",
**{"font-family": st.font},
)
)
for sp in layout.spans:
c1, c2 = max(sp.col_begin, c_start), min(sp.col_end, c_end)
if c1 > c2:
continue
x1, x2, y_b = col_x(c1) - 6, col_x(c2) + 6, top - 16
if opts.style == "modern":
prims.append(_rect(x1, top - 8, x2 - x1, y_bot_q - top + 16, fill=th.bracket, opacity=0.05))
prims.append(_line(x1, y_b, x2, y_b, stroke=th.bracket, **{"stroke-width": 1.4}))
prims.append(_line(x1, y_b, x1, y_b + 6, stroke=th.bracket, **{"stroke-width": 1.4}))
prims.append(_line(x2, y_b, x2, y_b + 6, stroke=th.bracket, **{"stroke-width": 1.4}))
prims.append(
_text(
(x1 + x2) / 2, y_b - 4, f"{sp.kind} {sp.cond}", fs=10.5, fill=th.bracket, **{"font-family": st.font}
)
)
for op in layout.ops:
if op.op == "QELSE" and c_start <= op.col <= c_end:
prims.append(
_text(
col_x(op.col),
top - 2,
"else",
fs=9.5,
fill=th.dim,
**{"font-family": st.font, "font-style": "italic", "dominant-baseline": "central"},
)
)
for op in layout.ops:
if c_start <= op.col <= c_end:
_draw_op(prims, op, col_x(op.col), geom, st, th, opts)
if bi < len(layout.bands) - 1:
prims.append({"t": "_shift", "h": band_h + 34})
shift = 0.0
for p in prims:
if p["t"] == "_shift":
shift += p["h"]
continue
for k in ("y", "y1", "y2", "cy"):
if k in p:
p[k] += shift
if "pts" in p:
p["pts"] = [(a, b + shift) for a, b in p["pts"]]
total_h = (yc(n_c - 1) + 16 if n_c else top + (n_q - 1) * st.wire_gap + 18) + shift + 6
height = max(total_h, 60.0)
meta = RenderMeta(style=opts.style, n_cols=layout.n_cols, bands=len(layout.bands), hatch_id=hatch_id)
return [p for p in prims if p["t"] != "_shift"], width, height, meta, st
# ---------------------------------------------------------------------------
# SVG serialization
# ---------------------------------------------------------------------------
_ATTR_KEYS = ("fill", "stroke", "stroke-width", "stroke-dasharray", "stroke-linecap", "opacity", "class", "rx")
def _attrs(p: dict, keys) -> str:
out = ""
for k in keys:
v = p.get(k)
if v is None:
continue
if isinstance(v, float):
v = round(v, 2)
out += f' class="{v}"' if k == "class" else f' {k}="{_html.escape(str(v), quote=True)}"'
return out
[docs]
def serialize_svg(
prims: list[dict], width: float, height: float, opts: RenderOptions, *, defs: str = "", css: str = ""
) -> str:
"""Serialize primitives to SVG text; transposes axes for vertical mode."""
vert = opts.vertical
sc = (opts.scale or 100) / 100
vpad = 18 if vert else 0
def mp(x, y):
return (y * sc, x * sc + vpad) if vert else (x * sc, y * sc)
def num(v):
return round(v, 2)
out_w = num((height if vert else width) * sc)
out_h = num((width if vert else height) * sc + vpad)
parts = [
f'<svg class="uniqc-viz" xmlns="http://www.w3.org/2000/svg" width="{out_w}" height="{out_h}" '
f'viewBox="0 0 {out_w} {out_h}" role="img">'
]
if defs:
parts.append(f"<defs>{defs}</defs>")
if css:
parts.append(f"<style>{css}</style>")
for p in prims:
t = p["t"]
if t == "g":
extra = f' data-i="{p["data-i"]}"' if p.get("data-i") is not None else ""
parts.append(f'<g class="{p.get("class", "")}"{extra}>')
if p.get("tip"):
parts.append(f"<title>{_html.escape(str(p['tip']))}</title>")
elif t == "/g":
parts.append("</g>")
elif t == "line":
x1, y1 = mp(p["x1"], p["y1"])
x2, y2 = mp(p["x2"], p["y2"])
parts.append(f'<line x1="{num(x1)}" y1="{num(y1)}" x2="{num(x2)}" y2="{num(y2)}"{_attrs(p, _ATTR_KEYS)}/>')
elif t == "rect":
x, y = mp(p["x"], p["y"])
w = (p["h"] if vert else p["w"]) * sc
h = (p["w"] if vert else p["h"]) * sc
parts.append(
f'<rect x="{num(x)}" y="{num(y)}" width="{num(max(0, w))}" height="{num(max(0, h))}"{_attrs(p, _ATTR_KEYS)}/>'
)
elif t == "circle":
cx, cy = mp(p["cx"], p["cy"])
parts.append(f'<circle cx="{num(cx)}" cy="{num(cy)}" r="{num(p["r"] * sc)}"{_attrs(p, _ATTR_KEYS)}/>')
elif t == "text":
x, y = mp(p["x"], p["y"])
rot = f' transform="rotate(-90,{num(x)},{num(y)})"' if vert else ""
anchor = p.get("anchor", "middle")
parts.append(
f'<text x="{num(x)}" y="{num(y)}" font-size="{num(p.get("fs", 12) * sc)}"'
f"{_attrs(p, ('fill', 'font-family', 'font-style', 'font-weight', 'opacity', 'class', 'dominant-baseline'))}"
f' text-anchor="{anchor}"{rot}>{_html.escape(p["txt"])}</text>'
)
elif t == "tri":
pts = " ".join(f"{num(a)},{num(b)}" for a, b in (mp(*pt) for pt in p["pts"]))
parts.append(f'<polygon points="{pts}"{_attrs(p, _ATTR_KEYS)}/>')
elif t == "arc":
def pt(a, _p=p):
return mp(
_p["cx"] + _p["r"] * math.cos(a * math.pi / 180), _p["cy"] + _p["r"] * math.sin(a * math.pi / 180)
)
(x1, y1), (x2, y2) = pt(p["a1"]), pt(p["a2"])
r = p["r"] * sc
large = 1 if abs(p["a2"] - p["a1"]) > 180 else 0
sweep = 0 if vert else 1
parts.append(
f'<path d="M {num(x1)} {num(y1)} A {num(r)} {num(r)} 0 {large} {sweep} {num(x2)} {num(y2)}"'
f"{_attrs(p, _ATTR_KEYS)}/>"
)
parts.append("</svg>")
return "\n".join(parts)
[docs]
def render_svg(draw: DrawCircuit, opts: RenderOptions) -> tuple[str, RenderMeta]:
"""Render a DrawCircuit to SVG text; returns (svg, meta)."""
prims, width, height, meta, st = build_primitives(draw, opts)
hatch_id = meta["hatch_id"]
defs = (
f'<pattern id="{hatch_id}" width="6" height="6" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">'
'<line x1="0" y1="0" x2="0" y2="6" stroke="#888" stroke-width="1.1"/></pattern>'
)
css = (
".op{cursor:pointer}"
".op:hover .gbox{stroke-width:2.6}"
+ (".op:hover .gbox{filter:drop-shadow(0 0 4px rgba(99,102,241,.65))}" if opts.style == "modern" else "")
+ ".op:hover .gtext{font-weight:600}"
)
return serialize_svg(prims, width, height, opts, defs=defs, css=css), meta