Skip to content
Back to top

# Source code for PIL.EpsImagePlugin

    #
    # The Python Imaging Library.
    # $Id$
    #
    # EPS file handling
    #
    # History:
    # 1995-09-01 fl   Created (0.1)
    # 1996-05-18 fl   Don't choke on "atend" fields, Ghostscript interface (0.2)
    # 1996-08-22 fl   Don't choke on floating point BoundingBox values
    # 1996-08-23 fl   Handle files from Macintosh (0.3)
    # 2001-02-17 fl   Use 're' instead of 'regex' (Python 2.1) (0.4)
    # 2003-09-07 fl   Check gs.close status (from Federico Di Gregorio) (0.5)
    # 2014-05-07 e    Handling of EPS with binary preview and fixed resolution
    #                 resizing
    #
    # Copyright (c) 1997-2003 by Secret Labs AB.
    # Copyright (c) 1995-2003 by Fredrik Lundh
    #
    # See the README file for information on usage and redistribution.
    #
    from __future__ import annotations

    import io
    import os
    import re
    import subprocess
    import sys
    import tempfile
    from typing import IO

    from . import Image, ImageFile
    from ._binary import i32le as i32

    # --------------------------------------------------------------------

    split = re.compile(r"^%%([^:]*):[ \t]*(.*)[ \t]*$")
    field = re.compile(r"^%[%!\w]([^:]*)[ \t]*$")

    gs_binary: str | bool | None = None
    gs_windows_binary = None

    def has_ghostscript() -> bool:
        global gs_binary, gs_windows_binary
        if gs_binary is None:
            if sys.platform.startswith("win"):
                if gs_windows_binary is None:
                    import shutil

                    for binary in ("gswin32c", "gswin64c", "gs"):
                        if shutil.which(binary) is not None:
                            gs_windows_binary = binary
                            break
                    else:
                        gs_windows_binary = False
                gs_binary = gs_windows_binary
            else:
                try:
                    subprocess.check_call(["gs", "--version"], stdout=subprocess.DEVNULL)
                    gs_binary = "gs"
                except OSError:
                    gs_binary = False
        return gs_binary is not False

    def Ghostscript(
        tile: list[ImageFile._Tile],
        size: tuple[int, int],
        fp: IO[bytes],
        scale: int = 1,
        transparency: bool = False,
    ) -> Image.core.ImagingCore:
        """Render an image using Ghostscript"""
        global gs_binary
        if not has_ghostscript():
            msg = "Unable to locate Ghostscript on paths"
            raise OSError(msg)
        assert isinstance(gs_binary, str)

        # Unpack decoder tile
        args = tile[0].args
        assert isinstance(args, tuple)
        length, bbox = args

        # Hack to support hi-res rendering
        scale = int(scale) or 1
        width = size[0] * scale
        height = size[1] * scale
        # resolution is dependent on bbox and size
        res_x = 72.0 * width / (bbox[2] - bbox[0])
        res_y = 72.0 * height / (bbox[3] - bbox[1])

        out_fd, outfile = tempfile.mkstemp()
        os.close(out_fd)

        infile_temp = None
        if hasattr(fp, "name") and os.path.exists(fp.name):
            infile = fp.name
        else:
            in_fd, infile_temp = tempfile.mkstemp()
            os.close(in_fd)
            infile = infile_temp

            # Ignore length and offset!
            # Ghostscript can read it
            # Copy whole file to read in Ghostscript
            with open(infile_temp, "wb") as f:
                # fetch length of fp
                fp.seek(0, io.SEEK_END)
                fsize = fp.tell()
                # ensure start position
                # go back
                fp.seek(0)
                lengthfile = fsize
                while lengthfile > 0:
                    s = fp.read(min(lengthfile, 100 * 1024))
                    if not s:
                        break
                    lengthfile -= len(s)
                    f.write(s)

        if transparency:
            # "RGBA"
            device = "pngalpha"
        else:
            # "pnmraw" automatically chooses between
            # PBM ("1"), PGM ("L"), and PPM ("RGB").
            device = "pnmraw"

        # Build Ghostscript command
        command = [
            gs_binary,
            "-q",  # quiet mode
            f"-g{width:d}x{height:d}",  # set output geometry (pixels)
            f"-r{res_x:f}x{res_y:f}",  # set input DPI (dots per inch)
            "-dBATCH",  # exit after processing
            "-dNOPAUSE",  # don't pause between pages
            "-dSAFER",  # safe mode
            f"-sDEVICE={device}",
            f"-sOutputFile={outfile}",  # output file
            # adjust for image origin
            "-c",
            f"{-bbox[0]} {-bbox[1]} translate",
            "-f",
            infile,  # input file
            # showpage (see https://bugs.ghostscript.com/show_bug.cgi?id=698272)
            "-c",
            "showpage",
        ]

        # push data through Ghostscript
        try:
            startupinfo = None
            if sys.platform.startswith("win"):
                startupinfo = subprocess.STARTUPINFO()
                startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
            subprocess.check_call(command, startupinfo=startupinfo)
            with Image.open(outfile) as out_im:
                out_im.load()
                return out_im.im.copy()
        finally:
            try:
                os.unlink(outfile)
                if infile_temp:
                    os.unlink(infile_temp)
            except OSError:
                pass

    def _accept(prefix: bytes) -> bool:
        return prefix.startswith(b"%!PS") or (
            len(prefix) >= 4 and i32(prefix) == 0xC6D3D0C5
        )

    ##
    # Image plugin for Encapsulated PostScript. This plugin supports only
    # a few variants of this format.

    class EpsImageFile(ImageFile.ImageFile):
        """EPS File Parser for the Python Imaging Library"""

        format = "EPS"
        format_description = "Encapsulated Postscript"

        mode_map = {1: "L", 2: "LAB", 3: "RGB", 4: "CMYK"}

        def _open(self) -> None:
            assert self.fp is not None
            length, offset = self._find_offset(self.fp)

            # go to offset - start of "%!PS"
            self.fp.seek(offset)

            self._mode = "RGB"

            # When reading header comments, the first comment is used.
            # When reading trailer comments, the last comment is used.
            bounding_box: list[int] | None = None
            imagedata_size: tuple[int, int] | None = None

            byte_arr = bytearray(255)
            bytes_mv = memoryview(byte_arr)
            bytes_read = 0
            reading_header_comments = True
            reading_trailer_comments = False
            trailer_reached = False

            def check_required_header_comments() -> None:
                """
                The EPS specification requires that some headers exist.
                This should be checked when the header comments formally end,
                when image data starts, or when the file ends, whichever comes first.
                """
                if "PS-Adobe" not in self.info:
                    msg = 'EPS header missing "%!PS-Adobe" comment'
                    raise SyntaxError(msg)
                if "BoundingBox" not in self.info:
                    msg = 'EPS header missing "%%BoundingBox" comment'
                    raise SyntaxError(msg)

            def read_comment(s: str) -> bool:
                nonlocal bounding_box, reading_trailer_comments
                try:
                    m = split.match(s)
                except re.error as e:
                    msg = "not an EPS file"
                    raise SyntaxError(msg) from e

                if not m:
                    return False

                k, v = m.group(1, 2)
                self.info[k] = v
                if k == "BoundingBox":
                    if v == "(atend)":
                        reading_trailer_comments = True
                    elif not bounding_box or (trailer_reached and reading_trailer_comments):
                        try:
                            # Note: The DSC spec says that BoundingBox
                            # fields should be integers, but some drivers
                            # put floating point values there anyway.
                            bounding_box = [int(float(i)) for i in v.split()]
                        except Exception:
                            pass
                return True

            while True:
                byte = self.fp.read(1)
                if byte == b"":
                    # if we didn't read a byte we must be at the end of the file
                    if bytes_read == 0:
                        if reading_header_comments:
                            check_required_header_comments()
                        break
                elif byte in b"\r\n":
                    # if we read a line ending character, ignore it and parse what
                    # we have already read. if we haven't read any other characters,
                    # continue reading
                    if bytes_read == 0:
                        continue
                else:
                    # ASCII/hexadecimal lines in an EPS file must not exceed
                    # 255 characters, not including line ending characters
                    if bytes_read >= 255:
                        # only enforce this for lines starting with a "%",
                        # otherwise assume it's binary data
                        if byte_arr[0] == ord("%"):
                            msg = "not an EPS file"
                            raise SyntaxError(msg)
                        else:
                            if reading_header_comments:
                                check_required_header_comments()
                                reading_header_comments = False
                            # reset bytes_read so we can keep reading
                            # data until the end of the line
                            bytes_read = 0
                    byte_arr[bytes_read] = byte[0]
                    bytes_read += 1
                    continue

                if reading_header_comments:
                    # Load EPS header

                    # if this line doesn't start with a "%",
                    # or does start with "%%EndComments",
                    # then we've reached the end of the header/comments
                    if byte_arr[0] != ord("%") or bytes_mv[:13] == b"%%EndComments":
                        check_required_header_comments()
                        reading_header_comments = False
                        continue

                    s = str(bytes_mv[:bytes_read], "latin-1")
                    if not read_comment(s):
                        m = field.match(s)
                        if m:
                            k = m.group(1)
                            if k.startswith("PS-Adobe"):
                                self.info["PS-Adobe"] = k[9:]
                            else:
                                self.info[k] = ""
                        elif s[0] == "%":
                            # handle non-DSC PostScript comments that some
                            # tools mistakenly put in the Comments section
                            pass
                        else:
                            msg = "bad EPS header"
                            raise OSError(msg)
                elif bytes_mv[:11] == b"%ImageData:":
                    # Check for an "ImageData" descriptor
                    # https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#50577413_pgfId-1035096

                    # If we've already read an "ImageData" descriptor,
                    # don't read another one.
                    if imagedata_size:
                        bytes_read = 0
                        continue

                    # Values:
                    # columns
                    # rows
                    # bit depth (1 or 8)
                    # mode (1: L, 2: LAB, 3: RGB, 4: CMYK)
                    # number of padding channels
                    # block size (number of bytes per row per channel)
                    # binary/ascii (1: binary, 2: ascii)
                    # data start identifier (the image data follows after a single line
                    #   consisting only of this quoted value)
                    image_data_values = byte_arr[11:bytes_read].split(None, 7)
                    columns, rows, bit_depth, mode_id = (
                        int(value) for value in image_data_values[:4]
                    )

                    if bit_depth == 1:
                        self._mode = "1"
                    elif bit_depth == 8:
                        try:
                            self._mode = self.mode_map[mode_id]
                        except ValueError:
                            break
                    else:
                        break

                    # Parse the columns and rows after checking the bit depth and mode
                    # in case the bit depth and/or mode are invalid.
                    imagedata_size = columns, rows
                elif bytes_mv[:5] == b"%%EOF":
                    break
                elif trailer_reached and reading_trailer_comments:
                    # Load EPS trailer
                    s = str(bytes_mv[:bytes_read], "latin-1")
                    read_comment(s)
                elif bytes_mv[:9] == b"%%Trailer":
                    trailer_reached = True
                elif bytes_mv[:14] == b"%%BeginBinary:":
                    bytecount = int(byte_arr[14:bytes_read])
                    if bytecount < 0:
                        msg = "BeginBinary bytecount cannot be negative"
                        raise ValueError(msg)
                    self.fp.seek(bytecount, os.SEEK_CUR)
                bytes_read = 0

            # A "BoundingBox" is always required,
            # even if an "ImageData" descriptor size exists.
            if not bounding_box:
                msg = "cannot determine EPS bounding box"
                raise OSError(msg)

            # An "ImageData" size takes precedence over the "BoundingBox".
            self._size = imagedata_size or (
                bounding_box[2] - bounding_box[0],
                bounding_box[3] - bounding_box[1],
            )

            self.tile = [
                ImageFile._Tile("eps", (0, 0) + self.size, offset, (length, bounding_box))
            ]

        def _find_offset(self, fp: IO[bytes]) -> tuple[int, int]:
            s = fp.read(4)

            if s == b"%!PS":
                # for HEAD without binary preview
                fp.seek(0, io.SEEK_END)
                length = fp.tell()
                offset = 0
            elif i32(s) == 0xC6D3D0C5:
                # FIX for: Some EPS file not handled correctly / issue #302
                # EPS can contain binary data
                # or start directly with latin coding
                # more info see:
                # https://web.archive.org/web/20160528181353/http://partners.adobe.com/public/developer/en/ps/5002.EPSF_Spec.pdf
                s = fp.read(8)
                offset = i32(s)
                length = i32(s, 4)
            else:
                msg = "not an EPS file"
                raise SyntaxError(msg)

            return length, offset

        def load(
            self, scale: int = 1, transparency: bool = False
        ) -> Image.core.PixelAccess | None:
            # Load EPS via Ghostscript
            if self.tile:
                assert self.fp is not None
                self.im = Ghostscript(self.tile, self.size, self.fp, scale, transparency)
                self._mode = self.im.mode
                self._size = self.im.size
                self.tile = []
            return Image.Image.load(self)

        def load_seek(self, pos: int) -> None:
            # we can't incrementally load, so force ImageFile.parser to
            # use our custom load method by defining this method.
            pass

    # --------------------------------------------------------------------

    def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes, eps: int = 1) -> None:
        """EPS Writer for the Python Imaging Library."""

        # make sure image data is available
        im.load()

        # determine PostScript image mode
        if im.mode == "L":
            operator = (8, 1, b"image")
        elif im.mode == "RGB":
            operator = (8, 3, b"false 3 colorimage")
        elif im.mode == "CMYK":
            operator = (8, 4, b"false 4 colorimage")
        else:
            msg = "image mode is not supported"
            raise ValueError(msg)

        if eps:
            # write EPS header
            fp.write(b"%!PS-Adobe-3.0 EPSF-3.0\n")
            fp.write(b"%%Creator: PIL 0.1 EpsEncode\n")
            # fp.write("%%CreationDate: %s"...)
            fp.write(b"%%%%BoundingBox: 0 0 %d %d\n" % im.size)
            fp.write(b"%%Pages: 1\n")
            fp.write(b"%%EndComments\n")
            fp.write(b"%%Page: 1 1\n")
            fp.write(b"%%ImageData: %d %d " % im.size)
            fp.write(b'%d %d 0 1 1 "%s"\n' % operator)

        # image header
        fp.write(b"gsave\n")
        fp.write(b"10 dict begin\n")
        fp.write(b"/buf %d string def\n" % (im.size[0] * operator[1]))
        fp.write(b"%d %d scale\n" % im.size)
        fp.write(b"%d %d 8\n" % im.size)  # <= bits
        fp.write(b"[%d 0 0 -%d 0 %d]\n" % (im.size[0], im.size[1], im.size[1]))
        fp.write(b"{ currentfile buf readhexstring pop } bind\n")
        fp.write(operator[2] + b"\n")
        if hasattr(fp, "flush"):
            fp.flush()

        ImageFile._save(im, fp, [ImageFile._Tile("eps", (0, 0) + im.size)])

        fp.write(b"\n%%%%EndBinary\n")
        fp.write(b"grestore end\n")
        if hasattr(fp, "flush"):
            fp.flush()

    # --------------------------------------------------------------------

    Image.register_open(EpsImageFile.format, EpsImageFile, _accept)

    Image.register_save(EpsImageFile.format, _save)

    Image.register_extensions(EpsImageFile.format, [".ps", ".eps"])

    Image.register_mime(EpsImageFile.format, "application/postscript")