qutip/qutip-qip

View on GitHub
src/qutip_qip/circuit/texrenderer.py

Summary

Maintainability
D
1 day
Test Coverage
import os
import sys
import shutil
import tempfile
import warnings
import functools
import subprocess
import collections

from ..operations import Gate

__all__ = ["TeXRenderer", "CONVERTERS"]


class TeXRenderer:
    """
    Class to render the circuit in latex format.
    """

    def __init__(self, qc):

        self.qc = qc
        self.N = qc.N
        self.num_cbits = qc.num_cbits
        self.gates = qc.gates
        self.input_states = qc.input_states
        self.reverse_states = qc.reverse_states

        self._latex_template = r"""
        \documentclass[border=3pt]{standalone}
        \usepackage[braket]{qcircuit}
        \begin{document}
        \Qcircuit @C=1cm @R=1cm {
        %s}
        \end{document}
        """

        self._pdflatex = self._find_system_command(["pdflatex"])
        self._pdfcrop = self._find_system_command(["pdfcrop"])

    def _gate_label(self, gate):
        gate_label = gate.latex_str
        if gate.arg_label is not None:
            return r"%s(%s)" % (gate_label, gate.arg_label)
        return r"%s" % gate_label

    def latex_code(self):
        """
        Generate the latex code for the circuit.

        Returns
        -------
        code: str
            The latex code for the circuit.
        """

        rows = []

        ops = self.gates
        col = []
        for op in ops:
            if isinstance(op, Gate):
                gate = op
                col = []
                _swap_processing = False
                for n in range(self.N + self.num_cbits):
                    if gate.targets and n in gate.targets:
                        if len(gate.targets) > 1:
                            if gate.name == "SWAP":
                                if _swap_processing:
                                    col.append(r" \qswap \qw")
                                    continue
                                distance = abs(
                                    gate.targets[1] - gate.targets[0]
                                )
                                if self.reverse_states:
                                    distance = -distance
                                col.append(r" \qswap \qwx[%d] \qw" % distance)
                                _swap_processing = True

                            elif (
                                self.reverse_states and n == max(gate.targets)
                            ) or (
                                not self.reverse_states
                                and n == min(gate.targets)
                            ):
                                col.append(
                                    r" \multigate{%d}{%s} "
                                    % (
                                        len(gate.targets) - 1,
                                        self._gate_label(gate),
                                    )
                                )
                            else:
                                col.append(
                                    r" \ghost{%s} " % (self._gate_label(gate))
                                )

                        elif gate.name == "CNOT":
                            col.append(r" \targ ")
                        elif gate.name == "CY":
                            col.append(r" \targ ")
                        elif gate.name == "CZ":
                            col.append(r" \targ ")
                        elif gate.name == "CS":
                            col.append(r" \targ ")
                        elif gate.name == "CT":
                            col.append(r" \targ ")
                        elif gate.name == "TOFFOLI":
                            col.append(r" \targ ")
                        else:
                            col.append(r" \gate{%s} " % self._gate_label(gate))

                    elif gate.controls and n in gate.controls:
                        control_tag = (-1 if self.reverse_states else 1) * (
                            gate.targets[0] - n
                        )
                        col.append(r" \ctrl{%d} " % control_tag)

                    elif (
                        gate.classical_controls
                        and (n - self.N) in gate.classical_controls
                    ):
                        control_tag = (-1 if self.reverse_states else 1) * (
                            gate.targets[0] - n
                        )
                        col.append(r" \ctrl{%d} " % control_tag)

                    elif not gate.controls and not gate.targets:
                        # global gate
                        if (self.reverse_states and n == self.N - 1) or (
                            not self.reverse_states and n == 0
                        ):
                            col.append(
                                r" \multigate{%d}{%s} "
                                % (
                                    self.N - 1,
                                    self._gate_label(gate),
                                )
                            )
                        else:
                            col.append(
                                r" \ghost{%s} " % (self._gate_label(gate))
                            )
                    else:
                        col.append(r" \qw ")

            else:
                measurement = op
                col = []
                for n in range(self.N + self.num_cbits):
                    if n in measurement.targets:
                        col.append(r" \meter")
                    elif (n - self.N) == measurement.classical_store:
                        sgn = 1 if self.reverse_states else -1
                        store_tag = sgn * (n - measurement.targets[0])
                        col.append(r" \qw \cwx[%d] " % store_tag)
                    else:
                        col.append(r" \qw ")

            col.append(r" \qw ")
            rows.append(col)

        input_states_quantum = [
            r"\lstick{\ket{" + x + "}}" if x is not None else ""
            for x in self.input_states[: self.N]
        ]
        input_states_classical = [
            r"\lstick{" + x + "}" if x is not None else ""
            for x in self.input_states[self.N :]
        ]
        input_states = input_states_quantum + input_states_classical

        code = ""
        n_iter = (
            reversed(range(self.N + self.num_cbits))
            if self.reverse_states
            else range(self.N + self.num_cbits)
        )
        for n in n_iter:
            code += r" & %s" % input_states[n]
            for m in range(len(ops)):
                code += r" & %s" % rows[m][n]
            code += r" & \qw \\ " + "\n"

        return self._latex_template % code

    def raw_img(self, file_type="png", dpi=100):
        return self.image_from_latex(self.latex_code(), file_type, dpi)

    @classmethod
    def _run_command(self, command, *args, **kwargs):
        """
        Run a command with stdout explicitly thrown away, raising
        `RuntimeError` with the system error message
        if the command returned a non-zero exit code.
        """
        try:
            return subprocess.run(
                command,
                *args,
                check=True,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.PIPE,
                **kwargs,
            )
        except subprocess.CalledProcessError as e:
            raise RuntimeError(e.stderr.decode(sys.stderr.encoding)) from None

    def _force_remove(self, *filenames):
        """`rm -f`: try to remove a file, ignoring errors if it doesn't exist."""
        for filename in filenames:
            try:
                os.remove(filename)
            except FileNotFoundError:
                pass

    @classmethod
    def _test_convert_is_imagemagick(self):
        """
        Test to see if the `convert` command behaves like we'd expect ImageMagick
        to.  On Windows if ImageMagick is not installed then `convert` may refer to
        a system utility.
        """
        try:
            # Don't use `capture_output` because we're still supporting Python 3.6
            process = subprocess.run(
                ("convert", "-version"),
                stdout=subprocess.PIPE,
                stderr=subprocess.DEVNULL,
            )
            return "imagemagick" in process.stdout.decode("utf-8").lower()
        except FileNotFoundError:
            return False

    @staticmethod
    def _find_system_command(names):
        """
        Given a list of possible system commands (as strings), return the first one
        which has a locatable executable form, or `None` if none of them do.  We
        also check some special cases of shadowing (e.g. ImageMagick 6's `convert`
        is also a Windows system utility) to try and catch false-positives.
        """
        for name in names:
            if shutil.which(name) is not None:
                is_valid = _SPECIAL_CASES.get(name, lambda: True)()
                if is_valid:
                    return name
        return None

    def _crop_pdf(self, filename):
        if self._pdfcrop is not None:
            """Crop the pdf file `filename` in place."""
            temporary = ".tmp." + filename
            self._run_command((self._pdfcrop, filename, temporary))
            # Windows does not allow renaming to an existing file (but unix does).
            self._force_remove(filename)
            os.rename(temporary, filename)
        else:
            # Warn, but do not raise - we can recover from a failed crop.
            warnings.warn(
                "Could not locate system 'pdfcrop':"
                " image output may have additional margins."
            )

    @staticmethod
    def _convert_pdf(file_stem, dpi=None):
        """
        'Convert' to pdf: since LaTeX outputs a PDF file, there's nothing to do.
        """
        if dpi is not None:
            warnings.warn("argument dpi is ignored for pdf output.")
        with open(file_stem + ".pdf", "rb") as file:
            return file.read()

    @classmethod
    def _make_converter(self, configuration):
        """
        Create the actual conversion function of signature
            file_stem: str -> 'T,
        where 'T is data in the format to be converted to.
        """
        which = self._find_system_command(configuration.executables)
        if which is None:
            return None
        mode = "rb" if configuration.binary else "r"

        def converter(file_stem, dpi=100):
            """
            Convert a file located in the current directory named `<file_stem>.pdf`
            to an image format with the name `<file_stem>.xxx`, where `xxx` is
            converter-dependent.

            Parameters
            ----------
            file_stem : str
                The basename of the PDF file to be converted.
            dpi : int/float
                Image density in dots per inch. Ignored for SVG.
            """
            in_file = file_stem + ".pdf"
            out_file = file_stem + "." + configuration.file_type
            if "-density" in configuration.arguments:
                arguments = list(configuration.arguments)
                arguments[arguments.index("-density") + 1] = str(dpi)
            else:
                arguments = configuration.arguments
            self._run_command((which, *arguments, in_file, out_file))
            with open(out_file, mode) as file:
                return file.read()

        return converter

    def image_from_latex(self, code, file_type="png", dpi=100):
        """
        Convert the LaTeX `code` into an image format, defined by the
        `file_type`.  Returns a string or bytes object, depending on whether
        the requested type is textual (e.g. svg) or binary (e.g. png).  The
        known file types are in keys in this module's `CONVERTERS` dictionary.

        Parameters
        ----------
        code: str
            LaTeX code representing the circuit to be converted.

        file_type: str ("png")
            The file type that the image should be returned in.


        Returns
        -------
        image: str or bytes
            An encoded version of the image.  Whether the output type is str or
            bytes depends on whether the requested image format is textual or
            binary.
        """
        if self._pdflatex is not None:
            filename = "qcirc"  # Arbitrary and internal.
            # We do all the image conversion in a temporary directory to prevent
            # leftover files if something goes wrong (or we get a
            # KeyboardInterrupt) during conversion.
            previous_dir = os.getcwd()
            with tempfile.TemporaryDirectory() as temporary_dir:
                try:
                    os.chdir(temporary_dir)
                    with open(filename + ".tex", "w") as file:
                        file.write(code)
                    try:
                        self._run_command(
                            (
                                self._pdflatex,
                                "-interaction",
                                "batchmode",
                                filename,
                            )
                        )
                    except RuntimeError as e:
                        message = (
                            "pdflatex failed."
                            " Perhaps you do not have it installed, or you are"
                            " missing the LaTeX package 'qcircuit'."
                        )
                        message += (
                            "The latex code is printed below. "
                            "Please try to compile locally using pdflatex:\n"
                            + code
                        )
                        raise RuntimeError(message) from e
                    self._crop_pdf(filename + ".pdf")
                    if file_type in _MISSING_CONVERTERS:
                        dependency = _MISSING_CONVERTERS[file_type]
                        message = "".join(
                            [
                                "Could not find system ",
                                dependency,
                                ".",
                                " Image conversion to '",
                                file_type,
                                "'",
                                " is not available.",
                            ]
                        )
                        raise RuntimeError(message)
                    if file_type not in CONVERTERS:
                        raise ValueError(
                            "".join(
                                ["Unknown output format: '", file_type, "'."]
                            )
                        )
                    out = CONVERTERS[file_type](filename, dpi)
                finally:
                    # Leave the temporary directory before it is removed (necessary
                    # on Windows, but it doesn't hurt on POSIX).
                    os.chdir(previous_dir)
            return out
        else:
            raise RuntimeError("Could not find system 'pdflatex'.")


# Record type to hold definitions of possible conversions - this is just for
# reading convenience.
_ConverterConfiguration = collections.namedtuple(
    "_ConverterConfiguration",
    ["file_type", "dependency", "executables", "arguments", "binary"],
)
CONVERTERS = {"pdf": TeXRenderer._convert_pdf}
_MISSING_CONVERTERS = {}
_CONVERTER_CONFIGURATIONS = [
    _ConverterConfiguration(
        "png",
        "ImageMagick",
        ["magick", "convert"],
        arguments=("-density", "100"),
        binary=True,
    ),
    _ConverterConfiguration(
        "svg", "pdf2svg", ["pdf2svg"], arguments=(), binary=False
    ),
]
_SPECIAL_CASES = {
    "convert": TeXRenderer._test_convert_is_imagemagick,
}
for configuration in _CONVERTER_CONFIGURATIONS:
    # Make the converter using a higher-order function, because if we defined a
    # function in the loop, it would be easy to later introduce bugs due to
    # leaky closures over loop variables.
    converter = TeXRenderer._make_converter(configuration)
    if converter:
        CONVERTERS[configuration.file_type] = converter
    else:
        _MISSING_CONVERTERS[configuration.file_type] = configuration.dependency