# # 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[:4] == 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: (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[:8] == "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 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: 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, 0, None)]) 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")