You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
475 lines
15 KiB
475 lines
15 KiB
5 months ago
|
#
|
||
|
# 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 . 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():
|
||
|
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):
|
||
|
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):
|
||
|
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):
|
||
|
(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():
|
||
|
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):
|
||
|
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 m:
|
||
|
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:
|
||
|
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
|
||
|
|
||
|
check_required_header_comments()
|
||
|
|
||
|
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):
|
||
|
# we can't incrementally load, so force ImageFile.parser to
|
||
|
# use our custom load method by defining this method.
|
||
|
pass
|
||
|
|
||
|
|
||
|
# --------------------------------------------------------------------
|
||
|
|
||
|
|
||
|
def _save(im, fp, filename, eps=1):
|
||
|
"""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")
|