diff --git a/cirq-core/cirq/vis/__init__.py b/cirq-core/cirq/vis/__init__.py index 69c47c19cc3..2817f5c93a0 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 CircuitToQuantikz as CircuitToQuantikz +from cirq.vis.circuit_to_latex_render import render_circuit as 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..6195d5e878a --- /dev/null +++ b/cirq-core/cirq/vis/circuit_to_latex_quantikz.py @@ -0,0 +1,704 @@ +# 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. + +# -*- coding: utf-8 -*- +r"""Converts Cirq circuits to Quantikz LaTeX (using modern quantikz syntax). + +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 CircuitToQuantikz + >>> 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 = CircuitToQuantikz(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} +""" + +from __future__ import annotations + +import math +import warnings +from typing import Any, Optional + +import sympy + +from cirq import circuits, ops, protocols, value + +__all__ = ["CircuitToQuantikz", "DEFAULT_PREAMBLE_TEMPLATE", "GATE_STYLES_COLORFUL"] + + +# ============================================================================= +# 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_COLORFUL = { + "H": _yellow_gate_style, + "_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) + "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, +} + + +# 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", +} +_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)), + ("Rz", getattr(ops, "Rz", None)), + ("PhasedXZ", getattr(ops, "PhasedXZGate", None)), + ("FSim", getattr(ops, "FSimGate", None)), +] +_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 +} + + +# ============================================================================= +# Cirq to Quantikz Conversion Class +# ============================================================================= +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 + 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_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 + 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 `"qid"`. + 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). + qubit_order: Determines how qubits are ordered in the diagram. + + Raises: + ValueError: If the input `circuit` is empty or contains no qubits. + """ + + def __init__( + self, + circuit: circuits.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 = "qid", + show_parameters: bool = True, + 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_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 = _GATE_NAME_MAP.copy() + if gate_name_map: + self.current_gate_name_map.update(gate_name_map) + 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() + 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 + + 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. + + 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 _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") + 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. + + 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}$"). + """ + lbl = ( + f"q_{{{index}}}" + if self.wire_labels == "q" + else ( + str(index) if self.wire_labels == "index" else str(self._escape_string(str(qubit))) + ) + ) + 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): + 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)) + # 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 then to string to remove unnecessary trailing zeros + exp_str = str(float(rounded_str)) + except ( + TypeError, + ValueError, + AttributeError, + sympy.SympifyError, + ): # pragma: nocover + # 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) + exp_str = str(exponent) + + 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. + + 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 (simple_name := _SIMPLE_GATE_MAP.get(gate_type)) is not None: + return simple_name + + 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 := _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: + # 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}" + 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 + ): # pragma: nocover + return f"{mapped_name}({self._format_exponent_for_display(gate.exponent)})" + return mapped_name # pragma: nocover + + try: + # 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, 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) + + if ( + hasattr(gate, "exponent") + 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: + recon_base = self.current_gate_name_map.get(base_key, base_key) + needs_recon = (name_cand == base_key) or ( + isinstance(gate, ops.CZPowGate) and name_cand == "@" + ) + if needs_recon: + 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): # pragma: nocover + # Fallback to default string representation if diagram info parsing fails. + pass + + name_fb = str(gate) + 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 = _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 "**" in name_fb: + parts = name_fb.split("**", 1) + if len(parts) == 2: + 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])}}}" + 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]: + """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 = 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) + + gate_type = type(gate) + style_key = gate_type.__name__ # Default style key + + # 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, ops.CZPowGate) and hasattr(gate, "exponent") and gate.exponent == 1: + style_key = "CZideal" + elif isinstance(gate, ops.SwapPowGate) and hasattr(gate, "exponent") and gate.exponent == 1: + style_key = "Swapideal" + elif isinstance(gate, ops.MeasurementGate): + style_key = "Measure" + elif (param_base_name := _PARAMETERIZED_GATE_BASE_NAMES.get(gate_type)) is not None: + style_key = param_base_name + 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, "") + final_style_tikz = f"[{style_opts_str}]" if style_opts_str else "" + + # Apply special Quantikz commands for specific gate types + if isinstance(gate, ops.MeasurementGate): + lbl = gate.key.replace("_", r"\_") if gate.key else "" + 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}}}" + 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] = 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] = 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] = f"\\swap{final_style_tikz}{{{i2-i1}}}" + output[i2] = f"\\targX{final_style_tikz}{{}}" + # Handle generic \gate command for single and multi-qubit gates + 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}" + if style_opts_str: + 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" + 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]]: + """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. + + 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 = [] + active_chunk = self._initial_active_chunk() + + for m_idx, moment in enumerate(self.circuit): + 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 + 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_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(["\\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)}}}"] + ) + chunks.append(active_chunk) + + opts_str = self._get_quantikz_options_string() + 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]) + " \\\\") + + if len(lines) > 1: + for k_idx in range(len(lines) - 1, 0, -1): + 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))) + + 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 += "\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( + [ + "\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_quantikz_test.py b/cirq-core/cirq/vis/circuit_to_latex_quantikz_test.py new file mode 100644 index 00000000000..17a016a11ea --- /dev/null +++ b/cirq-core/cirq/vis/circuit_to_latex_quantikz_test.py @@ -0,0 +1,250 @@ +# 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 pytest +import sympy + +import cirq + +# 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_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, 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, q2, q3, key='m0'), + ) + converter = CircuitToQuantikz(circuit, wire_labels="q") + latex_code = converter.generate_latex_document() + + assert r"\lstick{$q_{0}$} & \gate[" 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() + + +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.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"), + ) + # Test with show_parameters=True (default) + converter_show_params = CircuitToQuantikz(param_circuit, show_parameters=True) + 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 + # 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 + + # 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.""" + 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() + + 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, q2 = cirq.NamedQubit('alice'), cirq.LineQubit(10), cirq.GridQubit(4, 3) + circuit = cirq.Circuit(cirq.H(q0), cirq.X(q1), cirq.Z(q2)) + + # '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() + 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(): + """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() + 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() + 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 + + +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 new file mode 100644 index 00000000000..c5abbcdd58a --- /dev/null +++ b/cirq-core/cirq/vis/circuit_to_latex_render.py @@ -0,0 +1,594 @@ +# 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. + +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 `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`. + +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 `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. +""" + +from __future__ import annotations + +import io +import shutil +import subprocess +import tempfile +import traceback +import warnings +from pathlib import Path +from typing import Any + +import numpy as np + +# Import individual Cirq packages as recommended for internal Cirq code +from cirq import circuits, ops + +# Use absolute import for the sibling module +from cirq.vis.circuit_to_latex_quantikz import CircuitToQuantikz + +__all__ = ["render_circuit", "create_gif_from_ipython_images"] + +try: + from IPython.display import display, Image # pragma: nocover + + _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): + pass + + def get_ipython(*args, **kwargs) -> Any: + pass + + +# ============================================================================= +# High-Level Wrapper Function +# ============================================================================= +def render_circuit( + circuit: circuits.Circuit, + 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, + display_png_jupyter: bool = True, + cleanup: bool = True, + debug: bool = False, + timeout: int = 120, + # Carried over CircuitToQuantikz args + gate_styles: dict[str, str] | None = None, + quantikz_options: str | None = None, + fold_at: int | None = None, + wire_labels: str = "qid", + show_parameters: bool = True, + gate_name_map: dict[str, str] | None = None, + float_precision_exps: int = 2, + qubit_order: ops.QubitOrderOrList = ops.QubitOrder.DEFAULT, + **kwargs: Any, +) -> 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 + 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 `CircuitToQuantikz`. + quantikz_options: An optional string of global options to pass to the + `quantikz` environment (e.g., `"[row sep=0.5em]"`). Passed to + `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 `CircuitToQuantikz`. + wire_labels: A string specifying how qubit wire labels should be + 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 `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 `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` + 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 + >>> 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(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", + ... fold_at=2, + ... wire_labels="qid", + ... quantikz_options="[column sep=0.7em]", + ... show_parameters=False # Example of new parameter + ... ) + >>> # 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: 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 + + # 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) " + "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." + ) # 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) " + "and add its `bin` directory to your system PATH." + ) # pragma: nocover + # Disable dependent step + run_pdftoppm = False # pragma: nocover + + try: + # Use TemporaryDirectory for safe handling of temporary files + with tempfile.TemporaryDirectory() as tmpdir_s: + tmp_p = Path(tmpdir_s) + _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 CircuitToQuantikz, 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, + "qubit_order": qubit_order, + **kwargs, # Existing kwargs are merged, but explicit args take precedence + } + + try: + converter = CircuitToQuantikz(circuit, **converter_kwargs) + except Exception as e: # pragma: nocover + print(f"Error initializing CircuitToQuantikz: {e}") # pragma: nocover + if debug: + traceback.print_exc() + return None + + _debug_print("Generating LaTeX source...") + try: + latex_s = converter.generate_latex_document() + except Exception as e: # pragma: nocover + print(f"Error generating LaTeX document: {e}") + if debug: + traceback.print_exc() + return None + if debug: + _debug_print("Generated LaTeX (first 500 chars):\n", latex_s[:500] + "...") + + 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: # pragma: nocover + print(f"Error writing temporary LaTeX file {tmp_tex_path}: {e}") + return None + + pdf_generated = False + 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 = [ + pdflatex_exec, + "-interaction=nonstopmode", # Don't prompt for input + "-halt-on-error", # Exit on first error + "-output-directory", + str(tmp_p), # Output files to temp directory + str(tmp_tex_path), + ] + latex_failed = False + 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, # Don't raise CalledProcessError immediately + cwd=tmp_p, + timeout=timeout, + ) + 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" + f"{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}") + break # Exit loop if pdflatex failed + 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" + f"{log_file.read_text(errors='ignore')[-2000:]}" + ) + break + 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: # pragma: nocover + # pdflatex returned 0 but PDF not found + print("pdflatex reported success but PDF file was not found.") + 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: # pragma: nocover + _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_exec, + "-png", + "-r", + str(dpi), + "-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, # Raise CalledProcessError for non-zero exit codes + cwd=tmp_p, + timeout=timeout, + ) + if tmp_png_path.is_file(): + 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. !!!" + ) # 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: # pragma: nocover + print("!!! pdftoppm timed out. !!!") + 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 + if final_tex_path and tmp_tex_path.exists(): + try: + 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: # pragma: nocover + print(f"Error copying .tex file to final path: {e}") + 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(): # pragma: nocover + try: + final_png_path.parent.mkdir(parents=True, exist_ok=True) + 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 file to final path: {e}") + 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: 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}" + ) + 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. + # 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" + ): + 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: + _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"Error displaying PNG in Jupyter: {e_disp}") + if debug: + traceback.print_exc() + jupyter_image_object = None + else: + _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 # pragma: nocover + elif final_png_path and final_png_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") + # Default return if no specific output is generated or requested as return + return None # pragma: nocover + + 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: # pragma: nocover + 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: 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 + 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: + 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 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 # type: ignore + 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_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_file.mode == "RGBA": + # Create a white background image + background = PILImage.new("RGB", pil_img_file.size, (255, 255, 255)) + # Paste the RGBA image onto the white background + 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_file.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 + + # The 'duration' check was deemed unneeded by reviewer. + # 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.") # pragma: nocover + + 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..24b690e155a --- /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 = cirq.LineQubit.range(2) + 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