Spaces:
Running
Running
# | |
# 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 | |
from ._deprecate import deprecate | |
# -------------------------------------------------------------------- | |
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, size, fp, scale=1, transparency=False): | |
"""Render an image using Ghostscript""" | |
global gs_binary | |
if not has_ghostscript(): | |
msg = "Unable to locate Ghostscript on paths" | |
raise OSError(msg) | |
# Unpack decoder tile | |
decoder, tile, offset, data = tile[0] | |
length, bbox = data | |
# 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) | |
device = "pngalpha" if transparency else "ppmraw" | |
# 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) | |
out_im = Image.open(outfile) | |
out_im.load() | |
finally: | |
try: | |
os.unlink(outfile) | |
if infile_temp: | |
os.unlink(infile_temp) | |
except OSError: | |
pass | |
im = out_im.im.copy() | |
out_im.close() | |
return im | |
class PSFile: | |
""" | |
Wrapper for bytesio object that treats either CR or LF as end of line. | |
This class is no longer used internally, but kept for backwards compatibility. | |
""" | |
def __init__(self, fp): | |
deprecate( | |
"PSFile", | |
11, | |
action="If you need the functionality of this class " | |
"you will need to implement it yourself.", | |
) | |
self.fp = fp | |
self.char = None | |
def seek(self, offset, whence=io.SEEK_SET): | |
self.char = None | |
self.fp.seek(offset, whence) | |
def readline(self) -> str: | |
s = [self.char or b""] | |
self.char = None | |
c = self.fp.read(1) | |
while (c not in b"\r\n") and len(c): | |
s.append(c) | |
c = self.fp.read(1) | |
self.char = self.fp.read(1) | |
# line endings can be 1 or 2 of \r \n, in either order | |
if self.char in b"\r\n": | |
self.char = None | |
return b"".join(s).decode("latin-1") | |
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" | |
self._size = 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 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 self._size 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. | |
box = [int(float(i)) for i in v.split()] | |
self._size = box[2] - box[0], box[3] - box[1] | |
self.tile = [("eps", (0, 0) + self.size, offset, (length, box))] | |
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 | |
# 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 | |
self._size = columns, rows | |
return | |
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 | |
if not self._size: | |
msg = "cannot determine EPS bounding box" | |
raise OSError(msg) | |
def _find_offset(self, fp): | |
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=1, transparency=False): | |
# 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, [("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") | |