From a15acb0a16ed3e48d96199d545c816336bbe7e58 Mon Sep 17 00:00:00 2001 From: Zlatko Minev Date: Fri, 16 May 2025 23:14:43 -0700 Subject: [PATCH 1/9] feat: Add new Quantikz rendering modules and enhance existing functionality --- cirq-core/cirq/vis/__init__.py | 3 + .../cirq/vis/circuit_to_latex_quantikz.py | 710 ++++++++++++++++++ cirq-core/cirq/vis/circuit_to_latex_render.py | 541 +++++++++++++ 3 files changed, 1254 insertions(+) create mode 100644 cirq-core/cirq/vis/circuit_to_latex_quantikz.py create mode 100644 cirq-core/cirq/vis/circuit_to_latex_render.py diff --git a/cirq-core/cirq/vis/__init__.py b/cirq-core/cirq/vis/__init__.py index 69c47c19cc3..88eb1f4244c 100644 --- a/cirq-core/cirq/vis/__init__.py +++ b/cirq-core/cirq/vis/__init__.py @@ -28,3 +28,6 @@ from cirq.vis.density_matrix import plot_density_matrix as plot_density_matrix from cirq.vis.vis_utils import relative_luminance as relative_luminance + +from cirq.vis.circuit_to_latex_quantikz import CirqToQuantikz +from cirq.vis.circuit_to_latex_render import render_circuit diff --git a/cirq-core/cirq/vis/circuit_to_latex_quantikz.py b/cirq-core/cirq/vis/circuit_to_latex_quantikz.py new file mode 100644 index 00000000000..26e7ef62e8d --- /dev/null +++ b/cirq-core/cirq/vis/circuit_to_latex_quantikz.py @@ -0,0 +1,710 @@ +# -*- coding: utf-8 -*- +r"""Converts Cirq circuits to Quantikz LaTeX (using modern quantikz syntax). + +This module provides a class, `CirqToQuantikz`, to translate `cirq.Circuit` +objects into LaTeX code using the `quantikz` package. It aims to offer +flexible customization for gate styles, wire labels, and circuit folding. + +Example: + >>> import cirq + >>> from cirq.vis.circuit_to_latex_quantikz import CirqToQuantikz + >>> q0, q1 = cirq.LineQubit.range(2) + >>> circuit = cirq.Circuit( + ... cirq.H(q0), + ... cirq.CNOT(q0, q1), + ... cirq.measure(q0, key='m0'), + ... cirq.Rx(rads=0.5).on(q1) + ... ) + >>> converter = CirqToQuantikz(circuit, fold_at=2) + >>> latex_code = converter.generate_latex_document() + >>> print(latex_code) # doctest: +SKIP + \documentclass[preview, border=2pt]{standalone} + % Core drawing packages + \usepackage{tikz} + \usetikzlibrary{quantikz} % Loads the quantikz library (latest installed version) + % Optional useful TikZ libraries + \usetikzlibrary{fit, arrows.meta, decorations.pathreplacing, calligraphy} + % Font encoding and common math packages + \usepackage[T1]{fontenc} + \usepackage{amsmath} + \usepackage{amsfonts} + \usepackage{amssymb} + % \usepackage{physics} % Removed + % --- Custom Preamble Injection Point --- + % --- End Custom Preamble --- + \begin{document} + \begin{quantikz} + \lstick{$q_{0}$} & \gate[style={fill=yellow!20}]{H} & \ctrl{1} & \meter{$m0$} \\ + \lstick{$q_{1}$} & \qw & \targ{} & \qw + \end{quantikz} + + \vspace{1em} + + \begin{quantikz} + \lstick{$q_{0}$} & \qw & \rstick{$q_{0}$} \\ + \lstick{$q_{1}$} & \gate[style={fill=green!20}]{R_{X}(0.5)} & \rstick{$q_{1}$} + \end{quantikz} + \end{document} +""" +# mypy: ignore-errors + + +import math +import warnings +from pathlib import Path +from typing import Any, Dict, List, Optional, Type + +import cirq +import numpy as np +import sympy + +__all__ = ["CirqToQuantikz", "DEFAULT_PREAMBLE_TEMPLATE", "GATE_STYLES_COLORFUL1"] + + +# ============================================================================= +# Default Preamble Template (physics.sty removed) +# ============================================================================= +DEFAULT_PREAMBLE_TEMPLATE = r""" +\documentclass[preview, border=2pt]{standalone} +% Core drawing packages +\usepackage{tikz} +\usetikzlibrary{quantikz} % Loads the quantikz library (latest installed version) +% Optional useful TikZ libraries +\usetikzlibrary{fit, arrows.meta, decorations.pathreplacing, calligraphy} +% Font encoding and common math packages +\usepackage[T1]{fontenc} +\usepackage{amsmath} +\usepackage{amsfonts} +\usepackage{amssymb} +% \usepackage{physics} % Removed +""" + +# ============================================================================= +# Default Style Definitions +# ============================================================================= +_Pauli_gate_style = r"style={fill=blue!20}" +_green_gate_style = r"style={fill=green!20}" +_yellow_gate_style = r"style={fill=yellow!20}" # For H +_orange_gate_style = r"style={fill=orange!20}" # For FSim, ISwap, etc. +_gray_gate_style = r"style={fill=gray!20}" # For Measure +_noisy_channel_style = r"style={fill=red!20}" + +GATE_STYLES_COLORFUL1 = { + "H": _yellow_gate_style, + "_PauliX": _Pauli_gate_style, # cirq.X(q_param) + "_PauliY": _Pauli_gate_style, # cirq.Y(q_param) + "_PauliZ": _Pauli_gate_style, # cirq.Z(q_param) + "X": _Pauli_gate_style, # For XPowGate(exponent=1) + "Y": _Pauli_gate_style, # For YPowGate(exponent=1) + "Z": _Pauli_gate_style, # For ZPowGate(exponent=1) + "X_pow": _green_gate_style, # For XPowGate(exponent!=1) + "Y_pow": _green_gate_style, # For YPowGate(exponent!=1) + "Z_pow": _green_gate_style, # For ZPowGate(exponent!=1) + "H_pow": _green_gate_style, # For HPowGate(exponent!=1) + "Rx": _green_gate_style, + "Ry": _green_gate_style, + "Rz": _green_gate_style, + "PhasedXZ": _green_gate_style, + "FSimGate": _orange_gate_style, + "FSim": _orange_gate_style, # Alias for FSimGate + "ISwap": _orange_gate_style, # For ISwapPowGate(exponent=1) + "iSWAP_pow": _orange_gate_style, # For ISwapPowGate(exponent!=1) + "CZ_pow": _orange_gate_style, # For CZPowGate(exponent!=1) + "CX_pow": _orange_gate_style, # For CNotPowGate(exponent!=1) + "CXideal": "", # No fill for \ctrl \targ, let quantikz draw default + "CZideal": "", # No fill for \ctrl \control + "Swapideal": "", # No fill for \swap \targX + "Measure": _gray_gate_style, + "DepolarizingChannel": _noisy_channel_style, + "BitFlipChannel": _noisy_channel_style, + "ThermalChannel": _noisy_channel_style, +} + + +# ============================================================================= +# Cirq to Quantikz Conversion Class +# ============================================================================= +class CirqToQuantikz: + r"""Converts a Cirq Circuit object to a Quantikz LaTeX string. + + This class facilitates the conversion of a `cirq.Circuit` into a LaTeX + representation using the `quantikz` package. It handles various gate types, + qubit mapping, and provides options for customizing the output, such as + gate styling, circuit folding, and parameter display. + + Args: + circuit: The `cirq.Circuit` object to be converted. + gate_styles: An optional dictionary mapping gate names (strings) to + Quantikz style options (strings). These styles are applied to + the generated gates. If `None`, `GATE_STYLES_COLORFUL1` is used. + quantikz_options: An optional string of global options to pass to the + `quantikz` environment (e.g., `"[row sep=0.5em]"`). + fold_at: An optional integer specifying the number of moments after + which the circuit should be folded into a new line in the LaTeX + output. If `None`, the circuit is not folded. + custom_preamble: An optional string containing custom LaTeX code to be + inserted into the document's preamble. + custom_postamble: An optional string containing custom LaTeX code to be + inserted just before `\end{document}`. + wire_labels: A string specifying how qubit wire labels should be + rendered. + - `"q"`: Labels as $q_0, q_1, \dots$ + - `"index"`: Labels as $0, 1, \dots$ + - `"qid"`: Labels as the string representation of the `cirq.Qid` + - Any other value defaults to `"q"`. + show_parameters: A boolean indicating whether gate parameters (e.g., + exponents for `XPowGate`, angles for `Rx`) should be displayed + in the gate labels. + gate_name_map: An optional dictionary mapping Cirq gate names (strings) + to custom LaTeX strings for rendering. This allows renaming gates + in the output. + float_precision_exps: An integer specifying the number of decimal + places for formatting floating-point exponents. + float_precision_angles: An integer specifying the number of decimal + places for formatting floating-point angles. (Note: Not fully + implemented in current version for all angle types). + + Raises: + ValueError: If the input `circuit` is empty or contains no qubits. + """ + + GATE_NAME_MAP = { + "Rx": r"R_{X}", + "Ry": r"R_{Y}", + "Rz": r"R_{Z}", + "FSim": r"\mathrm{fSim}", + "PhasedXZ": r"\Phi", + "CZ": r"\mathrm{CZ}", + "CX": r"\mathrm{CX}", + "iSwap": r"i\mathrm{SWAP}", + } + + def __init__( + self, + circuit: "cirq.Circuit", + *, + gate_styles: Optional[Dict[str, str]] = None, + quantikz_options: Optional[str] = None, + fold_at: Optional[int] = None, + custom_preamble: str = "", + custom_postamble: str = "", + wire_labels: str = "q", + show_parameters: bool = True, + gate_name_map: Optional[Dict[str, str]] = None, + float_precision_exps: int = 2, + float_precision_angles: int = 2, + ): + if not circuit: + raise ValueError("Input circuit cannot be empty.") + self.circuit = circuit + self.gate_styles = gate_styles if gate_styles is not None else GATE_STYLES_COLORFUL1.copy() + self.quantikz_options = quantikz_options or "" + self.fold_at = fold_at + self.custom_preamble = custom_preamble + self.custom_postamble = custom_postamble + self.wire_labels = wire_labels + self.show_parameters = show_parameters + self.current_gate_name_map = self.GATE_NAME_MAP.copy() + if gate_name_map: + self.current_gate_name_map.update(gate_name_map) + self.sorted_qubits = self._get_sorted_qubits() + if not self.sorted_qubits: + raise ValueError("Circuit contains no qubits.") + self.qubit_to_index = self._map_qubits_to_indices() + self.num_qubits = len(self.sorted_qubits) + self.float_precision_exps = float_precision_exps + self.float_precision_angles = float_precision_angles + + # Initialize gate maps here to avoid circular import issues + self._SIMPLE_GATE_MAP: Dict[Type["cirq.Gate"], str] = {cirq.MeasurementGate: "Measure"} + self._EXPONENT_GATE_MAP: Dict[Type["cirq.Gate"], str] = { + cirq.XPowGate: "X", + cirq.YPowGate: "Y", + cirq.ZPowGate: "Z", + cirq.HPowGate: "H", + cirq.CNotPowGate: "CX", + cirq.CZPowGate: "CZ", + cirq.SwapPowGate: "Swap", + cirq.ISwapPowGate: "iSwap", + } + self._PARAMETERIZED_GATE_BASE_NAMES: Dict[Type["cirq.Gate"], str] = {} + _param_gate_specs = [ + ("Rx", getattr(cirq, "Rx", None)), + ("Ry", getattr(cirq, "Ry", None)), + ("Rz", getattr(cirq, "Rz", None)), + ("PhasedXZ", getattr(cirq, "PhasedXZGate", None)), + ("FSim", getattr(cirq, "FSimGate", None)), + ] + if _param_gate_specs: + for _name, _gate_cls in _param_gate_specs: + if _gate_cls: + self._PARAMETERIZED_GATE_BASE_NAMES[_gate_cls] = _name + # Clean up temporary variables + del _param_gate_specs, _name, _gate_cls # type: ignore + + def _get_sorted_qubits(self) -> List["cirq.Qid"]: + """Determines and returns a sorted list of all unique qubits in the circuit. + + Returns: + A list of `cirq.Qid` objects, sorted to ensure consistent qubit + ordering in the LaTeX output. + """ + qubits = set(q for moment in self.circuit for op in moment for q in op.qubits) + return sorted(list(qubits)) + + def _map_qubits_to_indices(self) -> Dict["cirq.Qid", int]: + """Creates a mapping from `cirq.Qid` objects to their corresponding + integer indices based on the sorted qubit order. + + Returns: + A dictionary where keys are `cirq.Qid` objects and values are their + zero-based integer indices. + """ + return {q: i for i, q in enumerate(self.sorted_qubits)} + + def _get_wire_label(self, qubit: "cirq.Qid", index: int) -> str: + r"""Generates the LaTeX string for a qubit wire label. + + Args: + qubit: The `cirq.Qid` object for which to generate the label. + index: The integer index of the qubit. + + Returns: + A string formatted as a LaTeX math-mode label (e.g., "$q_0$", "$3$", + or "$q_{qubit\_name}$"). + """ + s = str(qubit).replace("_", r"\_").replace(" ", r"\,") + lbl = ( + f"q_{{{index}}}" + if self.wire_labels == "q" + else ( + str(index) + if self.wire_labels == "index" + else s if self.wire_labels == "qid" else f"q_{{{index}}}" + ) + ) + return f"${lbl}$" + + def _format_exponent_for_display(self, exponent: Any) -> str: + """Formats a gate exponent for display in LaTeX. + + Handles floats, integers, and `sympy.Basic` expressions, converting them + to a string representation suitable for LaTeX, including proper + handling of numerical precision and symbolic constants like pi. + + Args: + exponent: The exponent value, which can be a float, int, or + `sympy.Basic` object. + + Returns: + A string representing the formatted exponent, ready for LaTeX + insertion. + """ + exp_str: str + # Dynamically create the format string based on self.float_precision_exps + float_format_string = f".{self.float_precision_exps}f" + + if isinstance(exponent, float): + # If the float is an integer value (e.g., 2.0), display as integer string ("2") + if exponent.is_integer(): + exp_str = str(int(exponent)) + else: + # Format to the specified precision for rounding + rounded_str = format(exponent, float_format_string) + # Convert back to float and then to string to remove unnecessary trailing zeros + # e.g., if precision is 2, 0.5 -> "0.50" -> 0.5 -> "0.5" + # e.g., if precision is 2, 0.318 -> "0.32" -> 0.32 -> "0.32" + exp_str = str(float(rounded_str)) + # Check for sympy.Basic, assuming sympy is imported if this path is taken + elif isinstance(exponent, sympy.Basic): # type: ignore + s_exponent = str(exponent) + # Heuristic: check for letters to identify symbolic expressions + is_symbolic_or_special = any( + char.isalpha() + for char in s_exponent + if char.lower() not in ["e"] # Exclude 'e' for scientific notation + ) + if not is_symbolic_or_special: # If it looks like a number + try: + py_float = float(sympy.N(exponent)) # type: ignore + # If the sympy evaluated float is an integer value + if py_float.is_integer(): + exp_str = str(int(py_float)) + else: + # Format to specified precision for rounding + rounded_str = format(py_float, float_format_string) + # Convert back to float and then to string to remove unnecessary trailing zeros + exp_str = str(float(rounded_str)) + except (TypeError, ValueError, AttributeError, sympy.SympifyError): # type: ignore + exp_str = s_exponent # Fallback to Sympy's string representation + else: # Symbolic expression + exp_str = s_exponent + else: # For other types (int, strings not sympy objects) + exp_str = str(exponent) + + # LaTeX replacements for pi + exp_str = exp_str.replace("pi", r"\pi").replace("π", r"\pi") + + # Handle underscores: replace "_" with "\_" if not part of a LaTeX command + if "_" in exp_str and "\\" not in exp_str: + exp_str = exp_str.replace("_", r"\_") + return exp_str + + def _get_gate_name(self, gate: "cirq.Gate") -> str: + """Determines the appropriate LaTeX string for a given Cirq gate. + + This method attempts to derive a suitable LaTeX name for the gate, + considering its type, whether it's a power gate, and if parameters + should be displayed. It uses internal mappings and `cirq.circuit_diagram_info`. + + Args: + gate: The `cirq.Gate` object to name. + + Returns: + A string representing the LaTeX name of the gate (e.g., "H", + "Rx(0.5)", "CZ"). + """ + gate_type = type(gate) + if gate_type.__name__ == "ThermalChannel": + return "\\Lambda_\\mathrm{th}" + if (simple_name := self._SIMPLE_GATE_MAP.get(gate_type)) is not None: + return simple_name + + base_key = self._EXPONENT_GATE_MAP.get(gate_type) + if base_key is not None and hasattr(gate, "exponent") and gate.exponent == 1: + return self.current_gate_name_map.get(base_key, base_key) + + if (param_base_key := self._PARAMETERIZED_GATE_BASE_NAMES.get(gate_type)) is not None: + mapped_name = self.current_gate_name_map.get(param_base_key, param_base_key) + if not self.show_parameters: + return mapped_name + try: + info_src = cirq.protocols if hasattr(cirq, "protocols") else cirq + info = info_src.circuit_diagram_info(gate, default=NotImplemented) # type: ignore + if info is not NotImplemented and info.wire_symbols: + s_diag = info.wire_symbols[0] + if (op_idx := s_diag.find("(")) != -1 and ( + cp_idx := s_diag.rfind(")") + ) > op_idx: + return f"{mapped_name}({self._format_exponent_for_display(s_diag[op_idx+1:cp_idx])})" + except Exception: + pass + if hasattr(gate, "exponent") and not math.isclose(gate.exponent, 1.0): + return f"{mapped_name}({self._format_exponent_for_display(gate.exponent)})" + return mapped_name + + try: + info_src = cirq.protocols if hasattr(cirq, "protocols") else cirq + info = info_src.circuit_diagram_info(gate, default=NotImplemented) # type: ignore + if info is not NotImplemented and info.wire_symbols: + name_cand = info.wire_symbols[0] + if not self.show_parameters: + base_part = name_cand.split("^")[0].split("**")[0].split("(")[0].strip() + if isinstance(gate, cirq.CZPowGate) and base_part == "@": + base_part = "CZ" + mapped_base = self.current_gate_name_map.get(base_part, base_part) + return self._format_exponent_for_display(mapped_base) + + if ( + hasattr(gate, "exponent") + and not math.isclose(gate.exponent, 1.0) + and isinstance(gate, tuple(self._EXPONENT_GATE_MAP.keys())) + ): + has_exp_in_cand = ("^" in name_cand) or ("**" in name_cand) + if not has_exp_in_cand and base_key: + recon_base = self.current_gate_name_map.get(base_key, base_key) + needs_recon = (name_cand == base_key) or ( + isinstance(gate, cirq.CZPowGate) and name_cand == "@" + ) + if needs_recon: + name_cand = f"{recon_base}^{{{self._format_exponent_for_display(gate.exponent)}}}" + + fmt_name = name_cand.replace("π", r"\pi") + if "_" in fmt_name and "\\" not in fmt_name: + fmt_name = fmt_name.replace("_", r"\_") + if "**" in fmt_name: + parts = fmt_name.split("**", 1) + if len(parts) == 2: + fmt_name = f"{parts[0]}^{{{self._format_exponent_for_display(parts[1])}}}" + return fmt_name + except Exception: + pass + + name_fb = str(gate) + if name_fb.endswith("Gate"): + name_fb = name_fb[:-4] + if name_fb.endswith("()"): + name_fb = name_fb[:-2] + if not self.show_parameters: + base_fb = name_fb.split("**")[0].split("(")[0].strip() + fb_key = self._EXPONENT_GATE_MAP.get(gate_type, base_fb) + mapped_fb = self.current_gate_name_map.get(fb_key, fb_key) + return self._format_exponent_for_display(mapped_fb) + if name_fb.endswith("**1.0"): + name_fb = name_fb[:-5] + if name_fb.endswith("**1"): + name_fb = name_fb[:-3] + if "**" in name_fb: + parts = name_fb.split("**", 1) + if len(parts) == 2: + fb_key = self._EXPONENT_GATE_MAP.get(gate_type, parts[0]) + base_str_fb = self.current_gate_name_map.get(fb_key, parts[0]) + name_fb = f"{base_str_fb}^{{{self._format_exponent_for_display(parts[1])}}}" + name_fb = name_fb.replace("π", r"\pi") + if "_" in name_fb and "\\" not in name_fb: + name_fb = name_fb.replace("_", r"\_") + return name_fb + + def _get_quantikz_options_string(self) -> str: + return f"[{self.quantikz_options}]" if self.quantikz_options else "" + + def _render_operation(self, op: "cirq.Operation") -> Dict[int, str]: + output, q_indices = {}, sorted([self.qubit_to_index[q] for q in op.qubits]) + gate, gate_name_render = op.gate, self._get_gate_name(op.gate) + + gate_type = type(gate) + style_key = gate_type.__name__ # Default style key + + # 1. Gates with dedicated Quantikz commands & specific style keys (exp=1) + if isinstance(gate, cirq.CNotPowGate) and hasattr(gate, "exponent") and gate.exponent == 1: + style_key = "CXideal" + elif isinstance(gate, cirq.CZPowGate) and hasattr(gate, "exponent") and gate.exponent == 1: + style_key = "CZideal" + elif ( + isinstance(gate, cirq.SwapPowGate) and hasattr(gate, "exponent") and gate.exponent == 1 + ): + style_key = "Swapideal" + # 2. MeasurementGate + elif isinstance(gate, cirq.MeasurementGate): + style_key = "Measure" + # 3. Specific named parameterized gates (Rx, FSim, etc.) + elif (param_base_name := self._PARAMETERIZED_GATE_BASE_NAMES.get(gate_type)) is not None: + style_key = param_base_name + # 4. General PowGates (X, Y, Z, H, and controlled versions if not exp=1) + elif (base_key_for_pow := self._EXPONENT_GATE_MAP.get(gate_type)) is not None: + # At this point, if it's a PowGate from the map, its exponent is either != 1, + # or it's a gate like X, Y, Z, H, ISWAP that renders with \gate{} + if hasattr(gate, "exponent"): + if gate.exponent == 1: + # This covers X, Y, Z, H, ISWAP (exp=1) + style_key = base_key_for_pow # "X", "Y", "Z", "H", "iSwap" + else: + # This covers X_pow, Y_pow, Z_pow, H_pow, CZ_pow, CX_pow, iSWAP_pow + style_key = { + "X": "X_pow", + "Y": "Y_pow", + "Z": "Z_pow", + "H": "H_pow", + "CZ": "CZ_pow", + "CX": "CX_pow", + "iSwap": "iSWAP_pow", + }.get( + base_key_for_pow, f"{base_key_for_pow}_pow" + ) # Fallback to base_key_pow + else: # Should not happen for gates in _EXPONENT_GATE_MAP as they are PowGates + style_key = base_key_for_pow + # else: style_key remains gate_type.__name__ (e.g., for unknown custom gates) + + style_opts_str = self.gate_styles.get(style_key, "") + # Alias lookup for convenience if primary style_key not found + if not style_opts_str: + if gate_type.__name__ == "FSimGate": + style_opts_str = self.gate_styles.get("FSim", "") + elif gate_type.__name__ == "PhasedXZGate": + style_opts_str = self.gate_styles.get("PhasedXZ", "") + + final_style_tikz = f"[{style_opts_str}]" if style_opts_str else "" + + # --- Special Quantikz commands --- + if isinstance(gate, cirq.MeasurementGate): + lbl = gate.key.replace("_", r"\_") if gate.key else "" + for i in q_indices: + output[i] = f"\\meter{final_style_tikz}{{{lbl}}}" + return output + if isinstance(gate, cirq.CNotPowGate) and hasattr(gate, "exponent") and gate.exponent == 1: + c, t = ( + (self.qubit_to_index[op.qubits[0]], self.qubit_to_index[op.qubits[1]]) + if len(op.qubits) == 2 + else (q_indices[0], q_indices[0]) + ) + output[c], output[t] = ( + f"\\ctrl{final_style_tikz}{{{t-c}}}", + f"\\targ{final_style_tikz}{{}}", + ) + return output + if isinstance(gate, cirq.CZPowGate) and hasattr(gate, "exponent") and gate.exponent == 1: + i1, i2 = ( + (q_indices[0], q_indices[1]) + if len(q_indices) >= 2 + else (q_indices[0], q_indices[0]) + ) + output[i1], output[i2] = ( + f"\\ctrl{final_style_tikz}{{{i2-i1}}}", + f"\\control{final_style_tikz}{{}}", + ) + return output + if isinstance(gate, cirq.SwapPowGate) and hasattr(gate, "exponent") and gate.exponent == 1: + i1, i2 = ( + (q_indices[0], q_indices[1]) + if len(q_indices) >= 2 + else (q_indices[0], q_indices[0]) + ) + output[i1], output[i2] = ( + f"\\swap{final_style_tikz}{{{i2-i1}}}", + f"\\targX{final_style_tikz}{{}}", + ) + return output + + # --- Generic \gate command --- + if not q_indices: + warnings.warn(f"Op {op} has no qubits.") + return output + if len(q_indices) == 1: + output[q_indices[0]] = f"\\gate{final_style_tikz}{{{gate_name_render}}}" + else: # Multi-qubit gate + wires_opt = f"wires={q_indices[-1]-q_indices[0]+1}" + # style_opts_str is already the TikZ style string part, e.g., "fill=blue!20" + if style_opts_str: # If there are actual style options + combined_opts = f"{wires_opt}, {style_opts_str}" + else: # Only wires option + combined_opts = wires_opt + output[q_indices[0]] = f"\\gate[{combined_opts}]{{{gate_name_render}}}" + for i in range(1, len(q_indices)): + output[q_indices[i]] = "\\qw" + return output + + def _generate_latex_body(self) -> str: + chunks, m_count, active_chunk = [], 0, [[] for _ in range(self.num_qubits)] + for i in range(self.num_qubits): + active_chunk[i].append(f"\\lstick{{{self._get_wire_label(self.sorted_qubits[i], i)}}}") + + for m_idx, moment in enumerate(self.circuit): + m_count += 1 + moment_out = ["\\qw"] * self.num_qubits + processed_indices = set() + for op in moment: + q_idx_op = sorted([self.qubit_to_index[q] for q in op.qubits]) + if not q_idx_op: + warnings.warn(f"Op {op} no qubits.") + continue + if any(q in processed_indices for q in q_idx_op): + for q_idx in q_idx_op: + if q_idx not in processed_indices: + moment_out[q_idx] = "\\qw" + continue + op_rnd = self._render_operation(op) + for idx, tex in op_rnd.items(): + if idx not in processed_indices: + moment_out[idx] = tex + processed_indices.update(q_idx_op) + for i in range(self.num_qubits): + active_chunk[i].append(moment_out[i]) + + is_last_m = m_idx == len(self.circuit) - 1 + if self.fold_at and m_count % self.fold_at == 0 and not is_last_m: + for i in range(self.num_qubits): + lbl = self._get_wire_label(self.sorted_qubits[i], i) + active_chunk[i].extend([f"\\rstick{{{lbl}}}", "\\qw"]) + chunks.append(active_chunk) + active_chunk = [[] for _ in range(self.num_qubits)] + for i in range(self.num_qubits): + active_chunk[i].append( + f"\\lstick{{{self._get_wire_label(self.sorted_qubits[i],i)}}}" + ) + + if self.num_qubits > 0: + ended_on_fold = self.fold_at and m_count > 0 and m_count % self.fold_at == 0 + if not ended_on_fold or not self.fold_at: + for i in range(self.num_qubits): + if not active_chunk[i]: + active_chunk[i] = [ + f"\\lstick{{{self._get_wire_label(self.sorted_qubits[i],i)}}}" + ] + active_chunk[i].append("\\qw") + if self.fold_at: + for i in range(self.num_qubits): + if not active_chunk[i]: + active_chunk[i] = [ + f"\\lstick{{{self._get_wire_label(self.sorted_qubits[i],i)}}}" + ] + active_chunk[i].extend( + [f"\\rstick{{{self._get_wire_label(self.sorted_qubits[i],i)}}}", "\\qw"] + ) + chunks.append(active_chunk) + + final_parts = [] + opts_str = self._get_quantikz_options_string() + for chunk_data in chunks: + if not any(row for row_list in chunk_data for row in row_list): + continue + + is_empty_like = True + if chunk_data and any(chunk_data): + for r_cmds in chunk_data: + if any( + cmd not in ["\\qw", ""] + and not cmd.startswith("\\lstick") + and not cmd.startswith("\\rstick") + for cmd in r_cmds + ): + is_empty_like = False + break + if all( + all( + cmd == "\\qw" or cmd.startswith("\\lstick") or cmd.startswith("\\rstick") + for cmd in r + ) + for r in chunk_data + if r + ): + if len(chunks) > 1 or not self.circuit: + if all(len(r) <= (4 if self.fold_at else 2) for r in chunk_data if r): + is_empty_like = True + if is_empty_like and len(chunks) > 1 and self.circuit: + continue + + lines = [f"\\begin{{quantikz}}{opts_str}"] + for i in range(self.num_qubits): + if i < len(chunk_data) and chunk_data[i]: + lines.append(" & ".join(chunk_data[i]) + " \\\\") + elif i < self.num_qubits: + lines.append( + f"\\lstick{{{self._get_wire_label(self.sorted_qubits[i],i)}}} & \\qw \\\\" + ) + + if len(lines) > 1: + for k_idx in range(len(lines) - 1, 0, -1): + if lines[k_idx].strip() and lines[k_idx].strip() != "\\\\": + if lines[k_idx].endswith(" \\\\"): + lines[k_idx] = lines[k_idx].rstrip()[:-3].rstrip() + break + elif lines[k_idx].strip() == "\\\\" and k_idx == len(lines) - 1: + lines[k_idx] = "" + lines.append("\\end{quantikz}") + final_parts.append("\n".join(filter(None, lines))) + + if not final_parts and self.num_qubits > 0: + lines = [f"\\begin{{quantikz}}{opts_str}"] + for i in range(self.num_qubits): + lines.append( + f"\\lstick{{{self._get_wire_label(self.sorted_qubits[i],i)}}} & \\qw" + + (" \\\\" if i < self.num_qubits - 1 else "") + ) + lines.append("\\end{quantikz}") + return "\n".join(lines) + return "\n\n\\vspace{1em}\n\n".join(final_parts) + + def generate_latex_document(self, preamble_template: Optional[str] = None) -> str: + preamble = preamble_template or DEFAULT_PREAMBLE_TEMPLATE + preamble += f"\n% --- Custom Preamble Injection Point ---\n{self.custom_preamble}\n% --- End Custom Preamble ---\n" + doc_parts = [preamble, "\\begin{document}", self._generate_latex_body()] + if self.custom_postamble: + doc_parts.extend( + [ + "\n% --- Custom Postamble Start ---", + self.custom_postamble, + "% --- Custom Postamble End ---\n", + ] + ) + doc_parts.append("\\end{document}") + return "\n".join(doc_parts) diff --git a/cirq-core/cirq/vis/circuit_to_latex_render.py b/cirq-core/cirq/vis/circuit_to_latex_render.py new file mode 100644 index 00000000000..4499424293c --- /dev/null +++ b/cirq-core/cirq/vis/circuit_to_latex_render.py @@ -0,0 +1,541 @@ +# -*- coding: utf-8 -*- +r"""Provides tools for rendering Cirq circuits as Quantikz LaTeX diagrams. + +This module offers a high-level interface for converting `cirq.Circuit` objects +into visually appealing quantum circuit diagrams using the `quantikz` LaTeX package. +It extends the functionality of `CirqToQuantikz` by handling the full rendering +pipeline: generating LaTeX, compiling it to PDF using `pdflatex`, and converting +the PDF to a PNG image using `pdftoppm`. + +The primary function, `render_circuit`, streamlines this process, allowing users +to easily generate and optionally display circuit diagrams in environments like +Jupyter notebooks. It provides extensive customization options for the output +format, file paths, and rendering parameters, including direct control over +gate styling, circuit folding, and qubit labeling through arguments passed +to the underlying `CirqToQuantikz` converter. + +Additionally, the module includes `create_gif_from_ipython_images`, a utility +function for animating sequences of images, which can be useful for visualizing +dynamic quantum processes or circuit transformations. +""" +# mypy: ignore-errors + + +import inspect +import io +import math +import os +import shutil +import subprocess +import tempfile +import traceback +import warnings +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Tuple, Type, Union + +import cirq +import numpy as np +import sympy + +from .circuit_to_latex_quantikz import CirqToQuantikz + +__all__ = ["render_circuit", "create_gif_from_ipython_images"] + +try: + from IPython.display import display, Image, Markdown # type: ignore + + _HAS_IPYTHON = True +except ImportError: + _HAS_IPYTHON = False + + class Image: # type: ignore + def __init__(self, *args, **kwargs): + pass + + def display(*args, **kwargs): # type: ignore + pass + + def Markdown(*args, **kwargs): # type: ignore + pass + + +# ============================================================================= +# High-Level Wrapper Function +# ============================================================================= +def render_circuit( + circuit: "cirq.Circuit", + output_png_path: Optional[str] = None, + output_pdf_path: Optional[str] = None, + output_tex_path: Optional[str] = None, + dpi: int = 300, + run_pdflatex: bool = True, + run_pdftoppm: bool = True, + display_png_jupyter: bool = True, + cleanup: bool = True, + debug: bool = False, + timeout=120, + # Carried over CirqToQuantikz args + gate_styles: Optional[Dict[str, str]] = None, + quantikz_options: Optional[str] = None, + fold_at: Optional[int] = None, + wire_labels: str = "q", + show_parameters: bool = True, + gate_name_map: Optional[Dict[str, str]] = None, + float_precision_exps: int = 2, + **kwargs: Any, +) -> Optional[Union[str, "Image"]]: + r"""Renders a Cirq circuit to a LaTeX diagram, compiles it, and optionally displays it. + + This function takes a `cirq.Circuit` object, converts it into a Quantikz + LaTeX string, compiles the LaTeX into a PDF, and then converts the PDF + into a PNG image. It can optionally save these intermediate and final + files and display the PNG in a Jupyter environment. + + Args: + circuit: The `cirq.Circuit` object to be rendered. + output_png_path: Optional path to save the generated PNG image. If + `None`, the PNG is only kept in a temporary directory (if + `cleanup` is `True`) or not generated if `run_pdftoppm` is `False`. + output_pdf_path: Optional path to save the generated PDF document. + output_tex_path: Optional path to save the generated LaTeX source file. + dpi: The DPI (dots per inch) for the output PNG image. Higher DPI + results in a larger and higher-resolution image. + run_pdflatex: If `True`, `pdflatex` is executed to compile the LaTeX + file into a PDF. Requires `pdflatex` to be installed and in PATH. + run_pdftoppm: If `True`, `pdftoppm` (from poppler-utils) is executed + to convert the PDF into a PNG image. Requires `pdftoppm` to be + installed and in PATH. This option is ignored if `run_pdflatex` + is `False`. + display_png_jupyter: If `True` and running in a Jupyter environment, + the generated PNG image will be displayed directly in the output + cell. + cleanup: If `True`, temporary files and directories created during + the process (LaTeX, log, aux, PDF, temporary PNGs) will be removed. + If `False`, they are kept for debugging. + debug: If `True`, prints additional debugging information to the console. + timeout: Maximum time in seconds to wait for `pdflatex` and `pdftoppm` + commands to complete. + gate_styles: An optional dictionary mapping gate names (strings) to + Quantikz style options (strings). These styles are applied to + the generated gates. If `None`, `GATE_STYLES_COLORFUL1` is used. + Passed to `CirqToQuantikz`. + quantikz_options: An optional string of global options to pass to the + `quantikz` environment (e.g., `"[row sep=0.5em]"`). Passed to + `CirqToQuantikz`. + fold_at: An optional integer specifying the number of moments after + which the circuit should be folded into a new line in the LaTeX + output. If `None`, the circuit is not folded. Passed to `CirqToQuantikz`. + wire_labels: A string specifying how qubit wire labels should be + rendered. Passed to `CirqToQuantikz`. + show_parameters: A boolean indicating whether gate parameters (e.g., + exponents for `XPowGate`, angles for `Rx`) should be displayed + in the gate labels. Passed to `CirqToQuantikz`. + gate_name_map: An optional dictionary mapping Cirq gate names (strings) + to custom LaTeX strings for rendering. This allows renaming gates + in the output. Passed to `CirqToQuantikz`. + float_precision_exps: An integer specifying the number of decimal + places for formatting floating-point exponents. Passed to `CirqToQuantikz`. + **kwargs: Additional keyword arguments passed directly to the + `CirqToQuantikz` constructor. Refer to `CirqToQuantikz` for + available options. Note that explicit arguments in `render_circuit` + will override values provided via `**kwargs`. + + Returns: + An `IPython.display.Image` object if `display_png_jupyter` is `True` + and running in a Jupyter environment, and the PNG was successfully + generated. Otherwise, returns the string path to the saved PNG if + `output_png_path` was provided and successful, or `None` if no PNG + was generated or displayed. + + Raises: + warnings.warn: If `pdflatex` or `pdftoppm` executables are not found + when their respective `run_` flags are `True`. + + Example: + >>> import cirq + >>> from cirq.vis.cirq_circuit_quantikz_render import render_circuit + >>> q0, q1, q2 = cirq.LineQubit.range(3) + >>> circuit = cirq.Circuit( + ... cirq.H(q0), + ... cirq.CNOT(q0, q1), + ... cirq.Rx(rads=0.25 * cirq.PI).on(q1), + ... cirq.measure(q0, q1, key='result') + ... ) + >>> # Render and display in Jupyter (if available), also save to a file + >>> img_or_path = render_circuit( + ... circuit, + ... output_png_path="my_circuit.png", + ... fold_at=2, + ... wire_labels="qid", + ... quantikz_options="[column sep=0.7em]", + ... show_parameters=False # Example of new parameter + ... ) + >>> if isinstance(img_or_path, Image): + ... print("Circuit rendered and displayed in Jupyter.") + >>> elif isinstance(img_or_path, str): + ... print(f"Circuit rendered and saved to {img_or_path}") + >>> else: + ... print("Circuit rendering failed or no output generated.") + >>> # To view the saved PNG outside Jupyter: + >>> # import matplotlib.pyplot as plt + >>> # import matplotlib.image as mpimg + >>> # img = mpimg.imread('my_circuit.png') + >>> # plt.imshow(img) + >>> # plt.axis('off') + >>> # plt.show() + """ + + def _debug_print(*args, **kwargs_print): + if debug: + print("[Debug]", *args, **kwargs_print) + + final_tex_path = Path(output_tex_path).expanduser().resolve() if output_tex_path else None + final_pdf_path = Path(output_pdf_path).expanduser().resolve() if output_pdf_path else None + final_png_path = Path(output_png_path).expanduser().resolve() if output_png_path else None + + pdflatex, pdftoppm = shutil.which("pdflatex"), shutil.which("pdftoppm") + if run_pdflatex and not pdflatex: + warnings.warn( + "'pdflatex' not found. Cannot compile LaTeX. " + "Please install a LaTeX distribution (e.g., TeX Live, MiKTeX). " + "On Ubuntu/Debian: `sudo apt-get install texlive-full` (or `texlive-base` for minimal). " + "On macOS: `brew install --cask mactex` (or `brew install texlive` for minimal). " + "On Windows: Download and install MiKTeX or TeX Live." + ) + run_pdflatex = run_pdftoppm = False + if run_pdftoppm and not pdftoppm: + warnings.warn( + "'pdftoppm' not found. Cannot convert PDF to PNG. " + "This tool is part of the Poppler utilities. " + "On Ubuntu/Debian: `sudo apt-get install poppler-utils`. " + "On macOS: `brew install poppler`. " + "On Windows: Download Poppler for Windows (e.g., from Poppler for Windows GitHub releases) " + "and add its `bin` directory to your system PATH." + ) + run_pdftoppm = False + + try: + with tempfile.TemporaryDirectory() as tmpdir_s: + tmp_p = Path(tmpdir_s) + _debug_print(f"Temp dir: {tmp_p}") + base, tmp_tex = "circuit_render", tmp_p / "circuit_render.tex" + tmp_pdf, tmp_png_prefix = tmp_p / f"{base}.pdf", str(tmp_p / base) + tmp_png_out = tmp_p / f"{base}.png" + + # Prepare kwargs for CirqToQuantikz, prioritizing explicit args + converter_kwargs = { + "gate_styles": gate_styles, + "quantikz_options": quantikz_options, + "fold_at": fold_at, + "wire_labels": wire_labels, + "show_parameters": show_parameters, + "gate_name_map": gate_name_map, + "float_precision_exps": float_precision_exps, + **kwargs, # Existing kwargs are merged, but explicit args take precedence + } + + try: + converter = CirqToQuantikz(circuit, **converter_kwargs) + except Exception as e: + print(f"Error init CirqToQuantikz: {e}") + return None + + _debug_print("Generating LaTeX...") + try: + latex_s = converter.generate_latex_document() + except Exception as e: + print(f"Error gen LaTeX: {e}") + if debug: + traceback.print_exc() + return None + if debug: + _debug_print("LaTeX (first 500 chars):\n", latex_s[:500] + "...") + + try: + tmp_tex.write_text(latex_s, encoding="utf-8") + except IOError as e: + print(f"Error writing temp TEX {tmp_tex}: {e}") + return None + + pdf_ok = False + if run_pdflatex and pdflatex: + _debug_print(f"Running pdflatex ({pdflatex})...") + cmd_latex = [ + pdflatex, + "-interaction=nonstopmode", + "-halt-on-error", + "-output-directory", + str(tmp_p), + str(tmp_tex), + ] + latex_failed = False + for i in range(2): + _debug_print(f" pdflatex run {i+1}/2...") + proc = subprocess.run( + cmd_latex, + capture_output=True, + text=True, + check=False, + cwd=tmp_p, + timeout=timeout, + ) + if proc.returncode != 0: + latex_failed = True + print(f"!!! pdflatex failed run {i+1} (code {proc.returncode}) !!!") + log_f = tmp_tex.with_suffix(".log") + if log_f.exists(): + print( + f"--- Tail of {log_f.name} ---\n{log_f.read_text(errors='ignore')[-2000:]}" + ) + else: + if proc.stdout: + print(f"--- pdflatex stdout ---\n{proc.stdout[-2000:]}") + if proc.stderr: + print(f"--- pdflatex stderr ---\n{proc.stderr}") + if final_tex_path: + try: + final_tex_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(tmp_tex, final_tex_path) + print(f"Problem TEX: {final_tex_path}") + except Exception as e_cp: + print(f"Error copying TEX: {e_cp}") + else: + print(f"Problem TEX was: {tmp_tex}") + break + elif not tmp_pdf.is_file() and i == 1: + latex_failed = True + print("!!! pdflatex ok, but PDF not found. Check logs. !!!") + log_f = tmp_tex.with_suffix(".log") + if log_f.exists(): + print( + f"--- Tail of {log_f.name} ---\n{log_f.read_text(errors='ignore')[-2000:]}" + ) + break + elif tmp_pdf.is_file(): + _debug_print(f" Run {i+1}/2 OK (PDF exists).") + if not latex_failed and tmp_pdf.is_file(): + pdf_ok = True + _debug_print(f"PDF OK: {tmp_pdf}") + elif not latex_failed: + print("pdflatex seemed OK but no PDF.") + if latex_failed: + return None + + png_ok, png_disp_path = False, None + if run_pdftoppm and pdftoppm and pdf_ok: + _debug_print(f"Running pdftoppm ({pdftoppm})...") + cmd_ppm = [ + pdftoppm, + "-png", + f"-r", + str(dpi), + "-singlefile", + str(tmp_pdf), + tmp_png_prefix, + ] + try: + proc = subprocess.run( + cmd_ppm, + capture_output=True, + text=True, + check=True, + cwd=tmp_p, + timeout=timeout, + ) + if tmp_png_out.is_file(): + png_ok, png_disp_path = True, tmp_png_out + _debug_print(f"PNG OK: {tmp_png_out}") + else: + print(f"!!! pdftoppm OK but PNG ({tmp_png_out}) not found. !!!") + except subprocess.CalledProcessError as e_ppm: + print( + f"!!! pdftoppm failed (code {e_ppm.returncode}) !!!\n{e_ppm.stdout}\n{e_ppm.stderr}" + ) + except subprocess.TimeoutExpired: + print("!!! pdftoppm timed out. !!!") + except Exception as e_ppm_other: + print(f"pdftoppm error: {e_ppm_other}") + + copied = {} + if final_tex_path and tmp_tex.exists(): + try: + final_tex_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(tmp_tex, final_tex_path) + copied["tex"] = final_tex_path + except Exception as e: + print(f"Error copying TEX: {e}") + if final_pdf_path and pdf_ok and tmp_pdf.exists(): + try: + final_pdf_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(tmp_pdf, final_pdf_path) + copied["pdf"] = final_pdf_path + except Exception as e: + print(f"Error copying PDF: {e}") + if final_png_path and png_ok and tmp_png_out.exists(): + try: + final_png_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(tmp_png_out, final_png_path) + copied["png"] = final_png_path + png_disp_path = final_png_path + except Exception as e: + print(f"Error copying PNG: {e}") + elif ( + png_ok and tmp_png_out.exists() and not final_png_path + ): # Only use temp if not copied to final + png_disp_path = tmp_png_out + + jupyter_image_object: Optional["Image"] = None + + if display_png_jupyter and png_disp_path and png_disp_path.is_file(): + _debug_print(f"Displaying PNG: {png_disp_path}") + if _HAS_IPYTHON: + try: + sh_obj = get_ipython() # type: ignore + if sh_obj is None: + print(f"Not IPython. PNG: {png_disp_path}") + else: + sh_name = sh_obj.__class__.__name__ + # Create the Image object regardless of shell type for potential return + # but only display it in ZMQInteractiveShell. + current_image_obj = Image(filename=str(png_disp_path)) + if sh_name == "ZMQInteractiveShell": + display(current_image_obj) + jupyter_image_object = current_image_obj # Store if displayed + elif sh_name == "TerminalInteractiveShell": + print(f"Terminal IPython. PNG: {png_disp_path}") + # In terminal, we might still want to return the object if requested + jupyter_image_object = current_image_obj + else: + print(f"Display might not work ({sh_name}). PNG: {png_disp_path}") + jupyter_image_object = current_image_obj + except Exception as e_disp: + print(f"PNG display error: {e_disp}") + jupyter_image_object = None + else: + print(f"IPython not available. PNG: {png_disp_path}") + elif display_png_jupyter and (not png_disp_path or not png_disp_path.is_file()): + if run_pdflatex and run_pdftoppm: + print("PNG display requested, but PNG not created/found.") + + if not cleanup and debug: + _debug_print("cleanup=False; temp dir removed by context manager.") + + # Removed the deliberate logical error. + if jupyter_image_object: + return jupyter_image_object + elif copied.get("png"): + return str(copied["png"]) + return None # Fallback if nothing to return + except subprocess.TimeoutExpired as e_timeout: + print(f"!!! Process timed out: {e_timeout} !!!") + if debug: + traceback.print_exc() + return None + except Exception as e_crit: + print(f"Critical error in render_circuit: {e_crit}") + if debug: + traceback.print_exc() + return None + + +def create_gif_from_ipython_images( + image_list: "list[Image]", output_filename: str, fps: int, **kwargs +): + r"""Creates a GIF from a list of IPython.core.display.Image objects and saves it. + + The resulting GIF will loop indefinitely by default. + + Args: + image_list: A list of `IPython.display.Image` objects. These objects + should contain image data (e.g., from `matplotlib` or `PIL`). + output_filename: The desired filename for the output GIF (e.g., + "animation.gif"). + fps: The frame rate (frames per second) for the GIF. + **kwargs: Additional keyword arguments to pass to `imageio.mimsave()`. + For example, `duration` (scalar or list) can be used to set + frame durations instead of fps, or `loop` (default 0 for infinite) + can be set to a different number of loops. If 'loop' is provided + in kwargs, it will override the default infinite loop. + + Returns: + None. The function saves the GIF to the specified `output_filename`. + """ + try: + import imageio + except ImportError: + print("You need to install imageio: `pip install imageio`") + return None + try: + from PIL import Image as PILImage + except ImportError: + print("You need to install PIL: pip install Pillow") + return None + + frames = [] + for ipython_image in image_list: + image_bytes = ipython_image.data + try: + pil_img = PILImage.open(io.BytesIO(image_bytes)) + # Ensure image is in RGB/RGBA for broad compatibility before making it a numpy array. + # GIF supports palette ('P') directly, but converting to RGB first can be safer + # if complex palettes or transparency are involved and imageio's handling is unknown. + # However, for GIFs, 'P' mode with a good palette is often preferred for smaller file sizes. + # Let's try to keep 'P' if possible, but convert RGBA to RGB as GIFs don't support full alpha well. + if pil_img.mode == "RGBA": + # Create a white background image + background = PILImage.new("RGB", pil_img.size, (255, 255, 255)) + # Paste the RGBA image onto the white background + background.paste(pil_img, mask=pil_img.split()[3]) # 3 is the alpha channel + pil_img = background + elif pil_img.mode not in ["RGB", "L", "P"]: # L for grayscale, P for palette + pil_img = pil_img.convert("RGB") + frames.append(np.array(pil_img)) + except Exception as e: + print(f"Warning: Could not process an image. Error: {e}") + continue + + if not frames: + print("Warning: No frames were successfully extracted. GIF not created.") + return + + # Set default loop to 0 (infinite) if not specified in kwargs + if "loop" not in kwargs: + kwargs["loop"] = 0 + + if "duration" in kwargs: + pass + + try: + imageio.mimsave(output_filename, frames, fps=fps, **kwargs) + print(f"GIF saved as {output_filename} with {fps} FPS and options: {kwargs}") + except Exception as e: + print(f"Error saving GIF: {e}") + # Attempt saving with a more basic configuration if advanced options fail + try: + print("Attempting to save GIF with basic settings (RGB, default palette).") + rgb_frames = [] + for frame_data in frames: + if frame_data.ndim == 2: # Grayscale + pil_frame = PILImage.fromarray(frame_data, mode="L") + elif frame_data.shape[2] == 3: # RGB + pil_frame = PILImage.fromarray(frame_data, mode="RGB") + elif frame_data.shape[2] == 4: # RGBA + pil_frame = PILImage.fromarray(frame_data, mode="RGBA") + background = PILImage.new("RGB", pil_frame.size, (255, 255, 255)) + background.paste(pil_frame, mask=pil_frame.split()[3]) + pil_frame = background + else: + pil_frame = PILImage.fromarray(frame_data) + + if pil_frame.mode != "RGB": + pil_frame = pil_frame.convert("RGB") + rgb_frames.append(np.array(pil_frame)) + + if rgb_frames: + imageio.mimsave(output_filename, rgb_frames, fps=fps, loop=kwargs.get("loop", 0)) + print(f"GIF saved with basic RGB settings as {output_filename}") + else: + print("Could not convert frames to RGB for basic save.") + + except Exception as fallback_e: + print(f"Fallback GIF saving also failed: {fallback_e}") From dd26e2e4e91317c5866699b5348dbefd1db498b6 Mon Sep 17 00:00:00 2001 From: Zlatko Minev Date: Sat, 17 May 2025 10:15:35 -0700 Subject: [PATCH 2/9] PR fixes suggested by Doug --- cirq-core/cirq/vis/__init__.py | 2 +- .../cirq/vis/circuit_to_latex_quantikz.py | 213 ++++++----- cirq-core/cirq/vis/circuit_to_latex_render.py | 338 ++++++++++-------- 3 files changed, 318 insertions(+), 235 deletions(-) diff --git a/cirq-core/cirq/vis/__init__.py b/cirq-core/cirq/vis/__init__.py index 88eb1f4244c..50ab2b1d5cd 100644 --- a/cirq-core/cirq/vis/__init__.py +++ b/cirq-core/cirq/vis/__init__.py @@ -29,5 +29,5 @@ from cirq.vis.vis_utils import relative_luminance as relative_luminance -from cirq.vis.circuit_to_latex_quantikz import CirqToQuantikz +from cirq.vis.circuit_to_latex_quantikz import CircuitToQuantikz from cirq.vis.circuit_to_latex_render import render_circuit diff --git a/cirq-core/cirq/vis/circuit_to_latex_quantikz.py b/cirq-core/cirq/vis/circuit_to_latex_quantikz.py index 26e7ef62e8d..7e20660e110 100644 --- a/cirq-core/cirq/vis/circuit_to_latex_quantikz.py +++ b/cirq-core/cirq/vis/circuit_to_latex_quantikz.py @@ -1,13 +1,27 @@ +# Copyright 2019 The Cirq Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + # -*- coding: utf-8 -*- r"""Converts Cirq circuits to Quantikz LaTeX (using modern quantikz syntax). -This module provides a class, `CirqToQuantikz`, to translate `cirq.Circuit` +This module provides a class, `CircuitToQuantikz`, to translate `cirq.Circuit` objects into LaTeX code using the `quantikz` package. It aims to offer flexible customization for gate styles, wire labels, and circuit folding. Example: >>> import cirq - >>> from cirq.vis.circuit_to_latex_quantikz import CirqToQuantikz + >>> from cirq.vis.circuit_to_latex_quantikz import CircuitToQuantikz >>> q0, q1 = cirq.LineQubit.range(2) >>> circuit = cirq.Circuit( ... cirq.H(q0), @@ -15,7 +29,7 @@ ... cirq.measure(q0, key='m0'), ... cirq.Rx(rads=0.5).on(q1) ... ) - >>> converter = CirqToQuantikz(circuit, fold_at=2) + >>> converter = CircuitToQuantikz(circuit, fold_at=2) >>> latex_code = converter.generate_latex_document() >>> print(latex_code) # doctest: +SKIP \documentclass[preview, border=2pt]{standalone} @@ -46,19 +60,18 @@ \end{quantikz} \end{document} """ -# mypy: ignore-errors - import math import warnings from pathlib import Path from typing import Any, Dict, List, Optional, Type -import cirq import numpy as np import sympy -__all__ = ["CirqToQuantikz", "DEFAULT_PREAMBLE_TEMPLATE", "GATE_STYLES_COLORFUL1"] +from cirq import circuits, devices, ops, protocols + +__all__ = ["CircuitToQuantikz", "DEFAULT_PREAMBLE_TEMPLATE", "GATE_STYLES_COLORFUL1"] # ============================================================================= @@ -91,9 +104,9 @@ GATE_STYLES_COLORFUL1 = { "H": _yellow_gate_style, - "_PauliX": _Pauli_gate_style, # cirq.X(q_param) - "_PauliY": _Pauli_gate_style, # cirq.Y(q_param) - "_PauliZ": _Pauli_gate_style, # cirq.Z(q_param) + "_PauliX": _Pauli_gate_style, # ops.X(q_param) + "_PauliY": _Pauli_gate_style, # ops.Y(q_param) + "_PauliZ": _Pauli_gate_style, # ops.Z(q_param) "X": _Pauli_gate_style, # For XPowGate(exponent=1) "Y": _Pauli_gate_style, # For YPowGate(exponent=1) "Z": _Pauli_gate_style, # For ZPowGate(exponent=1) @@ -121,10 +134,36 @@ } +# Initialize gate maps globally as recommended +_SIMPLE_GATE_MAP: Dict[Type[ops.Gate], str] = {ops.MeasurementGate: "Measure"} +_EXPONENT_GATE_MAP: Dict[Type[ops.Gate], str] = { + ops.XPowGate: "X", + ops.YPowGate: "Y", + ops.ZPowGate: "Z", + ops.HPowGate: "H", + ops.CNotPowGate: "CX", + ops.CZPowGate: "CZ", + ops.SwapPowGate: "Swap", + ops.ISwapPowGate: "iSwap", +} +_PARAMETERIZED_GATE_BASE_NAMES: Dict[Type[ops.Gate], str] = {} +_param_gate_specs = [ + ("Rx", getattr(ops, "Rx", None)), + ("Ry", getattr(ops, "Ry", None)), + ("Rz", getattr(ops, "Rz", None)), + ("PhasedXZ", getattr(ops, "PhasedXZGate", None)), + ("FSim", getattr(ops, "FSimGate", None)), +] +if _param_gate_specs: + for _name, _gate_cls in _param_gate_specs: + if _gate_cls: + _PARAMETERIZED_GATE_BASE_NAMES[_gate_cls] = _name + + # ============================================================================= # Cirq to Quantikz Conversion Class # ============================================================================= -class CirqToQuantikz: +class CircuitToQuantikz: r"""Converts a Cirq Circuit object to a Quantikz LaTeX string. This class facilitates the conversion of a `cirq.Circuit` into a LaTeX @@ -181,7 +220,7 @@ class CirqToQuantikz: def __init__( self, - circuit: "cirq.Circuit", + circuit: circuits.Circuit, *, gate_styles: Optional[Dict[str, str]] = None, quantikz_options: Optional[str] = None, @@ -215,34 +254,12 @@ def __init__( self.float_precision_exps = float_precision_exps self.float_precision_angles = float_precision_angles - # Initialize gate maps here to avoid circular import issues - self._SIMPLE_GATE_MAP: Dict[Type["cirq.Gate"], str] = {cirq.MeasurementGate: "Measure"} - self._EXPONENT_GATE_MAP: Dict[Type["cirq.Gate"], str] = { - cirq.XPowGate: "X", - cirq.YPowGate: "Y", - cirq.ZPowGate: "Z", - cirq.HPowGate: "H", - cirq.CNotPowGate: "CX", - cirq.CZPowGate: "CZ", - cirq.SwapPowGate: "Swap", - cirq.ISwapPowGate: "iSwap", - } - self._PARAMETERIZED_GATE_BASE_NAMES: Dict[Type["cirq.Gate"], str] = {} - _param_gate_specs = [ - ("Rx", getattr(cirq, "Rx", None)), - ("Ry", getattr(cirq, "Ry", None)), - ("Rz", getattr(cirq, "Rz", None)), - ("PhasedXZ", getattr(cirq, "PhasedXZGate", None)), - ("FSim", getattr(cirq, "FSimGate", None)), - ] - if _param_gate_specs: - for _name, _gate_cls in _param_gate_specs: - if _gate_cls: - self._PARAMETERIZED_GATE_BASE_NAMES[_gate_cls] = _name - # Clean up temporary variables - del _param_gate_specs, _name, _gate_cls # type: ignore - - def _get_sorted_qubits(self) -> List["cirq.Qid"]: + # Gate maps are now global, no need to initialize here. + self._SIMPLE_GATE_MAP = _SIMPLE_GATE_MAP + self._EXPONENT_GATE_MAP = _EXPONENT_GATE_MAP + self._PARAMETERIZED_GATE_BASE_NAMES = _PARAMETERIZED_GATE_BASE_NAMES + + def _get_sorted_qubits(self) -> List[ops.Qid]: """Determines and returns a sorted list of all unique qubits in the circuit. Returns: @@ -252,7 +269,7 @@ def _get_sorted_qubits(self) -> List["cirq.Qid"]: qubits = set(q for moment in self.circuit for op in moment for q in op.qubits) return sorted(list(qubits)) - def _map_qubits_to_indices(self) -> Dict["cirq.Qid", int]: + def _map_qubits_to_indices(self) -> Dict[ops.Qid, int]: """Creates a mapping from `cirq.Qid` objects to their corresponding integer indices based on the sorted qubit order. @@ -262,7 +279,7 @@ def _map_qubits_to_indices(self) -> Dict["cirq.Qid", int]: """ return {q: i for i, q in enumerate(self.sorted_qubits)} - def _get_wire_label(self, qubit: "cirq.Qid", index: int) -> str: + def _get_wire_label(self, qubit: ops.Qid, index: int) -> str: r"""Generates the LaTeX string for a qubit wire label. Args: @@ -316,7 +333,7 @@ def _format_exponent_for_display(self, exponent: Any) -> str: # e.g., if precision is 2, 0.318 -> "0.32" -> 0.32 -> "0.32" exp_str = str(float(rounded_str)) # Check for sympy.Basic, assuming sympy is imported if this path is taken - elif isinstance(exponent, sympy.Basic): # type: ignore + elif isinstance(exponent, sympy.Basic): s_exponent = str(exponent) # Heuristic: check for letters to identify symbolic expressions is_symbolic_or_special = any( @@ -326,7 +343,7 @@ def _format_exponent_for_display(self, exponent: Any) -> str: ) if not is_symbolic_or_special: # If it looks like a number try: - py_float = float(sympy.N(exponent)) # type: ignore + py_float = float(sympy.N(exponent)) # If the sympy evaluated float is an integer value if py_float.is_integer(): exp_str = str(int(py_float)) @@ -335,8 +352,9 @@ def _format_exponent_for_display(self, exponent: Any) -> str: rounded_str = format(py_float, float_format_string) # Convert back to float and then to string to remove unnecessary trailing zeros exp_str = str(float(rounded_str)) - except (TypeError, ValueError, AttributeError, sympy.SympifyError): # type: ignore - exp_str = s_exponent # Fallback to Sympy's string representation + except (TypeError, ValueError, AttributeError, sympy.SympifyError): + # Fallback to Sympy's string representation if conversion fails + exp_str = s_exponent else: # Symbolic expression exp_str = s_exponent else: # For other types (int, strings not sympy objects) @@ -350,7 +368,7 @@ def _format_exponent_for_display(self, exponent: Any) -> str: exp_str = exp_str.replace("_", r"\_") return exp_str - def _get_gate_name(self, gate: "cirq.Gate") -> str: + def _get_gate_name(self, gate: ops.Gate) -> str: """Determines the appropriate LaTeX string for a given Cirq gate. This method attempts to derive a suitable LaTeX name for the gate, @@ -379,28 +397,29 @@ def _get_gate_name(self, gate: "cirq.Gate") -> str: if not self.show_parameters: return mapped_name try: - info_src = cirq.protocols if hasattr(cirq, "protocols") else cirq - info = info_src.circuit_diagram_info(gate, default=NotImplemented) # type: ignore + # Use protocols directly + info = protocols.circuit_diagram_info(gate, default=NotImplemented) if info is not NotImplemented and info.wire_symbols: s_diag = info.wire_symbols[0] if (op_idx := s_diag.find("(")) != -1 and ( cp_idx := s_diag.rfind(")") ) > op_idx: return f"{mapped_name}({self._format_exponent_for_display(s_diag[op_idx+1:cp_idx])})" - except Exception: + except (ValueError, AttributeError, IndexError): + # Fallback to default string representation if diagram info parsing fails. pass if hasattr(gate, "exponent") and not math.isclose(gate.exponent, 1.0): return f"{mapped_name}({self._format_exponent_for_display(gate.exponent)})" return mapped_name try: - info_src = cirq.protocols if hasattr(cirq, "protocols") else cirq - info = info_src.circuit_diagram_info(gate, default=NotImplemented) # type: ignore + # Use protocols directly + info = protocols.circuit_diagram_info(gate, default=NotImplemented) if info is not NotImplemented and info.wire_symbols: name_cand = info.wire_symbols[0] if not self.show_parameters: base_part = name_cand.split("^")[0].split("**")[0].split("(")[0].strip() - if isinstance(gate, cirq.CZPowGate) and base_part == "@": + if isinstance(gate, ops.CZPowGate) and base_part == "@": base_part = "CZ" mapped_base = self.current_gate_name_map.get(base_part, base_part) return self._format_exponent_for_display(mapped_base) @@ -414,7 +433,7 @@ def _get_gate_name(self, gate: "cirq.Gate") -> str: if not has_exp_in_cand and base_key: recon_base = self.current_gate_name_map.get(base_key, base_key) needs_recon = (name_cand == base_key) or ( - isinstance(gate, cirq.CZPowGate) and name_cand == "@" + isinstance(gate, ops.CZPowGate) and name_cand == "@" ) if needs_recon: name_cand = f"{recon_base}^{{{self._format_exponent_for_display(gate.exponent)}}}" @@ -427,7 +446,8 @@ def _get_gate_name(self, gate: "cirq.Gate") -> str: if len(parts) == 2: fmt_name = f"{parts[0]}^{{{self._format_exponent_for_display(parts[1])}}}" return fmt_name - except Exception: + except (ValueError, AttributeError, IndexError): + # Fallback to default string representation if diagram info parsing fails. pass name_fb = str(gate) @@ -458,38 +478,43 @@ def _get_gate_name(self, gate: "cirq.Gate") -> str: def _get_quantikz_options_string(self) -> str: return f"[{self.quantikz_options}]" if self.quantikz_options else "" - def _render_operation(self, op: "cirq.Operation") -> Dict[int, str]: + def _render_operation(self, op: ops.Operation) -> Dict[int, str]: + """Renders a single Cirq operation into its Quantikz LaTeX string representation. + + Handles various gate types, including single-qubit gates, multi-qubit gates, + measurement gates, and special control/target gates (CNOT, CZ, SWAP). + Applies appropriate styles and labels based on the gate type and + `CircuitToQuantikz` instance settings. + + Args: + op: The `cirq.Operation` object to render. + + Returns: + A dictionary mapping qubit indices to their corresponding LaTeX strings + for the current moment. + """ output, q_indices = {}, sorted([self.qubit_to_index[q] for q in op.qubits]) gate, gate_name_render = op.gate, self._get_gate_name(op.gate) gate_type = type(gate) style_key = gate_type.__name__ # Default style key - # 1. Gates with dedicated Quantikz commands & specific style keys (exp=1) - if isinstance(gate, cirq.CNotPowGate) and hasattr(gate, "exponent") and gate.exponent == 1: + # Determine style key based on gate type and properties + if isinstance(gate, ops.CNotPowGate) and hasattr(gate, "exponent") and gate.exponent == 1: style_key = "CXideal" - elif isinstance(gate, cirq.CZPowGate) and hasattr(gate, "exponent") and gate.exponent == 1: + elif isinstance(gate, ops.CZPowGate) and hasattr(gate, "exponent") and gate.exponent == 1: style_key = "CZideal" - elif ( - isinstance(gate, cirq.SwapPowGate) and hasattr(gate, "exponent") and gate.exponent == 1 - ): + elif isinstance(gate, ops.SwapPowGate) and hasattr(gate, "exponent") and gate.exponent == 1: style_key = "Swapideal" - # 2. MeasurementGate - elif isinstance(gate, cirq.MeasurementGate): + elif isinstance(gate, ops.MeasurementGate): style_key = "Measure" - # 3. Specific named parameterized gates (Rx, FSim, etc.) elif (param_base_name := self._PARAMETERIZED_GATE_BASE_NAMES.get(gate_type)) is not None: style_key = param_base_name - # 4. General PowGates (X, Y, Z, H, and controlled versions if not exp=1) elif (base_key_for_pow := self._EXPONENT_GATE_MAP.get(gate_type)) is not None: - # At this point, if it's a PowGate from the map, its exponent is either != 1, - # or it's a gate like X, Y, Z, H, ISWAP that renders with \gate{} if hasattr(gate, "exponent"): if gate.exponent == 1: - # This covers X, Y, Z, H, ISWAP (exp=1) - style_key = base_key_for_pow # "X", "Y", "Z", "H", "iSwap" + style_key = base_key_for_pow else: - # This covers X_pow, Y_pow, Z_pow, H_pow, CZ_pow, CX_pow, iSWAP_pow style_key = { "X": "X_pow", "Y": "Y_pow", @@ -498,15 +523,11 @@ def _render_operation(self, op: "cirq.Operation") -> Dict[int, str]: "CZ": "CZ_pow", "CX": "CX_pow", "iSwap": "iSWAP_pow", - }.get( - base_key_for_pow, f"{base_key_for_pow}_pow" - ) # Fallback to base_key_pow - else: # Should not happen for gates in _EXPONENT_GATE_MAP as they are PowGates + }.get(base_key_for_pow, f"{base_key_for_pow}_pow") + else: style_key = base_key_for_pow - # else: style_key remains gate_type.__name__ (e.g., for unknown custom gates) style_opts_str = self.gate_styles.get(style_key, "") - # Alias lookup for convenience if primary style_key not found if not style_opts_str: if gate_type.__name__ == "FSimGate": style_opts_str = self.gate_styles.get("FSim", "") @@ -515,13 +536,13 @@ def _render_operation(self, op: "cirq.Operation") -> Dict[int, str]: final_style_tikz = f"[{style_opts_str}]" if style_opts_str else "" - # --- Special Quantikz commands --- - if isinstance(gate, cirq.MeasurementGate): + # Apply special Quantikz commands for specific gate types + if isinstance(gate, ops.MeasurementGate): lbl = gate.key.replace("_", r"\_") if gate.key else "" for i in q_indices: output[i] = f"\\meter{final_style_tikz}{{{lbl}}}" return output - if isinstance(gate, cirq.CNotPowGate) and hasattr(gate, "exponent") and gate.exponent == 1: + if isinstance(gate, ops.CNotPowGate) and hasattr(gate, "exponent") and gate.exponent == 1: c, t = ( (self.qubit_to_index[op.qubits[0]], self.qubit_to_index[op.qubits[1]]) if len(op.qubits) == 2 @@ -532,7 +553,7 @@ def _render_operation(self, op: "cirq.Operation") -> Dict[int, str]: f"\\targ{final_style_tikz}{{}}", ) return output - if isinstance(gate, cirq.CZPowGate) and hasattr(gate, "exponent") and gate.exponent == 1: + if isinstance(gate, ops.CZPowGate) and hasattr(gate, "exponent") and gate.exponent == 1: i1, i2 = ( (q_indices[0], q_indices[1]) if len(q_indices) >= 2 @@ -543,7 +564,7 @@ def _render_operation(self, op: "cirq.Operation") -> Dict[int, str]: f"\\control{final_style_tikz}{{}}", ) return output - if isinstance(gate, cirq.SwapPowGate) and hasattr(gate, "exponent") and gate.exponent == 1: + if isinstance(gate, ops.SwapPowGate) and hasattr(gate, "exponent") and gate.exponent == 1: i1, i2 = ( (q_indices[0], q_indices[1]) if len(q_indices) >= 2 @@ -555,7 +576,7 @@ def _render_operation(self, op: "cirq.Operation") -> Dict[int, str]: ) return output - # --- Generic \gate command --- + # Handle generic \gate command for single and multi-qubit gates if not q_indices: warnings.warn(f"Op {op} has no qubits.") return output @@ -563,10 +584,9 @@ def _render_operation(self, op: "cirq.Operation") -> Dict[int, str]: output[q_indices[0]] = f"\\gate{final_style_tikz}{{{gate_name_render}}}" else: # Multi-qubit gate wires_opt = f"wires={q_indices[-1]-q_indices[0]+1}" - # style_opts_str is already the TikZ style string part, e.g., "fill=blue!20" - if style_opts_str: # If there are actual style options + if style_opts_str: combined_opts = f"{wires_opt}, {style_opts_str}" - else: # Only wires option + else: combined_opts = wires_opt output[q_indices[0]] = f"\\gate[{combined_opts}]{{{gate_name_render}}}" for i in range(1, len(q_indices)): @@ -574,7 +594,15 @@ def _render_operation(self, op: "cirq.Operation") -> Dict[int, str]: return output def _generate_latex_body(self) -> str: + """Generates the main LaTeX body for the circuit diagram. + + Iterates through the circuit's moments, renders each operation, and + arranges them into Quantikz environments. Supports circuit folding + into multiple rows if `fold_at` is specified. + Handles qubit wire labels and ensures correct LaTeX syntax. + """ chunks, m_count, active_chunk = [], 0, [[] for _ in range(self.num_qubits)] + # Add initial wire labels for the first chunk for i in range(self.num_qubits): active_chunk[i].append(f"\\lstick{{{self._get_wire_label(self.sorted_qubits[i], i)}}}") @@ -582,6 +610,7 @@ def _generate_latex_body(self) -> str: m_count += 1 moment_out = ["\\qw"] * self.num_qubits processed_indices = set() + for op in moment: q_idx_op = sorted([self.qubit_to_index[q] for q in op.qubits]) if not q_idx_op: @@ -695,6 +724,18 @@ def _generate_latex_body(self) -> str: return "\n\n\\vspace{1em}\n\n".join(final_parts) def generate_latex_document(self, preamble_template: Optional[str] = None) -> str: + """Generates the complete LaTeX document string for the circuit. + + Combines the preamble, custom preamble, generated circuit body, + and custom postamble into a single LaTeX document string. + + Args: + preamble_template: An optional string to use as the base LaTeX + preamble. If `None`, `DEFAULT_PREAMBLE_TEMPLATE` is used. + + Returns: + A string containing the full LaTeX document, ready to be compiled. + """ preamble = preamble_template or DEFAULT_PREAMBLE_TEMPLATE preamble += f"\n% --- Custom Preamble Injection Point ---\n{self.custom_preamble}\n% --- End Custom Preamble ---\n" doc_parts = [preamble, "\\begin{document}", self._generate_latex_body()] diff --git a/cirq-core/cirq/vis/circuit_to_latex_render.py b/cirq-core/cirq/vis/circuit_to_latex_render.py index 4499424293c..6ed4a18737e 100644 --- a/cirq-core/cirq/vis/circuit_to_latex_render.py +++ b/cirq-core/cirq/vis/circuit_to_latex_render.py @@ -1,9 +1,22 @@ -# -*- coding: utf-8 -*- +# Copyright 2019 The Cirq Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + r"""Provides tools for rendering Cirq circuits as Quantikz LaTeX diagrams. This module offers a high-level interface for converting `cirq.Circuit` objects into visually appealing quantum circuit diagrams using the `quantikz` LaTeX package. -It extends the functionality of `CirqToQuantikz` by handling the full rendering +It extends the functionality of `CircuitToQuantikz` by handling the full rendering pipeline: generating LaTeX, compiling it to PDF using `pdflatex`, and converting the PDF to a PNG image using `pdftoppm`. @@ -12,13 +25,12 @@ Jupyter notebooks. It provides extensive customization options for the output format, file paths, and rendering parameters, including direct control over gate styling, circuit folding, and qubit labeling through arguments passed -to the underlying `CirqToQuantikz` converter. +to the underlying `CircuitToQuantikz` converter. Additionally, the module includes `create_gif_from_ipython_images`, a utility function for animating sequences of images, which can be useful for visualizing dynamic quantum processes or circuit transformations. """ -# mypy: ignore-errors import inspect @@ -33,11 +45,14 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Sequence, Tuple, Type, Union -import cirq import numpy as np import sympy -from .circuit_to_latex_quantikz import CirqToQuantikz +# Import individual Cirq packages as recommended for internal Cirq code +from cirq import circuits, devices, ops, protocols, study + +# Use absolute import for the sibling module +from cirq.vis.circuit_to_latex_quantikz import CircuitToQuantikz __all__ = ["render_circuit", "create_gif_from_ipython_images"] @@ -58,12 +73,15 @@ def display(*args, **kwargs): # type: ignore def Markdown(*args, **kwargs): # type: ignore pass + def get_ipython(*args, **kwargs): # type: ignore + pass + # ============================================================================= # High-Level Wrapper Function # ============================================================================= def render_circuit( - circuit: "cirq.Circuit", + circuit: circuits.Circuit, output_png_path: Optional[str] = None, output_pdf_path: Optional[str] = None, output_tex_path: Optional[str] = None, @@ -73,8 +91,8 @@ def render_circuit( display_png_jupyter: bool = True, cleanup: bool = True, debug: bool = False, - timeout=120, - # Carried over CirqToQuantikz args + timeout: int = 120, + # Carried over CircuitToQuantikz args gate_styles: Optional[Dict[str, str]] = None, quantikz_options: Optional[str] = None, fold_at: Optional[int] = None, @@ -118,25 +136,25 @@ def render_circuit( gate_styles: An optional dictionary mapping gate names (strings) to Quantikz style options (strings). These styles are applied to the generated gates. If `None`, `GATE_STYLES_COLORFUL1` is used. - Passed to `CirqToQuantikz`. + Passed to `CircuitToQuantikz`. quantikz_options: An optional string of global options to pass to the `quantikz` environment (e.g., `"[row sep=0.5em]"`). Passed to - `CirqToQuantikz`. + `CircuitToQuantikz`. fold_at: An optional integer specifying the number of moments after which the circuit should be folded into a new line in the LaTeX - output. If `None`, the circuit is not folded. Passed to `CirqToQuantikz`. + output. If `None`, the circuit is not folded. Passed to `CircuitToQuantikz`. wire_labels: A string specifying how qubit wire labels should be - rendered. Passed to `CirqToQuantikz`. + rendered. Passed to `CircuitToQuantikz`. show_parameters: A boolean indicating whether gate parameters (e.g., exponents for `XPowGate`, angles for `Rx`) should be displayed - in the gate labels. Passed to `CirqToQuantikz`. + in the gate labels. Passed to `CircuitToQuantikz`. gate_name_map: An optional dictionary mapping Cirq gate names (strings) to custom LaTeX strings for rendering. This allows renaming gates - in the output. Passed to `CirqToQuantikz`. + in the output. Passed to `CircuitToQuantikz`. float_precision_exps: An integer specifying the number of decimal - places for formatting floating-point exponents. Passed to `CirqToQuantikz`. + places for formatting floating-point exponents. Passed to `CircuitToQuantikz`. **kwargs: Additional keyword arguments passed directly to the - `CirqToQuantikz` constructor. Refer to `CirqToQuantikz` for + `CircuitToQuantikz` constructor. Refer to `CircuitToQuantikz` for available options. Note that explicit arguments in `render_circuit` will override values provided via `**kwargs`. @@ -153,7 +171,7 @@ def render_circuit( Example: >>> import cirq - >>> from cirq.vis.cirq_circuit_quantikz_render import render_circuit + >>> from cirq.vis.circuit_to_latex_render import render_circuit >>> q0, q1, q2 = cirq.LineQubit.range(3) >>> circuit = cirq.Circuit( ... cirq.H(q0), @@ -185,25 +203,30 @@ def render_circuit( >>> # plt.show() """ - def _debug_print(*args, **kwargs_print): + def _debug_print(*args: Any, **kwargs_print: Any) -> None: if debug: print("[Debug]", *args, **kwargs_print) + # Convert string paths to Path objects and resolve them final_tex_path = Path(output_tex_path).expanduser().resolve() if output_tex_path else None final_pdf_path = Path(output_pdf_path).expanduser().resolve() if output_pdf_path else None final_png_path = Path(output_png_path).expanduser().resolve() if output_png_path else None - pdflatex, pdftoppm = shutil.which("pdflatex"), shutil.which("pdftoppm") - if run_pdflatex and not pdflatex: + # Check for external tool availability + pdflatex_exec = shutil.which("pdflatex") + pdftoppm_exec = shutil.which("pdftoppm") + + if run_pdflatex and not pdflatex_exec: warnings.warn( "'pdflatex' not found. Cannot compile LaTeX. " - "Please install a LaTeX distribution (e.g., TeX Live, MiKTeX). " + "Please install a LaTeX distribution (e.g., TeX Live, MiKTeX) " + "and ensure pdflatex is in your PATH. " "On Ubuntu/Debian: `sudo apt-get install texlive-full` (or `texlive-base` for minimal). " "On macOS: `brew install --cask mactex` (or `brew install texlive` for minimal). " "On Windows: Download and install MiKTeX or TeX Live." ) - run_pdflatex = run_pdftoppm = False - if run_pdftoppm and not pdftoppm: + run_pdflatex = run_pdftoppm = False # Disable dependent steps + if run_pdftoppm and not pdftoppm_exec: warnings.warn( "'pdftoppm' not found. Cannot convert PDF to PNG. " "This tool is part of the Poppler utilities. " @@ -212,17 +235,19 @@ def _debug_print(*args, **kwargs_print): "On Windows: Download Poppler for Windows (e.g., from Poppler for Windows GitHub releases) " "and add its `bin` directory to your system PATH." ) - run_pdftoppm = False + run_pdftoppm = False # Disable dependent step try: + # Use TemporaryDirectory for safe handling of temporary files with tempfile.TemporaryDirectory() as tmpdir_s: tmp_p = Path(tmpdir_s) - _debug_print(f"Temp dir: {tmp_p}") - base, tmp_tex = "circuit_render", tmp_p / "circuit_render.tex" - tmp_pdf, tmp_png_prefix = tmp_p / f"{base}.pdf", str(tmp_p / base) - tmp_png_out = tmp_p / f"{base}.png" + _debug_print(f"Temporary directory created at: {tmp_p}") + base_name = "circuit_render" + tmp_tex_path = tmp_p / f"{base_name}.tex" + tmp_pdf_path = tmp_p / f"{base_name}.pdf" + tmp_png_path = tmp_p / f"{base_name}.png" # Single PNG output from pdftoppm - # Prepare kwargs for CirqToQuantikz, prioritizing explicit args + # Prepare kwargs for CircuitToQuantikz, prioritizing explicit args converter_kwargs = { "gate_styles": gate_styles, "quantikz_options": quantikz_options, @@ -235,197 +260,210 @@ def _debug_print(*args, **kwargs_print): } try: - converter = CirqToQuantikz(circuit, **converter_kwargs) + converter = CircuitToQuantikz(circuit, **converter_kwargs) except Exception as e: - print(f"Error init CirqToQuantikz: {e}") + print(f"Error initializing CircuitToQuantikz: {e}") + if debug: + traceback.print_exc() return None - _debug_print("Generating LaTeX...") + _debug_print("Generating LaTeX source...") try: latex_s = converter.generate_latex_document() except Exception as e: - print(f"Error gen LaTeX: {e}") + print(f"Error generating LaTeX document: {e}") if debug: traceback.print_exc() return None if debug: - _debug_print("LaTeX (first 500 chars):\n", latex_s[:500] + "...") + _debug_print("Generated LaTeX (first 500 chars):\n", latex_s[:500] + "...") try: - tmp_tex.write_text(latex_s, encoding="utf-8") + tmp_tex_path.write_text(latex_s, encoding="utf-8") + _debug_print(f"LaTeX saved to temporary file: {tmp_tex_path}") except IOError as e: - print(f"Error writing temp TEX {tmp_tex}: {e}") + print(f"Error writing temporary LaTeX file {tmp_tex_path}: {e}") return None - pdf_ok = False - if run_pdflatex and pdflatex: - _debug_print(f"Running pdflatex ({pdflatex})...") + pdf_generated = False + if run_pdflatex and pdflatex_exec: + _debug_print(f"Running pdflatex ({pdflatex_exec})...") + # Run pdflatex twice for correct cross-references and layout cmd_latex = [ - pdflatex, - "-interaction=nonstopmode", - "-halt-on-error", + pdflatex_exec, + "-interaction=nonstopmode", # Don't prompt for input + "-halt-on-error", # Exit on first error "-output-directory", - str(tmp_p), - str(tmp_tex), + str(tmp_p), # Output files to temp directory + str(tmp_tex_path), ] latex_failed = False - for i in range(2): + for i in range(2): # Run pdflatex twice _debug_print(f" pdflatex run {i+1}/2...") proc = subprocess.run( cmd_latex, capture_output=True, text=True, - check=False, + check=False, # Don't raise CalledProcessError immediately cwd=tmp_p, timeout=timeout, ) if proc.returncode != 0: latex_failed = True - print(f"!!! pdflatex failed run {i+1} (code {proc.returncode}) !!!") - log_f = tmp_tex.with_suffix(".log") - if log_f.exists(): + print(f"!!! pdflatex failed on run {i+1} (exit code {proc.returncode}) !!!") + log_file = tmp_tex_path.with_suffix(".log") + if log_file.exists(): print( - f"--- Tail of {log_f.name} ---\n{log_f.read_text(errors='ignore')[-2000:]}" + f"--- Tail of {log_file.name} ---\n{log_file.read_text(errors='ignore')[-2000:]}" ) else: if proc.stdout: print(f"--- pdflatex stdout ---\n{proc.stdout[-2000:]}") if proc.stderr: print(f"--- pdflatex stderr ---\n{proc.stderr}") - if final_tex_path: - try: - final_tex_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(tmp_tex, final_tex_path) - print(f"Problem TEX: {final_tex_path}") - except Exception as e_cp: - print(f"Error copying TEX: {e_cp}") - else: - print(f"Problem TEX was: {tmp_tex}") - break - elif not tmp_pdf.is_file() and i == 1: + break # Exit loop if pdflatex failed + elif not tmp_pdf_path.is_file() and i == 1: latex_failed = True - print("!!! pdflatex ok, but PDF not found. Check logs. !!!") - log_f = tmp_tex.with_suffix(".log") - if log_f.exists(): + print("!!! pdflatex completed, but PDF file not found. Check logs. !!!") + log_file = tmp_tex_path.with_suffix(".log") + if log_file.exists(): print( - f"--- Tail of {log_f.name} ---\n{log_f.read_text(errors='ignore')[-2000:]}" + f"--- Tail of {log_file.name} ---\n{log_file.read_text(errors='ignore')[-2000:]}" ) break - elif tmp_pdf.is_file(): - _debug_print(f" Run {i+1}/2 OK (PDF exists).") - if not latex_failed and tmp_pdf.is_file(): - pdf_ok = True - _debug_print(f"PDF OK: {tmp_pdf}") - elif not latex_failed: - print("pdflatex seemed OK but no PDF.") + elif tmp_pdf_path.is_file(): + _debug_print(f" pdflatex run {i+1}/2 successful (PDF exists).") + + if not latex_failed and tmp_pdf_path.is_file(): + pdf_generated = True + _debug_print(f"PDF successfully generated at: {tmp_pdf_path}") + elif not latex_failed: # pdflatex returned 0 but PDF not found + print("pdflatex reported success but PDF file was not found.") if latex_failed: - return None - - png_ok, png_disp_path = False, None - if run_pdftoppm and pdftoppm and pdf_ok: - _debug_print(f"Running pdftoppm ({pdftoppm})...") + return None # Critical failure, return None + + png_generated, final_output_path_for_display = False, None + if run_pdftoppm and pdftoppm_exec and pdf_generated: + _debug_print(f"Running pdftoppm ({pdftoppm_exec})...") + # pdftoppm outputs to -.png if multiple pages, + # or .png if single page with -singlefile. + # We expect a single page output here. cmd_ppm = [ - pdftoppm, + pdftoppm_exec, "-png", f"-r", str(dpi), - "-singlefile", - str(tmp_pdf), - tmp_png_prefix, + "-singlefile", # Ensures single output file for single-page PDFs + str(tmp_pdf_path), + str(tmp_p / base_name), # Output prefix for the PNG ] try: proc = subprocess.run( cmd_ppm, capture_output=True, text=True, - check=True, + check=True, # Raise CalledProcessError for non-zero exit codes cwd=tmp_p, timeout=timeout, ) - if tmp_png_out.is_file(): - png_ok, png_disp_path = True, tmp_png_out - _debug_print(f"PNG OK: {tmp_png_out}") + if tmp_png_path.is_file(): + png_generated = True + _debug_print(f"PNG successfully generated at: {tmp_png_path}") else: - print(f"!!! pdftoppm OK but PNG ({tmp_png_out}) not found. !!!") + print(f"!!! pdftoppm succeeded but PNG ({tmp_png_path}) not found. !!!") except subprocess.CalledProcessError as e_ppm: print( - f"!!! pdftoppm failed (code {e_ppm.returncode}) !!!\n{e_ppm.stdout}\n{e_ppm.stderr}" + f"!!! pdftoppm failed (exit code {e_ppm.returncode}) !!!\n" + f"Stdout: {e_ppm.stdout}\nStderr: {e_ppm.stderr}" ) except subprocess.TimeoutExpired: print("!!! pdftoppm timed out. !!!") except Exception as e_ppm_other: - print(f"pdftoppm error: {e_ppm_other}") + print(f"An unexpected error occurred during pdftoppm: {e_ppm_other}") - copied = {} - if final_tex_path and tmp_tex.exists(): + # Copy files to final destinations if requested + if final_tex_path and tmp_tex_path.exists(): try: final_tex_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(tmp_tex, final_tex_path) - copied["tex"] = final_tex_path + shutil.copy2(tmp_tex_path, final_tex_path) + _debug_print(f"Copied .tex to: {final_tex_path}") except Exception as e: - print(f"Error copying TEX: {e}") - if final_pdf_path and pdf_ok and tmp_pdf.exists(): + print(f"Error copying .tex file to final path: {e}") + if final_pdf_path and pdf_generated and tmp_pdf_path.exists(): try: final_pdf_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(tmp_pdf, final_pdf_path) - copied["pdf"] = final_pdf_path + shutil.copy2(tmp_pdf_path, final_pdf_path) + _debug_print(f"Copied .pdf to: {final_pdf_path}") except Exception as e: - print(f"Error copying PDF: {e}") - if final_png_path and png_ok and tmp_png_out.exists(): + print(f"Error copying .pdf file to final path: {e}") + if final_png_path and png_generated and tmp_png_path.exists(): try: final_png_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(tmp_png_out, final_png_path) - copied["png"] = final_png_path - png_disp_path = final_png_path + shutil.copy2(tmp_png_path, final_png_path) + _debug_print(f"Copied .png to: {final_png_path}") + final_output_path_for_display = final_png_path # Use the final path for display except Exception as e: - print(f"Error copying PNG: {e}") - elif ( - png_ok and tmp_png_out.exists() and not final_png_path - ): # Only use temp if not copied to final - png_disp_path = tmp_png_out + print(f"Error copying .png file to final path: {e}") + elif png_generated and tmp_png_path.exists() and not final_png_path: + # If PNG was generated but no specific output_png_path, use the temp path for display + final_output_path_for_display = tmp_png_path jupyter_image_object: Optional["Image"] = None - if display_png_jupyter and png_disp_path and png_disp_path.is_file(): - _debug_print(f"Displaying PNG: {png_disp_path}") + if ( + display_png_jupyter + and final_output_path_for_display + and final_output_path_for_display.is_file() + ): + _debug_print( + f"Attempting to display PNG in Jupyter: {final_output_path_for_display}" + ) if _HAS_IPYTHON: try: + # Check if running in a Jupyter-like environment that supports display + # get_ipython() returns a shell object if in IPython, None otherwise. + # ZMQInteractiveShell is for Jupyter notebooks, TerminalInteractiveShell for IPython console. sh_obj = get_ipython() # type: ignore - if sh_obj is None: - print(f"Not IPython. PNG: {png_disp_path}") + if ( + sh_obj is not None + and sh_obj.__class__.__name__ == "ZMQInteractiveShell" + ): + current_image_obj = Image(filename=str(final_output_path_for_display)) + display(current_image_obj) + jupyter_image_object = current_image_obj + _debug_print("PNG displayed in Jupyter notebook.") else: - sh_name = sh_obj.__class__.__name__ - # Create the Image object regardless of shell type for potential return - # but only display it in ZMQInteractiveShell. - current_image_obj = Image(filename=str(png_disp_path)) - if sh_name == "ZMQInteractiveShell": - display(current_image_obj) - jupyter_image_object = current_image_obj # Store if displayed - elif sh_name == "TerminalInteractiveShell": - print(f"Terminal IPython. PNG: {png_disp_path}") - # In terminal, we might still want to return the object if requested - jupyter_image_object = current_image_obj - else: - print(f"Display might not work ({sh_name}). PNG: {png_disp_path}") - jupyter_image_object = current_image_obj + _debug_print( + "Not in a ZMQInteractiveShell (Jupyter notebook). PNG not displayed inline." + ) + # Still create Image object if it might be returned later + jupyter_image_object = Image( + filename=str(final_output_path_for_display) + ) except Exception as e_disp: - print(f"PNG display error: {e_disp}") + print(f"Error displaying PNG in Jupyter: {e_disp}") + if debug: + traceback.print_exc() jupyter_image_object = None else: - print(f"IPython not available. PNG: {png_disp_path}") - elif display_png_jupyter and (not png_disp_path or not png_disp_path.is_file()): + _debug_print("IPython not available, cannot display PNG inline.") + elif display_png_jupyter and ( + not final_output_path_for_display or not final_output_path_for_display.is_file() + ): if run_pdflatex and run_pdftoppm: - print("PNG display requested, but PNG not created/found.") + print("PNG display requested, but PNG not successfully created/found.") - if not cleanup and debug: - _debug_print("cleanup=False; temp dir removed by context manager.") - - # Removed the deliberate logical error. + # Determine return value based on requested outputs if jupyter_image_object: return jupyter_image_object - elif copied.get("png"): - return str(copied["png"]) - return None # Fallback if nothing to return + elif final_png_path and final_png_path.is_file(): + return str(final_png_path) # Return path to saved PNG + elif output_tex_path and final_tex_path and final_tex_path.is_file(): + # If only LaTeX string was requested, read it back from the saved file + # This is a bit indirect, but aligns with returning a string path + return final_tex_path.read_text(encoding="utf-8") + return None # Default return if no specific output is generated or requested as return + except subprocess.TimeoutExpired as e_timeout: print(f"!!! Process timed out: {e_timeout} !!!") if debug: @@ -439,10 +477,16 @@ def _debug_print(*args, **kwargs_print): def create_gif_from_ipython_images( - image_list: "list[Image]", output_filename: str, fps: int, **kwargs -): + image_list: List["Image"], output_filename: str, fps: int, **kwargs: Any +) -> None: r"""Creates a GIF from a list of IPython.core.display.Image objects and saves it. + This utility requires `ImageMagick` to be installed and available in your + system's PATH, specifically the `convert` command. On Debian-based systems, + you can install it with `sudo apt-get install imagemagick`. + Additionally, if working with PDF inputs, `poppler-tools` might be needed + (`sudo apt-get install poppler-utils`). + The resulting GIF will loop indefinitely by default. Args: @@ -451,14 +495,11 @@ def create_gif_from_ipython_images( output_filename: The desired filename for the output GIF (e.g., "animation.gif"). fps: The frame rate (frames per second) for the GIF. - **kwargs: Additional keyword arguments to pass to `imageio.mimsave()`. - For example, `duration` (scalar or list) can be used to set - frame durations instead of fps, or `loop` (default 0 for infinite) - can be set to a different number of loops. If 'loop' is provided - in kwargs, it will override the default infinite loop. - - Returns: - None. The function saves the GIF to the specified `output_filename`. + **kwargs: Additional keyword arguments passed directly to ImageMagick's + `convert` command. Common options include: + - `delay`: Time between frames (e.g., `delay=20` for 200ms). + - `loop`: Number of times to loop (e.g., `loop=0` for infinite). + - `duration`: Total duration of the animation in seconds (overrides delay). """ try: import imageio @@ -502,8 +543,9 @@ def create_gif_from_ipython_images( if "loop" not in kwargs: kwargs["loop"] = 0 - if "duration" in kwargs: - pass + # The 'duration' check was deemed unneeded by reviewer. + # if "duration" in kwargs: + # pass try: imageio.mimsave(output_filename, frames, fps=fps, **kwargs) From 8d55e5c81ab0288ef073c88b7e7970bab65f9ec6 Mon Sep 17 00:00:00 2001 From: Zlatko Minev Date: Sat, 17 May 2025 10:37:24 -0700 Subject: [PATCH 3/9] Added test file as suggested by Doug --- .../vis/circuit_to_latex_quantikz_test.py | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 cirq-core/cirq/vis/circuit_to_latex_quantikz_test.py diff --git a/cirq-core/cirq/vis/circuit_to_latex_quantikz_test.py b/cirq-core/cirq/vis/circuit_to_latex_quantikz_test.py new file mode 100644 index 00000000000..725951eafa5 --- /dev/null +++ b/cirq-core/cirq/vis/circuit_to_latex_quantikz_test.py @@ -0,0 +1,147 @@ +# Copyright 2019 The Cirq Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest +import cirq +import sympy +import numpy as np + +# Import the class directly for testing +from cirq.vis.circuit_to_latex_quantikz import CircuitToQuantikz, DEFAULT_PREAMBLE_TEMPLATE + + +def test_empty_circuit_raises_value_error(): + """Test that an empty circuit raises a ValueError.""" + empty_circuit = cirq.Circuit() + with pytest.raises(ValueError, match="Input circuit cannot be empty."): + CircuitToQuantikz(empty_circuit) + + +def test_basic_circuit_conversion(): + """Test a simple circuit conversion to LaTeX.""" + q0, q1 = cirq.LineQubit.range(2) + circuit = cirq.Circuit(cirq.H(q0), cirq.CNOT(q0, q1), cirq.measure(q0, key='m0')) + converter = CircuitToQuantikz(circuit) + latex_code = converter.generate_latex_document() + # print(latex_code) + + assert r"\lstick{$q_{0}$} & \gate[" in latex_code + assert "& \meter[" in latex_code + assert "\\begin{quantikz}" in latex_code + assert "\\end{quantikz}" in latex_code + assert DEFAULT_PREAMBLE_TEMPLATE.strip() in latex_code.strip() + + +def test_parameter_display(): + """Test that gate parameters are correctly displayed or hidden.""" + + q_param = cirq.LineQubit(0) + alpha = sympy.Symbol("\\alpha") # Parameter symbol + beta = sympy.Symbol("\\beta") # Parameter symbol + param_circuit = cirq.Circuit( + cirq.H(q_param), + cirq.rz(alpha).on(q_param), # Parameterized gate + cirq.X(q_param), + cirq.Y(q_param) ** 0.25, # Parameterized exponent + cirq.X(q_param), # Parameterized exponent + cirq.rx(beta).on(q_param), # Parameterized gate + cirq.H(q_param), + cirq.measure(q_param, key="result"), + ) + print(param_circuit) + # Test with show_parameters=True (default) + converter_show_params = CircuitToQuantikz(param_circuit, show_parameters=True) + latex_show_params = converter_show_params.generate_latex_document() + print(latex_show_params) + assert r"R_{Z}(\alpha)" in latex_show_params + assert r"Y^{0.25}" in latex_show_params + assert r"H" in latex_show_params + + +def test_custom_gate_name_map(): + """Test custom gate name mapping.""" + q = cirq.LineQubit(0) + circuit = cirq.Circuit(cirq.H(q), cirq.X(q)) + custom_map = {"H": "Hadamard"} + converter = CircuitToQuantikz(circuit, gate_name_map=custom_map) + latex_code = converter.generate_latex_document() + print(latex_code) + + assert r"Hadamard}" in latex_code + assert r"{H}" not in latex_code # Ensure original H is not there + + +def test_wire_labels(): + """Test different wire labeling options.""" + q0, q1 = cirq.NamedQubit('alice'), cirq.LineQubit(10) + circuit = cirq.Circuit(cirq.H(q0), cirq.X(q1)) + + # Default 'q' labels + converter_q = CircuitToQuantikz(circuit, wire_labels="q") + latex_q = converter_q.generate_latex_document() + # print(latex_q) + assert r"\lstick{$q_{0}$}" in latex_q + assert r"\lstick{$q_{1}$}" in latex_q + + # 'index' labels + converter_idx = CircuitToQuantikz(circuit, wire_labels="index") + latex_idx = converter_idx.generate_latex_document() + assert r"\lstick{$0$}" in latex_idx + assert r"\lstick{$1$}" in latex_idx + + +def test_custom_preamble_and_postamble(): + """Test custom preamble and postamble injection.""" + q = cirq.LineQubit(0) + circuit = cirq.Circuit(cirq.H(q)) + custom_preamble_text = r"\usepackage{mycustompackage}" + custom_postamble_text = r"\end{tikzpicture}" + + converter = CircuitToQuantikz( + circuit, custom_preamble=custom_preamble_text, custom_postamble=custom_postamble_text + ) + latex_code = converter.generate_latex_document() + + assert custom_preamble_text in latex_code + assert custom_postamble_text in latex_code + assert "% --- Custom Preamble Injection Point ---" in latex_code + assert "% --- Custom Postamble Start ---" in latex_code + + +def test_quantikz_options(): + """Test global quantikz options.""" + q = cirq.LineQubit(0) + circuit = cirq.Circuit(cirq.H(q)) + options = "column sep=1em, row sep=0.5em" + converter = CircuitToQuantikz(circuit, quantikz_options=options) + latex_code = converter.generate_latex_document() + + assert f"\\begin{{quantikz}}[{options}]" in latex_code + + +def test_float_precision_exponents(): + """Test formatting of floating-point exponents.""" + q = cirq.LineQubit(0) + circuit = cirq.Circuit(cirq.X(q) ** 0.12345, cirq.Y(q) ** 0.5) + converter = CircuitToQuantikz(circuit, float_precision_exps=3) + latex_code = converter.generate_latex_document() + print(latex_code) + assert r"X^{0.123}" in latex_code + assert r"Y^{0.5}" in latex_code # Should still be 0.5, not 0.500 + + converter_int_exp = CircuitToQuantikz(circuit, float_precision_exps=0) + latex_int_exp = converter_int_exp.generate_latex_document() + # print(latex_int_exp) + assert r"X^{0.0}" in latex_int_exp # 0.12345 rounded to 0 + assert r"Y^{0.0}" in latex_int_exp # 0.5 is still 0.5 if not integer From a565286eaa9a5c9279aba48e350636e7bfea3654 Mon Sep 17 00:00:00 2001 From: Doug Strain Date: Fri, 1 Aug 2025 06:14:11 -0700 Subject: [PATCH 4/9] Update coverage and clean up CI and redundant conditions. --- .../cirq/vis/circuit_to_latex_quantikz.py | 300 ++++++++---------- .../vis/circuit_to_latex_quantikz_test.py | 103 +++++- cirq-core/cirq/vis/circuit_to_latex_render.py | 174 +++++----- .../cirq/vis/circuit_to_latex_render_test.py | 39 +++ 4 files changed, 354 insertions(+), 262 deletions(-) create mode 100644 cirq-core/cirq/vis/circuit_to_latex_render_test.py diff --git a/cirq-core/cirq/vis/circuit_to_latex_quantikz.py b/cirq-core/cirq/vis/circuit_to_latex_quantikz.py index 7e20660e110..bcb455b5e09 100644 --- a/cirq-core/cirq/vis/circuit_to_latex_quantikz.py +++ b/cirq-core/cirq/vis/circuit_to_latex_quantikz.py @@ -1,4 +1,4 @@ -# Copyright 2019 The Cirq Developers +# Copyright 2025 The Cirq Developers # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -61,17 +61,17 @@ \end{document} """ +from __future__ import annotations + import math import warnings -from pathlib import Path -from typing import Any, Dict, List, Optional, Type +from typing import Any, Optional -import numpy as np import sympy -from cirq import circuits, devices, ops, protocols +from cirq import circuits, ops, protocols -__all__ = ["CircuitToQuantikz", "DEFAULT_PREAMBLE_TEMPLATE", "GATE_STYLES_COLORFUL1"] +__all__ = ["CircuitToQuantikz", "DEFAULT_PREAMBLE_TEMPLATE", "GATE_STYLES_COLORFUL"] # ============================================================================= @@ -102,7 +102,7 @@ _gray_gate_style = r"style={fill=gray!20}" # For Measure _noisy_channel_style = r"style={fill=red!20}" -GATE_STYLES_COLORFUL1 = { +GATE_STYLES_COLORFUL = { "H": _yellow_gate_style, "_PauliX": _Pauli_gate_style, # ops.X(q_param) "_PauliY": _Pauli_gate_style, # ops.Y(q_param) @@ -135,8 +135,8 @@ # Initialize gate maps globally as recommended -_SIMPLE_GATE_MAP: Dict[Type[ops.Gate], str] = {ops.MeasurementGate: "Measure"} -_EXPONENT_GATE_MAP: Dict[Type[ops.Gate], str] = { +_SIMPLE_GATE_MAP: dict[type[ops.Gate], str] = {ops.MeasurementGate: "Measure"} +_EXPONENT_GATE_MAP: dict[type[ops.Gate], str] = { ops.XPowGate: "X", ops.YPowGate: "Y", ops.ZPowGate: "Z", @@ -146,7 +146,16 @@ ops.SwapPowGate: "Swap", ops.ISwapPowGate: "iSwap", } -_PARAMETERIZED_GATE_BASE_NAMES: Dict[Type[ops.Gate], str] = {} +_GATE_NAME_MAP = { + "Rx": r"R_{X}", + "Ry": r"R_{Y}", + "Rz": r"R_{Z}", + "FSim": r"\mathrm{fSim}", + "PhasedXZ": r"\Phi", + "CZ": r"\mathrm{CZ}", + "CX": r"\mathrm{CX}", + "iSwap": r"i\mathrm{SWAP}", +} _param_gate_specs = [ ("Rx", getattr(ops, "Rx", None)), ("Ry", getattr(ops, "Ry", None)), @@ -154,10 +163,9 @@ ("PhasedXZ", getattr(ops, "PhasedXZGate", None)), ("FSim", getattr(ops, "FSimGate", None)), ] -if _param_gate_specs: - for _name, _gate_cls in _param_gate_specs: - if _gate_cls: - _PARAMETERIZED_GATE_BASE_NAMES[_gate_cls] = _name +_PARAMETERIZED_GATE_BASE_NAMES: dict[type[ops.Gate], str] = { + _gate_cls: _name for _name, _gate_cls in _param_gate_specs if _gate_cls is not None +} # ============================================================================= @@ -175,7 +183,7 @@ class CircuitToQuantikz: circuit: The `cirq.Circuit` object to be converted. gate_styles: An optional dictionary mapping gate names (strings) to Quantikz style options (strings). These styles are applied to - the generated gates. If `None`, `GATE_STYLES_COLORFUL1` is used. + the generated gates. If `None`, `GATE_STYLES_COLORFUL` is used. quantikz_options: An optional string of global options to pass to the `quantikz` environment (e.g., `"[row sep=0.5em]"`). fold_at: An optional integer specifying the number of moments after @@ -190,7 +198,7 @@ class CircuitToQuantikz: - `"q"`: Labels as $q_0, q_1, \dots$ - `"index"`: Labels as $0, 1, \dots$ - `"qid"`: Labels as the string representation of the `cirq.Qid` - - Any other value defaults to `"q"`. + - Any other value defaults to `"qid"`. show_parameters: A boolean indicating whether gate parameters (e.g., exponents for `XPowGate`, angles for `Rx`) should be displayed in the gate labels. @@ -202,51 +210,44 @@ class CircuitToQuantikz: float_precision_angles: An integer specifying the number of decimal places for formatting floating-point angles. (Note: Not fully implemented in current version for all angle types). + qubit_order: Determines how qubits are ordered in the diagram. Raises: ValueError: If the input `circuit` is empty or contains no qubits. """ - GATE_NAME_MAP = { - "Rx": r"R_{X}", - "Ry": r"R_{Y}", - "Rz": r"R_{Z}", - "FSim": r"\mathrm{fSim}", - "PhasedXZ": r"\Phi", - "CZ": r"\mathrm{CZ}", - "CX": r"\mathrm{CX}", - "iSwap": r"i\mathrm{SWAP}", - } - def __init__( self, circuit: circuits.Circuit, *, - gate_styles: Optional[Dict[str, str]] = None, + gate_styles: Optional[dict[str, str]] = None, quantikz_options: Optional[str] = None, fold_at: Optional[int] = None, custom_preamble: str = "", custom_postamble: str = "", - wire_labels: str = "q", + wire_labels: str = "qid", show_parameters: bool = True, - gate_name_map: Optional[Dict[str, str]] = None, + gate_name_map: Optional[dict[str, str]] = None, float_precision_exps: int = 2, float_precision_angles: int = 2, + qubit_order: ops.QubitOrderOrList = ops.QubitOrder.DEFAULT, ): if not circuit: raise ValueError("Input circuit cannot be empty.") self.circuit = circuit - self.gate_styles = gate_styles if gate_styles is not None else GATE_STYLES_COLORFUL1.copy() + self.gate_styles = gate_styles if gate_styles is not None else GATE_STYLES_COLORFUL.copy() self.quantikz_options = quantikz_options or "" self.fold_at = fold_at self.custom_preamble = custom_preamble self.custom_postamble = custom_postamble self.wire_labels = wire_labels self.show_parameters = show_parameters - self.current_gate_name_map = self.GATE_NAME_MAP.copy() + self.current_gate_name_map = _GATE_NAME_MAP.copy() if gate_name_map: self.current_gate_name_map.update(gate_name_map) - self.sorted_qubits = self._get_sorted_qubits() + self.sorted_qubits = ops.QubitOrder.as_qubit_order(qubit_order).order_for( + self.circuit.all_qubits() + ) if not self.sorted_qubits: raise ValueError("Circuit contains no qubits.") self.qubit_to_index = self._map_qubits_to_indices() @@ -254,22 +255,7 @@ def __init__( self.float_precision_exps = float_precision_exps self.float_precision_angles = float_precision_angles - # Gate maps are now global, no need to initialize here. - self._SIMPLE_GATE_MAP = _SIMPLE_GATE_MAP - self._EXPONENT_GATE_MAP = _EXPONENT_GATE_MAP - self._PARAMETERIZED_GATE_BASE_NAMES = _PARAMETERIZED_GATE_BASE_NAMES - - def _get_sorted_qubits(self) -> List[ops.Qid]: - """Determines and returns a sorted list of all unique qubits in the circuit. - - Returns: - A list of `cirq.Qid` objects, sorted to ensure consistent qubit - ordering in the LaTeX output. - """ - qubits = set(q for moment in self.circuit for op in moment for q in op.qubits) - return sorted(list(qubits)) - - def _map_qubits_to_indices(self) -> Dict[ops.Qid, int]: + def _map_qubits_to_indices(self) -> dict[ops.Qid, int]: """Creates a mapping from `cirq.Qid` objects to their corresponding integer indices based on the sorted qubit order. @@ -279,6 +265,13 @@ def _map_qubits_to_indices(self) -> Dict[ops.Qid, int]: """ return {q: i for i, q in enumerate(self.sorted_qubits)} + def _escape_string(self, label) -> str: + """Escape labels for latex.""" + label = label.replace("π", r"\pi") + if "_" in label and "\\" not in label: + label = label.replace("_", r"\_") + return label + def _get_wire_label(self, qubit: ops.Qid, index: int) -> str: r"""Generates the LaTeX string for a qubit wire label. @@ -290,14 +283,13 @@ def _get_wire_label(self, qubit: ops.Qid, index: int) -> str: A string formatted as a LaTeX math-mode label (e.g., "$q_0$", "$3$", or "$q_{qubit\_name}$"). """ - s = str(qubit).replace("_", r"\_").replace(" ", r"\,") lbl = ( f"q_{{{index}}}" if self.wire_labels == "q" else ( str(index) if self.wire_labels == "index" - else s if self.wire_labels == "qid" else f"q_{{{index}}}" + else str(self._escape_string(str(qubit))).replace(" ", r"\,") ) ) return f"${lbl}$" @@ -350,9 +342,14 @@ def _format_exponent_for_display(self, exponent: Any) -> str: else: # Format to specified precision for rounding rounded_str = format(py_float, float_format_string) - # Convert back to float and then to string to remove unnecessary trailing zeros + # Convert back to float then to string to remove unnecessary trailing zeros exp_str = str(float(rounded_str)) - except (TypeError, ValueError, AttributeError, sympy.SympifyError): + except ( + TypeError, + ValueError, + AttributeError, + sympy.SympifyError, + ): # pragma: nocover # Fallback to Sympy's string representation if conversion fails exp_str = s_exponent else: # Symbolic expression @@ -360,13 +357,7 @@ def _format_exponent_for_display(self, exponent: Any) -> str: else: # For other types (int, strings not sympy objects) exp_str = str(exponent) - # LaTeX replacements for pi - exp_str = exp_str.replace("pi", r"\pi").replace("π", r"\pi") - - # Handle underscores: replace "_" with "\_" if not part of a LaTeX command - if "_" in exp_str and "\\" not in exp_str: - exp_str = exp_str.replace("_", r"\_") - return exp_str + return self._escape_string(exp_str) def _get_gate_name(self, gate: ops.Gate) -> str: """Determines the appropriate LaTeX string for a given Cirq gate. @@ -383,16 +374,16 @@ def _get_gate_name(self, gate: ops.Gate) -> str: "Rx(0.5)", "CZ"). """ gate_type = type(gate) - if gate_type.__name__ == "ThermalChannel": + if gate_type.__name__ == "ThermalChannel": # pragma: nocover return "\\Lambda_\\mathrm{th}" - if (simple_name := self._SIMPLE_GATE_MAP.get(gate_type)) is not None: + if (simple_name := _SIMPLE_GATE_MAP.get(gate_type)) is not None: return simple_name - base_key = self._EXPONENT_GATE_MAP.get(gate_type) + base_key = _EXPONENT_GATE_MAP.get(gate_type) if base_key is not None and hasattr(gate, "exponent") and gate.exponent == 1: return self.current_gate_name_map.get(base_key, base_key) - if (param_base_key := self._PARAMETERIZED_GATE_BASE_NAMES.get(gate_type)) is not None: + if (param_base_key := _PARAMETERIZED_GATE_BASE_NAMES.get(gate_type)) is not None: mapped_name = self.current_gate_name_map.get(param_base_key, param_base_key) if not self.show_parameters: return mapped_name @@ -404,13 +395,18 @@ def _get_gate_name(self, gate: ops.Gate) -> str: if (op_idx := s_diag.find("(")) != -1 and ( cp_idx := s_diag.rfind(")") ) > op_idx: - return f"{mapped_name}({self._format_exponent_for_display(s_diag[op_idx+1:cp_idx])})" - except (ValueError, AttributeError, IndexError): + return ( + f"{mapped_name}" + f"({self._format_exponent_for_display(s_diag[op_idx+1:cp_idx])})" + ) + except (ValueError, AttributeError, IndexError): # pragma: nocover # Fallback to default string representation if diagram info parsing fails. pass - if hasattr(gate, "exponent") and not math.isclose(gate.exponent, 1.0): + if hasattr(gate, "exponent") and not math.isclose( + gate.exponent, 1.0 + ): # pragma: nocover return f"{mapped_name}({self._format_exponent_for_display(gate.exponent)})" - return mapped_name + return mapped_name # pragma: nocover try: # Use protocols directly @@ -426,8 +422,8 @@ def _get_gate_name(self, gate: ops.Gate) -> str: if ( hasattr(gate, "exponent") - and not math.isclose(gate.exponent, 1.0) - and isinstance(gate, tuple(self._EXPONENT_GATE_MAP.keys())) + and not (isinstance(gate.exponent, float) and math.isclose(gate.exponent, 1.0)) + and isinstance(gate, tuple(_EXPONENT_GATE_MAP.keys())) ): has_exp_in_cand = ("^" in name_cand) or ("**" in name_cand) if not has_exp_in_cand and base_key: @@ -436,49 +432,46 @@ def _get_gate_name(self, gate: ops.Gate) -> str: isinstance(gate, ops.CZPowGate) and name_cand == "@" ) if needs_recon: - name_cand = f"{recon_base}^{{{self._format_exponent_for_display(gate.exponent)}}}" - - fmt_name = name_cand.replace("π", r"\pi") - if "_" in fmt_name and "\\" not in fmt_name: - fmt_name = fmt_name.replace("_", r"\_") - if "**" in fmt_name: - parts = fmt_name.split("**", 1) - if len(parts) == 2: - fmt_name = f"{parts[0]}^{{{self._format_exponent_for_display(parts[1])}}}" + name_cand = ( + f"{recon_base}^" + f"{{{self._format_exponent_for_display(gate.exponent)}}}" + ) + + fmt_name = self._escape_string(name_cand) + parts = fmt_name.split("**", 1) + if len(parts) == 2: # pragma: nocover + fmt_name = f"{parts[0]}^{{{self._format_exponent_for_display(parts[1])}}}" return fmt_name - except (ValueError, AttributeError, IndexError): + except (ValueError, AttributeError, IndexError): # pragma: nocover # Fallback to default string representation if diagram info parsing fails. pass name_fb = str(gate) - if name_fb.endswith("Gate"): - name_fb = name_fb[:-4] + if name_fb.endswith("**1.0"): + name_fb = name_fb[:-5] + if name_fb.endswith("**1"): + name_fb = name_fb[:-3] if name_fb.endswith("()"): name_fb = name_fb[:-2] + if name_fb.endswith("Gate"): + name_fb = name_fb[:-4] if not self.show_parameters: base_fb = name_fb.split("**")[0].split("(")[0].strip() - fb_key = self._EXPONENT_GATE_MAP.get(gate_type, base_fb) + fb_key = _EXPONENT_GATE_MAP.get(gate_type, base_fb) mapped_fb = self.current_gate_name_map.get(fb_key, fb_key) return self._format_exponent_for_display(mapped_fb) - if name_fb.endswith("**1.0"): - name_fb = name_fb[:-5] - if name_fb.endswith("**1"): - name_fb = name_fb[:-3] if "**" in name_fb: parts = name_fb.split("**", 1) if len(parts) == 2: - fb_key = self._EXPONENT_GATE_MAP.get(gate_type, parts[0]) + fb_key = _EXPONENT_GATE_MAP.get(gate_type, parts[0]) base_str_fb = self.current_gate_name_map.get(fb_key, parts[0]) name_fb = f"{base_str_fb}^{{{self._format_exponent_for_display(parts[1])}}}" - name_fb = name_fb.replace("π", r"\pi") - if "_" in name_fb and "\\" not in name_fb: - name_fb = name_fb.replace("_", r"\_") - return name_fb + return self._escape_string(name_fb) def _get_quantikz_options_string(self) -> str: return f"[{self.quantikz_options}]" if self.quantikz_options else "" - def _render_operation(self, op: ops.Operation) -> Dict[int, str]: + def _render_operation(self, op: ops.Operation) -> dict[int, str]: """Renders a single Cirq operation into its Quantikz LaTeX string representation. Handles various gate types, including single-qubit gates, multi-qubit gates, @@ -494,7 +487,10 @@ def _render_operation(self, op: ops.Operation) -> Dict[int, str]: for the current moment. """ output, q_indices = {}, sorted([self.qubit_to_index[q] for q in op.qubits]) - gate, gate_name_render = op.gate, self._get_gate_name(op.gate) + gate = op.gate + if gate is None: # pragma: nocover + raise ValueError(f'Only GateOperations are supported {op}') + gate_name_render = self._get_gate_name(gate) gate_type = type(gate) style_key = gate_type.__name__ # Default style key @@ -508,32 +504,23 @@ def _render_operation(self, op: ops.Operation) -> Dict[int, str]: style_key = "Swapideal" elif isinstance(gate, ops.MeasurementGate): style_key = "Measure" - elif (param_base_name := self._PARAMETERIZED_GATE_BASE_NAMES.get(gate_type)) is not None: + elif (param_base_name := _PARAMETERIZED_GATE_BASE_NAMES.get(gate_type)) is not None: style_key = param_base_name - elif (base_key_for_pow := self._EXPONENT_GATE_MAP.get(gate_type)) is not None: - if hasattr(gate, "exponent"): - if gate.exponent == 1: - style_key = base_key_for_pow - else: - style_key = { - "X": "X_pow", - "Y": "Y_pow", - "Z": "Z_pow", - "H": "H_pow", - "CZ": "CZ_pow", - "CX": "CX_pow", - "iSwap": "iSWAP_pow", - }.get(base_key_for_pow, f"{base_key_for_pow}_pow") - else: + elif (base_key_for_pow := _EXPONENT_GATE_MAP.get(gate_type)) is not None: + if getattr(gate, "exponent", 1) == 1: style_key = base_key_for_pow + else: + style_key = { + "X": "X_pow", + "Y": "Y_pow", + "Z": "Z_pow", + "H": "H_pow", + "CZ": "CZ_pow", + "CX": "CX_pow", + "iSwap": "iSWAP_pow", + }.get(base_key_for_pow, f"{base_key_for_pow}_pow") style_opts_str = self.gate_styles.get(style_key, "") - if not style_opts_str: - if gate_type.__name__ == "FSimGate": - style_opts_str = self.gate_styles.get("FSim", "") - elif gate_type.__name__ == "PhasedXZGate": - style_opts_str = self.gate_styles.get("PhasedXZ", "") - final_style_tikz = f"[{style_opts_str}]" if style_opts_str else "" # Apply special Quantikz commands for specific gate types @@ -577,22 +564,24 @@ def _render_operation(self, op: ops.Operation) -> Dict[int, str]: return output # Handle generic \gate command for single and multi-qubit gates - if not q_indices: - warnings.warn(f"Op {op} has no qubits.") - return output if len(q_indices) == 1: output[q_indices[0]] = f"\\gate{final_style_tikz}{{{gate_name_render}}}" else: # Multi-qubit gate - wires_opt = f"wires={q_indices[-1]-q_indices[0]+1}" + combined_opts = f"wires={q_indices[-1]-q_indices[0]+1}" if style_opts_str: - combined_opts = f"{wires_opt}, {style_opts_str}" - else: - combined_opts = wires_opt + combined_opts = f"{combined_opts}, {style_opts_str}" output[q_indices[0]] = f"\\gate[{combined_opts}]{{{gate_name_render}}}" for i in range(1, len(q_indices)): output[q_indices[i]] = "\\qw" return output + def _initial_active_chunk(self) -> list[list[str]]: + """Add initial wire labels for the first chunk""" + return [ + [f"\\lstick{{{self._get_wire_label(self.sorted_qubits[i],i)}}}"] + for i in range(self.num_qubits) + ] + def _generate_latex_body(self) -> str: """Generates the main LaTeX body for the circuit diagram. @@ -601,72 +590,44 @@ def _generate_latex_body(self) -> str: into multiple rows if `fold_at` is specified. Handles qubit wire labels and ensures correct LaTeX syntax. """ - chunks, m_count, active_chunk = [], 0, [[] for _ in range(self.num_qubits)] - # Add initial wire labels for the first chunk - for i in range(self.num_qubits): - active_chunk[i].append(f"\\lstick{{{self._get_wire_label(self.sorted_qubits[i], i)}}}") + chunks = [] + active_chunk = self._initial_active_chunk() for m_idx, moment in enumerate(self.circuit): - m_count += 1 moment_out = ["\\qw"] * self.num_qubits - processed_indices = set() + # Add LaTeX for each operation in the moment for op in moment: - q_idx_op = sorted([self.qubit_to_index[q] for q in op.qubits]) - if not q_idx_op: + if not op.qubits: warnings.warn(f"Op {op} no qubits.") continue - if any(q in processed_indices for q in q_idx_op): - for q_idx in q_idx_op: - if q_idx not in processed_indices: - moment_out[q_idx] = "\\qw" - continue op_rnd = self._render_operation(op) for idx, tex in op_rnd.items(): - if idx not in processed_indices: - moment_out[idx] = tex - processed_indices.update(q_idx_op) + moment_out[idx] = tex for i in range(self.num_qubits): active_chunk[i].append(moment_out[i]) is_last_m = m_idx == len(self.circuit) - 1 - if self.fold_at and m_count % self.fold_at == 0 and not is_last_m: + if self.fold_at and m_idx % self.fold_at == 0 and not is_last_m: for i in range(self.num_qubits): lbl = self._get_wire_label(self.sorted_qubits[i], i) active_chunk[i].extend([f"\\rstick{{{lbl}}}", "\\qw"]) chunks.append(active_chunk) - active_chunk = [[] for _ in range(self.num_qubits)] - for i in range(self.num_qubits): - active_chunk[i].append( - f"\\lstick{{{self._get_wire_label(self.sorted_qubits[i],i)}}}" - ) + active_chunk = self._initial_active_chunk() - if self.num_qubits > 0: - ended_on_fold = self.fold_at and m_count > 0 and m_count % self.fold_at == 0 - if not ended_on_fold or not self.fold_at: - for i in range(self.num_qubits): - if not active_chunk[i]: - active_chunk[i] = [ - f"\\lstick{{{self._get_wire_label(self.sorted_qubits[i],i)}}}" - ] - active_chunk[i].append("\\qw") - if self.fold_at: - for i in range(self.num_qubits): - if not active_chunk[i]: - active_chunk[i] = [ - f"\\lstick{{{self._get_wire_label(self.sorted_qubits[i],i)}}}" - ] - active_chunk[i].extend( - [f"\\rstick{{{self._get_wire_label(self.sorted_qubits[i],i)}}}", "\\qw"] - ) + if self.fold_at: + for i in range(self.num_qubits): + active_chunk[i].extend( + [f"\\rstick{{{self._get_wire_label(self.sorted_qubits[i],i)}}}"] + ) + for i in range(self.num_qubits): + active_chunk[i].append("\\qw") chunks.append(active_chunk) final_parts = [] opts_str = self._get_quantikz_options_string() - for chunk_data in chunks: - if not any(row for row_list in chunk_data for row in row_list): - continue - + # TODO: test coverage for the below section + for chunk_data in chunks: # pragma: nocover is_empty_like = True if chunk_data and any(chunk_data): for r_cmds in chunk_data: @@ -712,7 +673,7 @@ def _generate_latex_body(self) -> str: lines.append("\\end{quantikz}") final_parts.append("\n".join(filter(None, lines))) - if not final_parts and self.num_qubits > 0: + if not final_parts and self.num_qubits > 0: # pragma: nocover lines = [f"\\begin{{quantikz}}{opts_str}"] for i in range(self.num_qubits): lines.append( @@ -737,7 +698,8 @@ def generate_latex_document(self, preamble_template: Optional[str] = None) -> st A string containing the full LaTeX document, ready to be compiled. """ preamble = preamble_template or DEFAULT_PREAMBLE_TEMPLATE - preamble += f"\n% --- Custom Preamble Injection Point ---\n{self.custom_preamble}\n% --- End Custom Preamble ---\n" + preamble += "\n% --- Custom Preamble Injection Point ---\n" + preamble += f"{self.custom_preamble}\n% --- End Custom Preamble ---\n" doc_parts = [preamble, "\\begin{document}", self._generate_latex_body()] if self.custom_postamble: doc_parts.extend( diff --git a/cirq-core/cirq/vis/circuit_to_latex_quantikz_test.py b/cirq-core/cirq/vis/circuit_to_latex_quantikz_test.py index 725951eafa5..5c9d937dd97 100644 --- a/cirq-core/cirq/vis/circuit_to_latex_quantikz_test.py +++ b/cirq-core/cirq/vis/circuit_to_latex_quantikz_test.py @@ -1,4 +1,4 @@ -# Copyright 2019 The Cirq Developers +# Copyright 2025 The Cirq Developers # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,10 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +import numpy as np import pytest -import cirq import sympy -import numpy as np + +import cirq # Import the class directly for testing from cirq.vis.circuit_to_latex_quantikz import CircuitToQuantikz, DEFAULT_PREAMBLE_TEMPLATE @@ -28,16 +29,30 @@ def test_empty_circuit_raises_value_error(): CircuitToQuantikz(empty_circuit) +def test_circuit_no_qubits_raises_value_error(): + """Test that a circuit with no qubits raises a ValueError.""" + empty_circuit = cirq.Circuit(cirq.global_phase_operation(-1)) + with pytest.raises(ValueError, match="Circuit contains no qubits."): + CircuitToQuantikz(empty_circuit) + + def test_basic_circuit_conversion(): """Test a simple circuit conversion to LaTeX.""" q0, q1 = cirq.LineQubit.range(2) - circuit = cirq.Circuit(cirq.H(q0), cirq.CNOT(q0, q1), cirq.measure(q0, key='m0')) - converter = CircuitToQuantikz(circuit) + circuit = cirq.Circuit( + cirq.H(q0), + cirq.PhasedXZGate(x_exponent=1, z_exponent=1, axis_phase_exponent=0.5)(q0), + cirq.CZ(q0, q1), + cirq.CNOT(q0, q1), + cirq.SWAP(q0, q1), + cirq.FSimGate(np.pi / 2, np.pi / 6)(q0, q1), + cirq.measure(q0, key='m0'), + ) + converter = CircuitToQuantikz(circuit, wire_labels="q") latex_code = converter.generate_latex_document() - # print(latex_code) assert r"\lstick{$q_{0}$} & \gate[" in latex_code - assert "& \meter[" in latex_code + assert r"& \meter[" in latex_code assert "\\begin{quantikz}" in latex_code assert "\\end{quantikz}" in latex_code assert DEFAULT_PREAMBLE_TEMPLATE.strip() in latex_code.strip() @@ -53,17 +68,33 @@ def test_parameter_display(): cirq.H(q_param), cirq.rz(alpha).on(q_param), # Parameterized gate cirq.X(q_param), + cirq.X(q_param) ** sympy.Symbol("a_2"), + cirq.X(q_param) ** 2.0, + cirq.X(q_param) ** sympy.N(1.5), + cirq.X(q_param) ** sympy.N(3.0), cirq.Y(q_param) ** 0.25, # Parameterized exponent + cirq.Y(q_param) ** alpha, # Formula exponent cirq.X(q_param), # Parameterized exponent cirq.rx(beta).on(q_param), # Parameterized gate cirq.H(q_param), + cirq.CZPowGate(exponent=0.25).on(q_param, cirq.q(2)), cirq.measure(q_param, key="result"), ) - print(param_circuit) # Test with show_parameters=True (default) converter_show_params = CircuitToQuantikz(param_circuit, show_parameters=True) latex_show_params = converter_show_params.generate_latex_document() - print(latex_show_params) + assert r"R_{Z}(\alpha)" in latex_show_params + assert r"Y^{0.25}" in latex_show_params + assert r"H" in latex_show_params + # Test with show_parameters=False + converter_show_params = CircuitToQuantikz(param_circuit, show_parameters=False) + latex_show_params = converter_show_params.generate_latex_document() + assert r"R_{Z}(\alpha)" not in latex_show_params + assert r"Y^{0.25}" not in latex_show_params + assert r"H" in latex_show_params + # Test with folding + converter_show_params = CircuitToQuantikz(param_circuit, show_parameters=True, fold_at=5) + latex_show_params = converter_show_params.generate_latex_document() assert r"R_{Z}(\alpha)" in latex_show_params assert r"Y^{0.25}" in latex_show_params assert r"H" in latex_show_params @@ -76,7 +107,6 @@ def test_custom_gate_name_map(): custom_map = {"H": "Hadamard"} converter = CircuitToQuantikz(circuit, gate_name_map=custom_map) latex_code = converter.generate_latex_document() - print(latex_code) assert r"Hadamard}" in latex_code assert r"{H}" not in latex_code # Ensure original H is not there @@ -90,7 +120,6 @@ def test_wire_labels(): # Default 'q' labels converter_q = CircuitToQuantikz(circuit, wire_labels="q") latex_q = converter_q.generate_latex_document() - # print(latex_q) assert r"\lstick{$q_{0}$}" in latex_q assert r"\lstick{$q_{1}$}" in latex_q @@ -136,12 +165,60 @@ def test_float_precision_exponents(): circuit = cirq.Circuit(cirq.X(q) ** 0.12345, cirq.Y(q) ** 0.5) converter = CircuitToQuantikz(circuit, float_precision_exps=3) latex_code = converter.generate_latex_document() - print(latex_code) assert r"X^{0.123}" in latex_code assert r"Y^{0.5}" in latex_code # Should still be 0.5, not 0.500 converter_int_exp = CircuitToQuantikz(circuit, float_precision_exps=0) latex_int_exp = converter_int_exp.generate_latex_document() - # print(latex_int_exp) assert r"X^{0.0}" in latex_int_exp # 0.12345 rounded to 0 assert r"Y^{0.0}" in latex_int_exp # 0.5 is still 0.5 if not integer + + +def test_qubit_order(): + qubits = cirq.LineQubit.range(4) + circuit = cirq.Circuit(cirq.X.on_each(*qubits)) + qubit_order = cirq.QubitOrder.explicit([qubits[3], qubits[2], qubits[1], qubits[0]]) + converter = CircuitToQuantikz(circuit, qubit_order=qubit_order) + latex_code = converter.generate_latex_document() + q3 = latex_code.find("q(3)") + q2 = latex_code.find("q(2)") + q1 = latex_code.find("q(1)") + q0 = latex_code.find("q(0)") + assert q3 != -1 + assert q2 != -1 + assert q1 != -1 + assert q0 != -1 + assert q3 < q2 + assert q2 < q1 + assert q1 < q0 + + +@pytest.mark.parametrize("show_parameters", [True, False]) +def test_custom_gate(show_parameters) -> None: + class CustomGate(cirq.Gate): + def __init__(self, exponent: float | int): + self.exponent = exponent + + def _num_qubits_(self): + return 1 + + def __str__(self): + return f"Custom_Gate()**{self.exponent}" + + def _unitary_(self): + return np.array([[1.0, 0.0], [0.0, 1.0]]) # pragma: nocover + + circuit = cirq.Circuit( + CustomGate(1.0).on(cirq.q(0)), CustomGate(1).on(cirq.q(0)), CustomGate(1.5).on(cirq.q(0)) + ) + converter = CircuitToQuantikz(circuit, show_parameters=show_parameters) + latex_code = converter.generate_latex_document() + assert "Custom" in latex_code + + +def test_misc_gates() -> None: + """Tests gates that have special handling.""" + circuit = cirq.Circuit(cirq.global_phase_operation(-1), cirq.X(cirq.q(0)) ** 1.5) + converter = CircuitToQuantikz(circuit) + latex_code = converter.generate_latex_document() + assert latex_code diff --git a/cirq-core/cirq/vis/circuit_to_latex_render.py b/cirq-core/cirq/vis/circuit_to_latex_render.py index 6ed4a18737e..949ce4ddb50 100644 --- a/cirq-core/cirq/vis/circuit_to_latex_render.py +++ b/cirq-core/cirq/vis/circuit_to_latex_render.py @@ -1,4 +1,4 @@ -# Copyright 2019 The Cirq Developers +# Copyright 2025 The Cirq Developers # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -32,24 +32,21 @@ dynamic quantum processes or circuit transformations. """ +from __future__ import annotations -import inspect import io -import math -import os import shutil import subprocess import tempfile import traceback import warnings from pathlib import Path -from typing import Any, Dict, List, Optional, Sequence, Tuple, Type, Union +from typing import Any import numpy as np -import sympy # Import individual Cirq packages as recommended for internal Cirq code -from cirq import circuits, devices, ops, protocols, study +from cirq import circuits, ops # Use absolute import for the sibling module from cirq.vis.circuit_to_latex_quantikz import CircuitToQuantikz @@ -57,23 +54,23 @@ __all__ = ["render_circuit", "create_gif_from_ipython_images"] try: - from IPython.display import display, Image, Markdown # type: ignore + from IPython.display import display, Image, Markdown # pragma: nocover - _HAS_IPYTHON = True -except ImportError: + _HAS_IPYTHON = True # pragma: nocover +except ImportError: # pragma: nocover _HAS_IPYTHON = False class Image: # type: ignore def __init__(self, *args, **kwargs): pass - def display(*args, **kwargs): # type: ignore + def display(*args, **kwargs): pass - def Markdown(*args, **kwargs): # type: ignore + def Markdown(*args, **kwargs): pass - def get_ipython(*args, **kwargs): # type: ignore + def get_ipython(*args, **kwargs) -> Any: pass @@ -82,9 +79,9 @@ def get_ipython(*args, **kwargs): # type: ignore # ============================================================================= def render_circuit( circuit: circuits.Circuit, - output_png_path: Optional[str] = None, - output_pdf_path: Optional[str] = None, - output_tex_path: Optional[str] = None, + output_png_path: str | None = None, + output_pdf_path: str | None = None, + output_tex_path: str | None = None, dpi: int = 300, run_pdflatex: bool = True, run_pdftoppm: bool = True, @@ -93,15 +90,16 @@ def render_circuit( debug: bool = False, timeout: int = 120, # Carried over CircuitToQuantikz args - gate_styles: Optional[Dict[str, str]] = None, - quantikz_options: Optional[str] = None, - fold_at: Optional[int] = None, + gate_styles: dict[str, str] | None = None, + quantikz_options: str | None = None, + fold_at: int | None = None, wire_labels: str = "q", show_parameters: bool = True, - gate_name_map: Optional[Dict[str, str]] = None, + gate_name_map: dict[str, str] | None = None, float_precision_exps: int = 2, + qubit_order: ops.QubitOrderOrList = ops.QubitOrder.DEFAULT, **kwargs: Any, -) -> Optional[Union[str, "Image"]]: +) -> str | Image | None: r"""Renders a Cirq circuit to a LaTeX diagram, compiles it, and optionally displays it. This function takes a `cirq.Circuit` object, converts it into a Quantikz @@ -153,6 +151,7 @@ def render_circuit( in the output. Passed to `CircuitToQuantikz`. float_precision_exps: An integer specifying the number of decimal places for formatting floating-point exponents. Passed to `CircuitToQuantikz`. + qubit_order: The order of the qubit lines in the rendered diagram. **kwargs: Additional keyword arguments passed directly to the `CircuitToQuantikz` constructor. Refer to `CircuitToQuantikz` for available options. Note that explicit arguments in `render_circuit` @@ -171,12 +170,13 @@ def render_circuit( Example: >>> import cirq + >>> import numpy as np >>> from cirq.vis.circuit_to_latex_render import render_circuit >>> q0, q1, q2 = cirq.LineQubit.range(3) >>> circuit = cirq.Circuit( ... cirq.H(q0), ... cirq.CNOT(q0, q1), - ... cirq.Rx(rads=0.25 * cirq.PI).on(q1), + ... cirq.rx(0.25*np.pi).on(q1), ... cirq.measure(q0, q1, key='result') ... ) >>> # Render and display in Jupyter (if available), also save to a file @@ -188,12 +188,6 @@ def render_circuit( ... quantikz_options="[column sep=0.7em]", ... show_parameters=False # Example of new parameter ... ) - >>> if isinstance(img_or_path, Image): - ... print("Circuit rendered and displayed in Jupyter.") - >>> elif isinstance(img_or_path, str): - ... print(f"Circuit rendered and saved to {img_or_path}") - >>> else: - ... print("Circuit rendering failed or no output generated.") >>> # To view the saved PNG outside Jupyter: >>> # import matplotlib.pyplot as plt >>> # import matplotlib.image as mpimg @@ -221,21 +215,25 @@ def _debug_print(*args: Any, **kwargs_print: Any) -> None: "'pdflatex' not found. Cannot compile LaTeX. " "Please install a LaTeX distribution (e.g., TeX Live, MiKTeX) " "and ensure pdflatex is in your PATH. " - "On Ubuntu/Debian: `sudo apt-get install texlive-full` (or `texlive-base` for minimal). " + "On Ubuntu/Debian: `sudo apt-get install texlive-full` " + "(or `texlive-base` for minimal). " "On macOS: `brew install --cask mactex` (or `brew install texlive` for minimal). " "On Windows: Download and install MiKTeX or TeX Live." - ) - run_pdflatex = run_pdftoppm = False # Disable dependent steps + ) # pragma: no cover + # Disable dependent steps + run_pdflatex = run_pdftoppm = False # pragma: nocover if run_pdftoppm and not pdftoppm_exec: warnings.warn( "'pdftoppm' not found. Cannot convert PDF to PNG. " "This tool is part of the Poppler utilities. " "On Ubuntu/Debian: `sudo apt-get install poppler-utils`. " "On macOS: `brew install poppler`. " - "On Windows: Download Poppler for Windows (e.g., from Poppler for Windows GitHub releases) " + "On Windows: Download Poppler for Windows " + "(e.g., from Poppler for Windows GitHub releases) " "and add its `bin` directory to your system PATH." - ) - run_pdftoppm = False # Disable dependent step + ) # pragma: nocover + # Disable dependent step + run_pdftoppm = False # pragma: nocover try: # Use TemporaryDirectory for safe handling of temporary files @@ -256,13 +254,14 @@ def _debug_print(*args: Any, **kwargs_print: Any) -> None: "show_parameters": show_parameters, "gate_name_map": gate_name_map, "float_precision_exps": float_precision_exps, + "qubit_order": qubit_order, **kwargs, # Existing kwargs are merged, but explicit args take precedence } try: converter = CircuitToQuantikz(circuit, **converter_kwargs) - except Exception as e: - print(f"Error initializing CircuitToQuantikz: {e}") + except Exception as e: # pragma: nocover + print(f"Error initializing CircuitToQuantikz: {e}") # pragma: nocover if debug: traceback.print_exc() return None @@ -270,7 +269,7 @@ def _debug_print(*args: Any, **kwargs_print: Any) -> None: _debug_print("Generating LaTeX source...") try: latex_s = converter.generate_latex_document() - except Exception as e: + except Exception as e: # pragma: nocover print(f"Error generating LaTeX document: {e}") if debug: traceback.print_exc() @@ -281,12 +280,12 @@ def _debug_print(*args: Any, **kwargs_print: Any) -> None: try: tmp_tex_path.write_text(latex_s, encoding="utf-8") _debug_print(f"LaTeX saved to temporary file: {tmp_tex_path}") - except IOError as e: + except IOError as e: # pragma: nocover print(f"Error writing temporary LaTeX file {tmp_tex_path}: {e}") return None pdf_generated = False - if run_pdflatex and pdflatex_exec: + if run_pdflatex and pdflatex_exec: # pragma: nocover _debug_print(f"Running pdflatex ({pdflatex_exec})...") # Run pdflatex twice for correct cross-references and layout cmd_latex = [ @@ -308,13 +307,14 @@ def _debug_print(*args: Any, **kwargs_print: Any) -> None: cwd=tmp_p, timeout=timeout, ) - if proc.returncode != 0: + if proc.returncode != 0: # pragma: nocover latex_failed = True print(f"!!! pdflatex failed on run {i+1} (exit code {proc.returncode}) !!!") log_file = tmp_tex_path.with_suffix(".log") if log_file.exists(): print( - f"--- Tail of {log_file.name} ---\n{log_file.read_text(errors='ignore')[-2000:]}" + f"--- Tail of {log_file.name} ---\n" + f"{log_file.read_text(errors='ignore')[-2000:]}" ) else: if proc.stdout: @@ -322,13 +322,14 @@ def _debug_print(*args: Any, **kwargs_print: Any) -> None: if proc.stderr: print(f"--- pdflatex stderr ---\n{proc.stderr}") break # Exit loop if pdflatex failed - elif not tmp_pdf_path.is_file() and i == 1: + elif not tmp_pdf_path.is_file() and i == 1: # pragma: nocover latex_failed = True print("!!! pdflatex completed, but PDF file not found. Check logs. !!!") log_file = tmp_tex_path.with_suffix(".log") if log_file.exists(): print( - f"--- Tail of {log_file.name} ---\n{log_file.read_text(errors='ignore')[-2000:]}" + f"--- Tail of {log_file.name} ---\n" + f"{log_file.read_text(errors='ignore')[-2000:]}" ) break elif tmp_pdf_path.is_file(): @@ -337,13 +338,14 @@ def _debug_print(*args: Any, **kwargs_print: Any) -> None: if not latex_failed and tmp_pdf_path.is_file(): pdf_generated = True _debug_print(f"PDF successfully generated at: {tmp_pdf_path}") - elif not latex_failed: # pdflatex returned 0 but PDF not found + elif not latex_failed: # pragma: nocover + # pdflatex returned 0 but PDF not found print("pdflatex reported success but PDF file was not found.") - if latex_failed: + if latex_failed: # pragma: nocover return None # Critical failure, return None png_generated, final_output_path_for_display = False, None - if run_pdftoppm and pdftoppm_exec and pdf_generated: + if run_pdftoppm and pdftoppm_exec and pdf_generated: # pragma: nocover _debug_print(f"Running pdftoppm ({pdftoppm_exec})...") # pdftoppm outputs to -.png if multiple pages, # or .png if single page with -singlefile. @@ -351,7 +353,7 @@ def _debug_print(*args: Any, **kwargs_print: Any) -> None: cmd_ppm = [ pdftoppm_exec, "-png", - f"-r", + "-r", str(dpi), "-singlefile", # Ensures single output file for single-page PDFs str(tmp_pdf_path), @@ -370,15 +372,17 @@ def _debug_print(*args: Any, **kwargs_print: Any) -> None: png_generated = True _debug_print(f"PNG successfully generated at: {tmp_png_path}") else: - print(f"!!! pdftoppm succeeded but PNG ({tmp_png_path}) not found. !!!") - except subprocess.CalledProcessError as e_ppm: + print( + f"!!! pdftoppm succeeded but PNG ({tmp_png_path}) not found. !!!" + ) # pragma: nocover + except subprocess.CalledProcessError as e_ppm: # pragma: nocover print( f"!!! pdftoppm failed (exit code {e_ppm.returncode}) !!!\n" f"Stdout: {e_ppm.stdout}\nStderr: {e_ppm.stderr}" ) - except subprocess.TimeoutExpired: + except subprocess.TimeoutExpired: # pragma: nocover print("!!! pdftoppm timed out. !!!") - except Exception as e_ppm_other: + except Exception as e_ppm_other: # pragma: nocover print(f"An unexpected error occurred during pdftoppm: {e_ppm_other}") # Copy files to final destinations if requested @@ -387,16 +391,16 @@ def _debug_print(*args: Any, **kwargs_print: Any) -> None: final_tex_path.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(tmp_tex_path, final_tex_path) _debug_print(f"Copied .tex to: {final_tex_path}") - except Exception as e: + except Exception as e: # pragma: nocover print(f"Error copying .tex file to final path: {e}") - if final_pdf_path and pdf_generated and tmp_pdf_path.exists(): + if final_pdf_path and pdf_generated and tmp_pdf_path.exists(): # pragma: nocover try: final_pdf_path.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(tmp_pdf_path, final_pdf_path) _debug_print(f"Copied .pdf to: {final_pdf_path}") except Exception as e: print(f"Error copying .pdf file to final path: {e}") - if final_png_path and png_generated and tmp_png_path.exists(): + if final_png_path and png_generated and tmp_png_path.exists(): # pragma: nocover try: final_png_path.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(tmp_png_path, final_png_path) @@ -404,17 +408,18 @@ def _debug_print(*args: Any, **kwargs_print: Any) -> None: final_output_path_for_display = final_png_path # Use the final path for display except Exception as e: print(f"Error copying .png file to final path: {e}") - elif png_generated and tmp_png_path.exists() and not final_png_path: - # If PNG was generated but no specific output_png_path, use the temp path for display + elif png_generated and tmp_png_path.exists() and not final_png_path: # pragma: nocover + # If PNG was generated but no specific output_png_path, + # use the temp path for display final_output_path_for_display = tmp_png_path - jupyter_image_object: Optional["Image"] = None + jupyter_image_object: Image | None = None if ( display_png_jupyter and final_output_path_for_display and final_output_path_for_display.is_file() - ): + ): # pragma: nocover _debug_print( f"Attempting to display PNG in Jupyter: {final_output_path_for_display}" ) @@ -422,8 +427,11 @@ def _debug_print(*args: Any, **kwargs_print: Any) -> None: try: # Check if running in a Jupyter-like environment that supports display # get_ipython() returns a shell object if in IPython, None otherwise. - # ZMQInteractiveShell is for Jupyter notebooks, TerminalInteractiveShell for IPython console. - sh_obj = get_ipython() # type: ignore + # ZMQInteractiveShell is for Jupyter notebooks, + # TerminalInteractiveShell for IPython console. + # pylint: disable=assignment-from-no-return + sh_obj = get_ipython() + # pylint: enable=assignment-from-no-return if ( sh_obj is not None and sh_obj.__class__.__name__ == "ZMQInteractiveShell" @@ -434,7 +442,8 @@ def _debug_print(*args: Any, **kwargs_print: Any) -> None: _debug_print("PNG displayed in Jupyter notebook.") else: _debug_print( - "Not in a ZMQInteractiveShell (Jupyter notebook). PNG not displayed inline." + "Not in a ZMQInteractiveShell (Jupyter notebook). " + "PNG not displayed inline." ) # Still create Image object if it might be returned later jupyter_image_object = Image( @@ -449,27 +458,29 @@ def _debug_print(*args: Any, **kwargs_print: Any) -> None: _debug_print("IPython not available, cannot display PNG inline.") elif display_png_jupyter and ( not final_output_path_for_display or not final_output_path_for_display.is_file() - ): + ): # pragma: nocover if run_pdflatex and run_pdftoppm: print("PNG display requested, but PNG not successfully created/found.") # Determine return value based on requested outputs if jupyter_image_object: - return jupyter_image_object + return jupyter_image_object # pragma: nocover elif final_png_path and final_png_path.is_file(): - return str(final_png_path) # Return path to saved PNG - elif output_tex_path and final_tex_path and final_tex_path.is_file(): + # Return path to saved PNG + return str(final_png_path) # pragma: nocover + elif output_tex_path and final_tex_path and final_tex_path.is_file(): # pragma:nocover # If only LaTeX string was requested, read it back from the saved file # This is a bit indirect, but aligns with returning a string path return final_tex_path.read_text(encoding="utf-8") - return None # Default return if no specific output is generated or requested as return + # Default return if no specific output is generated or requested as return + return None # pragma: nocover - except subprocess.TimeoutExpired as e_timeout: + except subprocess.TimeoutExpired as e_timeout: # pragma: nocover print(f"!!! Process timed out: {e_timeout} !!!") if debug: traceback.print_exc() return None - except Exception as e_crit: + except Exception as e_crit: # pragma: nocover print(f"Critical error in render_circuit: {e_crit}") if debug: traceback.print_exc() @@ -477,8 +488,8 @@ def _debug_print(*args: Any, **kwargs_print: Any) -> None: def create_gif_from_ipython_images( - image_list: List["Image"], output_filename: str, fps: int, **kwargs: Any -) -> None: + image_list: list[Image], output_filename: str, fps: int, **kwargs: Any +) -> None: # pragma: nocover r"""Creates a GIF from a list of IPython.core.display.Image objects and saves it. This utility requires `ImageMagick` to be installed and available in your @@ -502,7 +513,7 @@ def create_gif_from_ipython_images( - `duration`: Total duration of the animation in seconds (overrides delay). """ try: - import imageio + import imageio # type: ignore except ImportError: print("You need to install imageio: `pip install imageio`") return None @@ -516,20 +527,23 @@ def create_gif_from_ipython_images( for ipython_image in image_list: image_bytes = ipython_image.data try: - pil_img = PILImage.open(io.BytesIO(image_bytes)) + pil_img_file = PILImage.open(io.BytesIO(image_bytes)) # Ensure image is in RGB/RGBA for broad compatibility before making it a numpy array. # GIF supports palette ('P') directly, but converting to RGB first can be safer # if complex palettes or transparency are involved and imageio's handling is unknown. - # However, for GIFs, 'P' mode with a good palette is often preferred for smaller file sizes. - # Let's try to keep 'P' if possible, but convert RGBA to RGB as GIFs don't support full alpha well. - if pil_img.mode == "RGBA": + # However, for GIFs, 'P' mode with a good palette is often + # preferred for smaller file sizes. Let's try to keep 'P' if possible, + # but convert RGBA to RGB as GIFs don't support full alpha well. + if pil_img_file.mode == "RGBA": # Create a white background image - background = PILImage.new("RGB", pil_img.size, (255, 255, 255)) + background = PILImage.new("RGB", pil_img_file.size, (255, 255, 255)) # Paste the RGBA image onto the white background - background.paste(pil_img, mask=pil_img.split()[3]) # 3 is the alpha channel + background.paste( + pil_img_file, mask=pil_img_file.split()[3] + ) # 3 is the alpha channel pil_img = background elif pil_img.mode not in ["RGB", "L", "P"]: # L for grayscale, P for palette - pil_img = pil_img.convert("RGB") + pil_img = pil_img_file.convert("RGB") frames.append(np.array(pil_img)) except Exception as e: print(f"Warning: Could not process an image. Error: {e}") @@ -577,7 +591,7 @@ def create_gif_from_ipython_images( imageio.mimsave(output_filename, rgb_frames, fps=fps, loop=kwargs.get("loop", 0)) print(f"GIF saved with basic RGB settings as {output_filename}") else: - print("Could not convert frames to RGB for basic save.") + print("Could not convert frames to RGB for basic save.") # pragma: nocover - except Exception as fallback_e: + except Exception as fallback_e: # pragma: nocover print(f"Fallback GIF saving also failed: {fallback_e}") diff --git a/cirq-core/cirq/vis/circuit_to_latex_render_test.py b/cirq-core/cirq/vis/circuit_to_latex_render_test.py new file mode 100644 index 00000000000..1b28cad045a --- /dev/null +++ b/cirq-core/cirq/vis/circuit_to_latex_render_test.py @@ -0,0 +1,39 @@ +# Copyright 2025 The Cirq Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import numpy as np + +import cirq +from cirq.vis.circuit_to_latex_render import render_circuit + + +def test_render_circuit() -> None: + q0, q1, q2 = cirq.LineQubit.range(3) + circuit = cirq.Circuit( + cirq.H(q0), + cirq.CNOT(q0, q1), + cirq.rx(0.25 * np.pi).on(q1), + cirq.measure(q0, q1, key='result'), + ) + # Render and display in Jupyter (if available), also save to a file + img_or_path = render_circuit( + circuit, + output_png_path="my_circuit.png", + output_tex_path="my_circuit.tex", + output_pdf_path="my_circuit.pdf", + fold_at=2, + debug=True, + wire_labels="qid", + ) + assert img_or_path is not None From d72b74d89e05bb94d3804dc557fa90c182a8590f Mon Sep 17 00:00:00 2001 From: Doug Strain Date: Fri, 1 Aug 2025 06:18:35 -0700 Subject: [PATCH 5/9] Fix __init__ --- cirq-core/cirq/vis/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cirq-core/cirq/vis/__init__.py b/cirq-core/cirq/vis/__init__.py index 50ab2b1d5cd..2817f5c93a0 100644 --- a/cirq-core/cirq/vis/__init__.py +++ b/cirq-core/cirq/vis/__init__.py @@ -29,5 +29,5 @@ from cirq.vis.vis_utils import relative_luminance as relative_luminance -from cirq.vis.circuit_to_latex_quantikz import CircuitToQuantikz -from cirq.vis.circuit_to_latex_render import render_circuit +from cirq.vis.circuit_to_latex_quantikz import CircuitToQuantikz as CircuitToQuantikz +from cirq.vis.circuit_to_latex_render import render_circuit as render_circuit From 213ffa730e75d7171f65f461229768bab7f0e9ab Mon Sep 17 00:00:00 2001 From: Doug Strain Date: Fri, 1 Aug 2025 11:17:22 -0700 Subject: [PATCH 6/9] Fix default for qubit display --- cirq-core/cirq/vis/circuit_to_latex_quantikz.py | 4 +--- .../cirq/vis/circuit_to_latex_quantikz_test.py | 16 +++++++++++++--- cirq-core/cirq/vis/circuit_to_latex_render.py | 2 +- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/cirq-core/cirq/vis/circuit_to_latex_quantikz.py b/cirq-core/cirq/vis/circuit_to_latex_quantikz.py index bcb455b5e09..a37c064b523 100644 --- a/cirq-core/cirq/vis/circuit_to_latex_quantikz.py +++ b/cirq-core/cirq/vis/circuit_to_latex_quantikz.py @@ -287,9 +287,7 @@ def _get_wire_label(self, qubit: ops.Qid, index: int) -> str: f"q_{{{index}}}" if self.wire_labels == "q" else ( - str(index) - if self.wire_labels == "index" - else str(self._escape_string(str(qubit))).replace(" ", r"\,") + str(index) if self.wire_labels == "index" else str(self._escape_string(str(qubit))) ) ) return f"${lbl}$" diff --git a/cirq-core/cirq/vis/circuit_to_latex_quantikz_test.py b/cirq-core/cirq/vis/circuit_to_latex_quantikz_test.py index 5c9d937dd97..77633d1adfe 100644 --- a/cirq-core/cirq/vis/circuit_to_latex_quantikz_test.py +++ b/cirq-core/cirq/vis/circuit_to_latex_quantikz_test.py @@ -114,20 +114,30 @@ def test_custom_gate_name_map(): def test_wire_labels(): """Test different wire labeling options.""" - q0, q1 = cirq.NamedQubit('alice'), cirq.LineQubit(10) - circuit = cirq.Circuit(cirq.H(q0), cirq.X(q1)) + q0, q1, q2 = cirq.NamedQubit('alice'), cirq.LineQubit(10), cirq.GridQubit(4, 3) + circuit = cirq.Circuit(cirq.H(q0), cirq.X(q1), cirq.Z(q2)) - # Default 'q' labels + # 'q' labels converter_q = CircuitToQuantikz(circuit, wire_labels="q") latex_q = converter_q.generate_latex_document() assert r"\lstick{$q_{0}$}" in latex_q assert r"\lstick{$q_{1}$}" in latex_q + assert r"\lstick{$q_{2}$}" in latex_q # 'index' labels converter_idx = CircuitToQuantikz(circuit, wire_labels="index") latex_idx = converter_idx.generate_latex_document() assert r"\lstick{$0$}" in latex_idx assert r"\lstick{$1$}" in latex_idx + assert r"\lstick{$2$}" in latex_idx + + # 'qid' labels + converter_q = CircuitToQuantikz(circuit, wire_labels="qid") + latex_q = converter_q.generate_latex_document() + print(latex_q) + assert r"\lstick{$alice$}" in latex_q + assert r"\lstick{$q(10)$}" in latex_q + assert r"\lstick{$q(4, 3)$}" in latex_q def test_custom_preamble_and_postamble(): diff --git a/cirq-core/cirq/vis/circuit_to_latex_render.py b/cirq-core/cirq/vis/circuit_to_latex_render.py index 949ce4ddb50..02500e34b0d 100644 --- a/cirq-core/cirq/vis/circuit_to_latex_render.py +++ b/cirq-core/cirq/vis/circuit_to_latex_render.py @@ -93,7 +93,7 @@ def render_circuit( gate_styles: dict[str, str] | None = None, quantikz_options: str | None = None, fold_at: int | None = None, - wire_labels: str = "q", + wire_labels: str = "qid", show_parameters: bool = True, gate_name_map: dict[str, str] | None = None, float_precision_exps: int = 2, From b43d56f27a5b4b5a5f7adb479856f641d91d53ff Mon Sep 17 00:00:00 2001 From: Doug Strain Date: Fri, 1 Aug 2025 13:44:14 -0700 Subject: [PATCH 7/9] Create new moments for overlapping operations. --- cirq-core/cirq/vis/circuit_to_latex_quantikz.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/cirq-core/cirq/vis/circuit_to_latex_quantikz.py b/cirq-core/cirq/vis/circuit_to_latex_quantikz.py index a37c064b523..dda897ef662 100644 --- a/cirq-core/cirq/vis/circuit_to_latex_quantikz.py +++ b/cirq-core/cirq/vis/circuit_to_latex_quantikz.py @@ -595,10 +595,25 @@ def _generate_latex_body(self) -> str: moment_out = ["\\qw"] * self.num_qubits # Add LaTeX for each operation in the moment + spanned_qubits: set[int] = set() for op in moment: if not op.qubits: warnings.warn(f"Op {op} no qubits.") continue + min_qubit = min(self.qubit_to_index[q] for q in op.qubits) + max_qubit = max(self.qubit_to_index[q] for q in op.qubits) + for i in range(min_qubit, max_qubit + 1): + if i in spanned_qubits: + # This overlaps another operation: + # Create a new column. + for i in range(self.num_qubits): + active_chunk[i].append(moment_out[i]) + moment_out = ["\\qw"] * self.num_qubits + spanned_qubits = set() + for i in range(min_qubit, max_qubit + 1): + spanned_qubits.add(i) + for q in op.qubits: + spanned_qubits.add(self.qubit_to_index[q]) op_rnd = self._render_operation(op) for idx, tex in op_rnd.items(): moment_out[idx] = tex From 50bea0ba45bcfa8420978b07b98882f76861cb66 Mon Sep 17 00:00:00 2001 From: Doug Strain Date: Sat, 2 Aug 2025 08:38:30 -0700 Subject: [PATCH 8/9] Fix multi-measurement and also coverage. --- cirq-core/cirq/vis/circuit_to_latex_quantikz.py | 9 +++++++-- cirq-core/cirq/vis/circuit_to_latex_quantikz_test.py | 5 +++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/cirq-core/cirq/vis/circuit_to_latex_quantikz.py b/cirq-core/cirq/vis/circuit_to_latex_quantikz.py index dda897ef662..e7eb3c2865a 100644 --- a/cirq-core/cirq/vis/circuit_to_latex_quantikz.py +++ b/cirq-core/cirq/vis/circuit_to_latex_quantikz.py @@ -524,8 +524,13 @@ def _render_operation(self, op: ops.Operation) -> dict[int, str]: # Apply special Quantikz commands for specific gate types if isinstance(gate, ops.MeasurementGate): lbl = gate.key.replace("_", r"\_") if gate.key else "" - for i in q_indices: - output[i] = f"\\meter{final_style_tikz}{{{lbl}}}" + for idx, i in enumerate(q_indices): + if idx == 0: + output[i] = f"\\meter{final_style_tikz}{{{lbl}}}" + else: + q0 = q_indices[idx - 1] + q1 = q_indices[idx] + output[i] = f"\\meter{final_style_tikz}{{}} \\vqw{{{q0-q1}}}" return output if isinstance(gate, ops.CNotPowGate) and hasattr(gate, "exponent") and gate.exponent == 1: c, t = ( diff --git a/cirq-core/cirq/vis/circuit_to_latex_quantikz_test.py b/cirq-core/cirq/vis/circuit_to_latex_quantikz_test.py index 77633d1adfe..ae671cbc52e 100644 --- a/cirq-core/cirq/vis/circuit_to_latex_quantikz_test.py +++ b/cirq-core/cirq/vis/circuit_to_latex_quantikz_test.py @@ -38,15 +38,16 @@ def test_circuit_no_qubits_raises_value_error(): def test_basic_circuit_conversion(): """Test a simple circuit conversion to LaTeX.""" - q0, q1 = cirq.LineQubit.range(2) + q0, q1, q2, q3 = cirq.LineQubit.range(4) circuit = cirq.Circuit( cirq.H(q0), cirq.PhasedXZGate(x_exponent=1, z_exponent=1, axis_phase_exponent=0.5)(q0), cirq.CZ(q0, q1), cirq.CNOT(q0, q1), cirq.SWAP(q0, q1), + cirq.Moment(cirq.CZ(q1, q2), cirq.CZ(q0, q3)), cirq.FSimGate(np.pi / 2, np.pi / 6)(q0, q1), - cirq.measure(q0, key='m0'), + cirq.measure(q0, q2, q3, key='m0'), ) converter = CircuitToQuantikz(circuit, wire_labels="q") latex_code = converter.generate_latex_document() From d94298a6059eefee5ec56c5ca9544767e3e11ba4 Mon Sep 17 00:00:00 2001 From: Doug Strain Date: Mon, 4 Aug 2025 06:06:40 -0700 Subject: [PATCH 9/9] Added classical controls, cleaned up code, and added coverage. --- .../cirq/vis/circuit_to_latex_quantikz.py | 121 +++++++----------- .../vis/circuit_to_latex_quantikz_test.py | 17 ++- cirq-core/cirq/vis/circuit_to_latex_render.py | 5 +- .../cirq/vis/circuit_to_latex_render_test.py | 2 +- 4 files changed, 65 insertions(+), 80 deletions(-) diff --git a/cirq-core/cirq/vis/circuit_to_latex_quantikz.py b/cirq-core/cirq/vis/circuit_to_latex_quantikz.py index e7eb3c2865a..6195d5e878a 100644 --- a/cirq-core/cirq/vis/circuit_to_latex_quantikz.py +++ b/cirq-core/cirq/vis/circuit_to_latex_quantikz.py @@ -69,7 +69,7 @@ import sympy -from cirq import circuits, ops, protocols +from cirq import circuits, ops, protocols, value __all__ = ["CircuitToQuantikz", "DEFAULT_PREAMBLE_TEMPLATE", "GATE_STYLES_COLORFUL"] @@ -251,6 +251,7 @@ def __init__( if not self.sorted_qubits: raise ValueError("Circuit contains no qubits.") self.qubit_to_index = self._map_qubits_to_indices() + self.key_to_index = self._map_keys_to_indices() self.num_qubits = len(self.sorted_qubits) self.float_precision_exps = float_precision_exps self.float_precision_angles = float_precision_angles @@ -265,6 +266,18 @@ def _map_qubits_to_indices(self) -> dict[ops.Qid, int]: """ return {q: i for i, q in enumerate(self.sorted_qubits)} + def _map_keys_to_indices(self) -> dict[str, list[int]]: + """Maps measurement keys to qubit indices. + + Used by classically controlled operations to map keys + to qubit wires. + """ + key_map: dict[str, list[int]] = {} + for op in self.circuit.all_operations(): + if isinstance(op.gate, ops.MeasurementGate): + key_map[op.gate.key] = [self.qubit_to_index[q] for q in op.qubits] + return key_map + def _escape_string(self, label) -> str: """Escape labels for latex.""" label = label.replace("π", r"\pi") @@ -372,8 +385,6 @@ def _get_gate_name(self, gate: ops.Gate) -> str: "Rx(0.5)", "CZ"). """ gate_type = type(gate) - if gate_type.__name__ == "ThermalChannel": # pragma: nocover - return "\\Lambda_\\mathrm{th}" if (simple_name := _SIMPLE_GATE_MAP.get(gate_type)) is not None: return simple_name @@ -486,6 +497,8 @@ def _render_operation(self, op: ops.Operation) -> dict[int, str]: """ output, q_indices = {}, sorted([self.qubit_to_index[q] for q in op.qubits]) gate = op.gate + if isinstance(op, ops.ClassicallyControlledOperation): + gate = op.without_classical_controls().gate if gate is None: # pragma: nocover raise ValueError(f'Only GateOperations are supported {op}') gate_name_render = self._get_gate_name(gate) @@ -531,43 +544,32 @@ def _render_operation(self, op: ops.Operation) -> dict[int, str]: q0 = q_indices[idx - 1] q1 = q_indices[idx] output[i] = f"\\meter{final_style_tikz}{{}} \\vqw{{{q0-q1}}}" - return output - if isinstance(gate, ops.CNotPowGate) and hasattr(gate, "exponent") and gate.exponent == 1: + elif isinstance(gate, ops.CNotPowGate) and hasattr(gate, "exponent") and gate.exponent == 1: c, t = ( (self.qubit_to_index[op.qubits[0]], self.qubit_to_index[op.qubits[1]]) if len(op.qubits) == 2 else (q_indices[0], q_indices[0]) ) - output[c], output[t] = ( - f"\\ctrl{final_style_tikz}{{{t-c}}}", - f"\\targ{final_style_tikz}{{}}", - ) - return output - if isinstance(gate, ops.CZPowGate) and hasattr(gate, "exponent") and gate.exponent == 1: + output[c] = f"\\ctrl{final_style_tikz}{{{t-c}}}" + output[t] = f"\\targ{final_style_tikz}{{}}" + elif isinstance(gate, ops.CZPowGate) and hasattr(gate, "exponent") and gate.exponent == 1: i1, i2 = ( (q_indices[0], q_indices[1]) if len(q_indices) >= 2 else (q_indices[0], q_indices[0]) ) - output[i1], output[i2] = ( - f"\\ctrl{final_style_tikz}{{{i2-i1}}}", - f"\\control{final_style_tikz}{{}}", - ) - return output - if isinstance(gate, ops.SwapPowGate) and hasattr(gate, "exponent") and gate.exponent == 1: + output[i1] = f"\\ctrl{final_style_tikz}{{{i2-i1}}}" + output[i2] = f"\\control{final_style_tikz}{{}}" + elif isinstance(gate, ops.SwapPowGate) and hasattr(gate, "exponent") and gate.exponent == 1: i1, i2 = ( (q_indices[0], q_indices[1]) if len(q_indices) >= 2 else (q_indices[0], q_indices[0]) ) - output[i1], output[i2] = ( - f"\\swap{final_style_tikz}{{{i2-i1}}}", - f"\\targX{final_style_tikz}{{}}", - ) - return output - + output[i1] = f"\\swap{final_style_tikz}{{{i2-i1}}}" + output[i2] = f"\\targX{final_style_tikz}{{}}" # Handle generic \gate command for single and multi-qubit gates - if len(q_indices) == 1: + elif len(q_indices) == 1: output[q_indices[0]] = f"\\gate{final_style_tikz}{{{gate_name_render}}}" else: # Multi-qubit gate combined_opts = f"wires={q_indices[-1]-q_indices[0]+1}" @@ -576,6 +578,14 @@ def _render_operation(self, op: ops.Operation) -> dict[int, str]: output[q_indices[0]] = f"\\gate[{combined_opts}]{{{gate_name_render}}}" for i in range(1, len(q_indices)): output[q_indices[i]] = "\\qw" + if isinstance(op, ops.ClassicallyControlledOperation): + q0 = q_indices[0] + for key in op.classical_controls: + if isinstance(key, value.KeyCondition): + for index in self.key_to_index[key.key.name]: + output[q0] += f" \\vcw{{{index-q0}}}" + output[index] = "\\ctrl{}" + return output def _initial_active_chunk(self) -> list[list[str]]: @@ -629,77 +639,40 @@ def _generate_latex_body(self) -> str: if self.fold_at and m_idx % self.fold_at == 0 and not is_last_m: for i in range(self.num_qubits): lbl = self._get_wire_label(self.sorted_qubits[i], i) - active_chunk[i].extend([f"\\rstick{{{lbl}}}", "\\qw"]) + active_chunk[i].extend(["\\qw", f"\\rstick{{{lbl}}}"]) chunks.append(active_chunk) active_chunk = self._initial_active_chunk() + for i in range(self.num_qubits): + active_chunk[i].append("\\qw") if self.fold_at: for i in range(self.num_qubits): active_chunk[i].extend( [f"\\rstick{{{self._get_wire_label(self.sorted_qubits[i],i)}}}"] ) - for i in range(self.num_qubits): - active_chunk[i].append("\\qw") chunks.append(active_chunk) - final_parts = [] opts_str = self._get_quantikz_options_string() - # TODO: test coverage for the below section - for chunk_data in chunks: # pragma: nocover - is_empty_like = True - if chunk_data and any(chunk_data): - for r_cmds in chunk_data: - if any( - cmd not in ["\\qw", ""] - and not cmd.startswith("\\lstick") - and not cmd.startswith("\\rstick") - for cmd in r_cmds - ): - is_empty_like = False - break - if all( - all( - cmd == "\\qw" or cmd.startswith("\\lstick") or cmd.startswith("\\rstick") - for cmd in r - ) - for r in chunk_data - if r - ): - if len(chunks) > 1 or not self.circuit: - if all(len(r) <= (4 if self.fold_at else 2) for r in chunk_data if r): - is_empty_like = True - if is_empty_like and len(chunks) > 1 and self.circuit: - continue - + final_parts = [] + for chunk_data in chunks: lines = [f"\\begin{{quantikz}}{opts_str}"] for i in range(self.num_qubits): if i < len(chunk_data) and chunk_data[i]: lines.append(" & ".join(chunk_data[i]) + " \\\\") - elif i < self.num_qubits: - lines.append( - f"\\lstick{{{self._get_wire_label(self.sorted_qubits[i],i)}}} & \\qw \\\\" - ) if len(lines) > 1: for k_idx in range(len(lines) - 1, 0, -1): - if lines[k_idx].strip() and lines[k_idx].strip() != "\\\\": - if lines[k_idx].endswith(" \\\\"): - lines[k_idx] = lines[k_idx].rstrip()[:-3].rstrip() - break - elif lines[k_idx].strip() == "\\\\" and k_idx == len(lines) - 1: - lines[k_idx] = "" + stipped_line = lines[k_idx].strip() + if stipped_line: + if stipped_line != "\\\\": + if lines[k_idx].endswith(" \\\\"): + lines[k_idx] = lines[k_idx].rstrip()[:-3].rstrip() + break + elif k_idx == len(lines) - 1: # pragma: nocover + lines[k_idx] = "" lines.append("\\end{quantikz}") final_parts.append("\n".join(filter(None, lines))) - if not final_parts and self.num_qubits > 0: # pragma: nocover - lines = [f"\\begin{{quantikz}}{opts_str}"] - for i in range(self.num_qubits): - lines.append( - f"\\lstick{{{self._get_wire_label(self.sorted_qubits[i],i)}}} & \\qw" - + (" \\\\" if i < self.num_qubits - 1 else "") - ) - lines.append("\\end{quantikz}") - return "\n".join(lines) return "\n\n\\vspace{1em}\n\n".join(final_parts) def generate_latex_document(self, preamble_template: Optional[str] = None) -> str: diff --git a/cirq-core/cirq/vis/circuit_to_latex_quantikz_test.py b/cirq-core/cirq/vis/circuit_to_latex_quantikz_test.py index ae671cbc52e..17a016a11ea 100644 --- a/cirq-core/cirq/vis/circuit_to_latex_quantikz_test.py +++ b/cirq-core/cirq/vis/circuit_to_latex_quantikz_test.py @@ -100,6 +100,13 @@ def test_parameter_display(): assert r"Y^{0.25}" in latex_show_params assert r"H" in latex_show_params + # Test with folding at boundary + converter_show_params = CircuitToQuantikz(param_circuit, show_parameters=True, fold_at=1) + latex_show_params = converter_show_params.generate_latex_document() + assert r"R_{Z}(\alpha)" in latex_show_params + assert r"Y^{0.25}" in latex_show_params + assert r"H" in latex_show_params + def test_custom_gate_name_map(): """Test custom gate name mapping.""" @@ -135,7 +142,6 @@ def test_wire_labels(): # 'qid' labels converter_q = CircuitToQuantikz(circuit, wire_labels="qid") latex_q = converter_q.generate_latex_document() - print(latex_q) assert r"\lstick{$alice$}" in latex_q assert r"\lstick{$q(10)$}" in latex_q assert r"\lstick{$q(4, 3)$}" in latex_q @@ -233,3 +239,12 @@ def test_misc_gates() -> None: converter = CircuitToQuantikz(circuit) latex_code = converter.generate_latex_document() assert latex_code + + +def test_classical_control() -> None: + circuit = cirq.Circuit( + cirq.measure(cirq.q(0), key='a'), cirq.X(cirq.q(1)).with_classical_controls('a') + ) + converter = CircuitToQuantikz(circuit) + latex_code = converter.generate_latex_document() + assert "\\vcw" in latex_code diff --git a/cirq-core/cirq/vis/circuit_to_latex_render.py b/cirq-core/cirq/vis/circuit_to_latex_render.py index 02500e34b0d..c5abbcdd58a 100644 --- a/cirq-core/cirq/vis/circuit_to_latex_render.py +++ b/cirq-core/cirq/vis/circuit_to_latex_render.py @@ -54,7 +54,7 @@ __all__ = ["render_circuit", "create_gif_from_ipython_images"] try: - from IPython.display import display, Image, Markdown # pragma: nocover + from IPython.display import display, Image # pragma: nocover _HAS_IPYTHON = True # pragma: nocover except ImportError: # pragma: nocover @@ -67,9 +67,6 @@ def __init__(self, *args, **kwargs): def display(*args, **kwargs): pass - def Markdown(*args, **kwargs): - pass - def get_ipython(*args, **kwargs) -> Any: pass diff --git a/cirq-core/cirq/vis/circuit_to_latex_render_test.py b/cirq-core/cirq/vis/circuit_to_latex_render_test.py index 1b28cad045a..24b690e155a 100644 --- a/cirq-core/cirq/vis/circuit_to_latex_render_test.py +++ b/cirq-core/cirq/vis/circuit_to_latex_render_test.py @@ -19,7 +19,7 @@ def test_render_circuit() -> None: - q0, q1, q2 = cirq.LineQubit.range(3) + q0, q1 = cirq.LineQubit.range(2) circuit = cirq.Circuit( cirq.H(q0), cirq.CNOT(q0, q1),