diff --git a/cirq-core/cirq/vis/__init__.py b/cirq-core/cirq/vis/__init__.py index 69c47c19cc3..50ab2b1d5cd 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 +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..7e20660e110 --- /dev/null +++ b/cirq-core/cirq/vis/circuit_to_latex_quantikz.py @@ -0,0 +1,751 @@ +# 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, `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} +""" + +import math +import warnings +from pathlib import Path +from typing import Any, Dict, List, Optional, Type + +import numpy as np +import sympy + +from cirq import circuits, devices, ops, protocols + +__all__ = ["CircuitToQuantikz", "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, # 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", +} +_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 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_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: 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 = "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 + + # 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]: + """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: 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}$"). + """ + 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): + 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 and then to string to remove unnecessary trailing zeros + exp_str = str(float(rounded_str)) + 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) + 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: 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 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: + # 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 (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: + # 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 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, 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])}}}" + return fmt_name + except (ValueError, AttributeError, IndexError): + # 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("()"): + 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: 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 + + # 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 := self._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: + style_key = base_key_for_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 + 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, 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: + 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: + 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 + + # 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}" + if style_opts_str: + combined_opts = f"{wires_opt}, {style_opts_str}" + else: + 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: + """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)}}}") + + 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: + """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()] + 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..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 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..6ed4a18737e --- /dev/null +++ b/cirq-core/cirq/vis/circuit_to_latex_render.py @@ -0,0 +1,583 @@ +# 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 `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. +""" + + +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 numpy as np +import sympy + +# 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"] + +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 + + def get_ipython(*args, **kwargs): # type: ignore + pass + + +# ============================================================================= +# High-Level Wrapper Function +# ============================================================================= +def render_circuit( + circuit: circuits.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: int = 120, + # Carried over CircuitToQuantikz 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 `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`. + **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 + >>> 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.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: 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." + ) + 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. " + "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 # 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"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, + **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}") + if debug: + traceback.print_exc() + return None + + _debug_print("Generating LaTeX source...") + try: + latex_s = converter.generate_latex_document() + except Exception as e: + 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: + print(f"Error writing temporary LaTeX file {tmp_tex_path}: {e}") + return None + + 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_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: + 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:]}" + ) + 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: + 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:]}" + ) + 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: # pdflatex returned 0 but PDF not found + print("pdflatex reported success but PDF file was not found.") + if latex_failed: + 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_exec, + "-png", + f"-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. !!!") + except subprocess.CalledProcessError as e_ppm: + print( + 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"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: + 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_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(): + 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: + # 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 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 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() + ): + 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 + 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: + 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: 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: + 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 + 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 + + # 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.") + + except Exception as fallback_e: + print(f"Fallback GIF saving also failed: {fallback_e}")