"""quantikz LaTeX export for circuit rendering.
Emits source for the ``quantikz`` package (tikzlibrary ``quantikz2``), the
de-facto standard for quantum-circuit figures in papers. Constructs that
quantikz cannot express (classical instructions, QIF/QWHILE control flow,
channel semantics) degrade to comments plus placeholder gates — the output
always compiles.
Verified syntax (TeX Live quantikz2): ``\\gate[wires=N,style={...}]{...}``,
``\\ctrl{Δ}`` / ``\\targ{}`` / ``\\control{}``, ``\\swap{Δ}`` / ``\\targX{}``,
``\\meter{}``, ``\\slice{}`` for barriers.
"""
from __future__ import annotations
from .labels import classify, cop_label, to_pi
from .layout import layout_circuit
from .model import DrawCircuit
from .options import RenderOptions
from .styles import get_style
__all__ = ["render_latex"]
_TEX_SYM = {"α₂": r"\alpha_2", "θ": r"\theta", "φ": r"\phi", "π": r"\pi", "α": r"\alpha", "₂": "_2", "†": r"^\dag"}
_TEX_NAME = {
"RX": "R_X",
"RY": "R_Y",
"RZ": "R_Z",
"U1": "U_1",
"U2": "U_2",
"U3": "U_3",
"RPhi": r"R_\Phi",
"RPhi90": r"R_{\Phi 90}",
"RPhi180": r"R_{\Phi 180}",
}
def _tex_params(op, mode: str) -> list[str]:
"""Raw params -> LaTeX strings (numbers become \\frac{\\pi}{2} when close)."""
if mode == "hidden" or not op.params:
return []
from .labels import fmt_num
from .model import display_param
out = []
for p in op.params:
p = display_param(p)
if isinstance(p, (int, float)):
pi = to_pi(p) if mode == "pi" else None
if pi is not None:
import re
m = re.match(r"^(-?)(\d*)π/(\d+)$", pi)
if m:
out.append(f"{m.group(1)}\\frac{{{m.group(2)}\\pi}}{{{m.group(3)}}}")
else:
out.append(pi.replace("π", r"\pi"))
else:
out.append(fmt_num(p))
else:
s = str(p)
for src, dst in _TEX_SYM.items():
s = s.replace(src, dst)
out.append(s)
return out
def _tex_name(op) -> str:
base = _TEX_NAME.get(op.op, op.op)
return base + (r"^{\dag}" if op.dagger else "")
[docs]
def render_latex(draw: DrawCircuit, opts: RenderOptions) -> str:
"""Render a circuit as quantikz LaTeX source (compilable standalone block)."""
st = get_style(opts.style) # style affects geometry defaults only
lay = layout_circuit(draw, st, "hidden", 0, mode="latex")
n_q = draw.n_qubits
comments: list[str] = []
if draw.params:
comments.append("% PARAM " + ", ".join(draw.params))
grid = [[None] * lay.n_cols for _ in range(n_q)]
skip = [[False] * lay.n_cols for _ in range(n_q)]
def put(q, c, tok):
if 0 <= q < n_q and 0 <= c < lay.n_cols:
grid[q][c] = tok
for op in lay.ops:
c, kind = op.col, classify(op)
qs = list(op.qubits)
if kind == "box1":
ps = _tex_params(op, opts.param_mode)
inner = _tex_name(op) + (f"({','.join(ps)})" if ps else "")
put(qs[0], c, rf"\gate{{{inner}}}")
elif kind in ("span", "channel"):
ps = _tex_params(op, opts.param_mode)
nm = op.name if kind == "channel" else _tex_name(op)
inner = nm + ("(" + (rf"\times {len(ps)}" if len(ps) > 6 else ",".join(ps)) + ")") if ps else nm
n, q0 = len(qs), min(qs)
style = ",style={dashed}" if kind == "channel" else ""
put(q0, c, rf"\gate[wires={n}{style}]{{{inner}}}")
for k in range(1, n):
skip[qs[k]][c] = True
elif kind in ("cx", "mcx"):
ctrls = [qs[0]] if kind == "cx" else list(op.controls)
targ = qs[1] if kind == "cx" else qs[0]
for q in ctrls:
put(q, c, rf"\ctrl{{{targ - q}}}")
put(targ, c, r"\targ{}")
elif kind == "ccx":
put(qs[0], c, rf"\ctrl{{{qs[2] - qs[0]}}}")
put(qs[1], c, rf"\ctrl{{{qs[2] - qs[1]}}}")
put(qs[2], c, r"\targ{}")
elif kind == "cz":
put(qs[0], c, rf"\ctrl{{{qs[1] - qs[0]}}}")
put(qs[1], c, r"\control{}")
ps = _tex_params(op, opts.param_mode)
if ps:
comments.append(f"% {op.op} params: {', '.join(ps)} (controlled-phase angle)")
elif kind == "swap":
put(qs[0], c, rf"\swap{{{qs[1] - qs[0]}}}")
put(qs[1], c, r"\targX{}")
elif kind == "cswap":
put(qs[0], c, rf"\ctrl{{{qs[1] - qs[0]}}}")
put(qs[1], c, rf"\swap{{{qs[2] - qs[1]}}}")
put(qs[2], c, r"\targX{}")
elif kind == "cbox":
ps = _tex_params(op, opts.param_mode)
for q in op.controls:
put(q, c, rf"\ctrl{{{qs[0] - q}}}")
inner = _tex_name(op) + (f"({','.join(ps)})" if ps else "")
put(qs[0], c, rf"\gate{{{inner}}}")
elif kind == "measure":
put(qs[0], c, r"\meter{}")
cj = op.cbits[0] if op.cbits else None
if cj is not None:
comments.append(f"% MEASURE q[{qs[0]}] -> c[{cj}] (classical wire omitted in quantikz)")
elif kind == "reset":
put(qs[0], c, r"\gate{|0\rangle} % RESET")
elif kind == "barrier":
put(min(qs), c, r"\slice{}")
comments.append(f"% BARRIER on q[{'],q['.join(map(str, qs))}] rendered as a full-height \\slice{{}}")
elif kind == "qram":
put(qs[0], c, rf"\gate[wires={len(qs)}]{{\text{{QRAM }}{op.name}}}")
for k in range(1, len(qs)):
skip[qs[k]][c] = True
comments.append(
f"% QRAM {op.name}: addr q[{'],q['.join(map(str, qs[: op.addr or 2]))}]"
f" / data q[{'],q['.join(map(str, qs[op.addr or 2 :]))}]"
)
elif kind == "def":
put(qs[0], c, rf"\gate[wires={len(qs)},style={{double}}]{{\text{{DEF: }}{op.name}}}")
for k in range(1, len(qs)):
skip[qs[k]][c] = True
elif kind == "cop":
comments.append(f"% classical: {cop_label(op)} (no classical op support in quantikz)")
elif kind == "flow":
comments.append(f"% {op.op} {op.cond or ''} (OriginIR-ext control flow; not expressible in quantikz)")
lines = list(comments)
lines.append(r"\begin{quantikz}[column sep=0.9em, row sep=0.5em]")
for q in range(n_q):
row = f" q[{q}]"
for c in range(lay.n_cols):
if skip[q][c]:
continue
row += " & " + (grid[q][c] or r"\qw")
row += r" \\"
lines.append(row)
lines.append(r"\end{quantikz}")
return "\n".join(lines)