1#
2# The Python Imaging Library.
3# $Id$
4#
5# EPS file handling
6#
7# History:
8# 1995-09-01 fl Created (0.1)
9# 1996-05-18 fl Don't choke on "atend" fields, Ghostscript interface (0.2)
10# 1996-08-22 fl Don't choke on floating point BoundingBox values
11# 1996-08-23 fl Handle files from Macintosh (0.3)
12# 2001-02-17 fl Use 're' instead of 'regex' (Python 2.1) (0.4)
13# 2003-09-07 fl Check gs.close status (from Federico Di Gregorio) (0.5)
14# 2014-05-07 e Handling of EPS with binary preview and fixed resolution
15# resizing
16#
17# Copyright (c) 1997-2003 by Secret Labs AB.
18# Copyright (c) 1995-2003 by Fredrik Lundh
19#
20# See the README file for information on usage and redistribution.
21#
22from __future__ import annotations
23
24import io
25import os
26import re
27import subprocess
28import sys
29import tempfile
30from typing import IO
31
32from . import Image, ImageFile
33from ._binary import i32le as i32
34from ._deprecate import deprecate
35
36# --------------------------------------------------------------------
37
38
39split = re.compile(r"^%%([^:]*):[ \t]*(.*)[ \t]*$")
40field = re.compile(r"^%[%!\w]([^:]*)[ \t]*$")
41
42gs_binary: str | bool | None = None
43gs_windows_binary = None
44
45
46def has_ghostscript() -> bool:
47 global gs_binary, gs_windows_binary
48 if gs_binary is None:
49 if sys.platform.startswith("win"):
50 if gs_windows_binary is None:
51 import shutil
52
53 for binary in ("gswin32c", "gswin64c", "gs"):
54 if shutil.which(binary) is not None:
55 gs_windows_binary = binary
56 break
57 else:
58 gs_windows_binary = False
59 gs_binary = gs_windows_binary
60 else:
61 try:
62 subprocess.check_call(["gs", "--version"], stdout=subprocess.DEVNULL)
63 gs_binary = "gs"
64 except OSError:
65 gs_binary = False
66 return gs_binary is not False
67
68
69def Ghostscript(tile, size, fp, scale=1, transparency=False):
70 """Render an image using Ghostscript"""
71 global gs_binary
72 if not has_ghostscript():
73 msg = "Unable to locate Ghostscript on paths"
74 raise OSError(msg)
75
76 # Unpack decoder tile
77 decoder, tile, offset, data = tile[0]
78 length, bbox = data
79
80 # Hack to support hi-res rendering
81 scale = int(scale) or 1
82 width = size[0] * scale
83 height = size[1] * scale
84 # resolution is dependent on bbox and size
85 res_x = 72.0 * width / (bbox[2] - bbox[0])
86 res_y = 72.0 * height / (bbox[3] - bbox[1])
87
88 out_fd, outfile = tempfile.mkstemp()
89 os.close(out_fd)
90
91 infile_temp = None
92 if hasattr(fp, "name") and os.path.exists(fp.name):
93 infile = fp.name
94 else:
95 in_fd, infile_temp = tempfile.mkstemp()
96 os.close(in_fd)
97 infile = infile_temp
98
99 # Ignore length and offset!
100 # Ghostscript can read it
101 # Copy whole file to read in Ghostscript
102 with open(infile_temp, "wb") as f:
103 # fetch length of fp
104 fp.seek(0, io.SEEK_END)
105 fsize = fp.tell()
106 # ensure start position
107 # go back
108 fp.seek(0)
109 lengthfile = fsize
110 while lengthfile > 0:
111 s = fp.read(min(lengthfile, 100 * 1024))
112 if not s:
113 break
114 lengthfile -= len(s)
115 f.write(s)
116
117 device = "pngalpha" if transparency else "ppmraw"
118
119 # Build Ghostscript command
120 command = [
121 gs_binary,
122 "-q", # quiet mode
123 f"-g{width:d}x{height:d}", # set output geometry (pixels)
124 f"-r{res_x:f}x{res_y:f}", # set input DPI (dots per inch)
125 "-dBATCH", # exit after processing
126 "-dNOPAUSE", # don't pause between pages
127 "-dSAFER", # safe mode
128 f"-sDEVICE={device}",
129 f"-sOutputFile={outfile}", # output file
130 # adjust for image origin
131 "-c",
132 f"{-bbox[0]} {-bbox[1]} translate",
133 "-f",
134 infile, # input file
135 # showpage (see https://bugs.ghostscript.com/show_bug.cgi?id=698272)
136 "-c",
137 "showpage",
138 ]
139
140 # push data through Ghostscript
141 try:
142 startupinfo = None
143 if sys.platform.startswith("win"):
144 startupinfo = subprocess.STARTUPINFO()
145 startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
146 subprocess.check_call(command, startupinfo=startupinfo)
147 out_im = Image.open(outfile)
148 out_im.load()
149 finally:
150 try:
151 os.unlink(outfile)
152 if infile_temp:
153 os.unlink(infile_temp)
154 except OSError:
155 pass
156
157 im = out_im.im.copy()
158 out_im.close()
159 return im
160
161
162class PSFile:
163 """
164 Wrapper for bytesio object that treats either CR or LF as end of line.
165 This class is no longer used internally, but kept for backwards compatibility.
166 """
167
168 def __init__(self, fp):
169 deprecate(
170 "PSFile",
171 11,
172 action="If you need the functionality of this class "
173 "you will need to implement it yourself.",
174 )
175 self.fp = fp
176 self.char = None
177
178 def seek(self, offset, whence=io.SEEK_SET):
179 self.char = None
180 self.fp.seek(offset, whence)
181
182 def readline(self) -> str:
183 s = [self.char or b""]
184 self.char = None
185
186 c = self.fp.read(1)
187 while (c not in b"\r\n") and len(c):
188 s.append(c)
189 c = self.fp.read(1)
190
191 self.char = self.fp.read(1)
192 # line endings can be 1 or 2 of \r \n, in either order
193 if self.char in b"\r\n":
194 self.char = None
195
196 return b"".join(s).decode("latin-1")
197
198
199def _accept(prefix: bytes) -> bool:
200 return prefix[:4] == b"%!PS" or (len(prefix) >= 4 and i32(prefix) == 0xC6D3D0C5)
201
202
203##
204# Image plugin for Encapsulated PostScript. This plugin supports only
205# a few variants of this format.
206
207
208class EpsImageFile(ImageFile.ImageFile):
209 """EPS File Parser for the Python Imaging Library"""
210
211 format = "EPS"
212 format_description = "Encapsulated Postscript"
213
214 mode_map = {1: "L", 2: "LAB", 3: "RGB", 4: "CMYK"}
215
216 def _open(self) -> None:
217 (length, offset) = self._find_offset(self.fp)
218
219 # go to offset - start of "%!PS"
220 self.fp.seek(offset)
221
222 self._mode = "RGB"
223 self._size = None
224
225 byte_arr = bytearray(255)
226 bytes_mv = memoryview(byte_arr)
227 bytes_read = 0
228 reading_header_comments = True
229 reading_trailer_comments = False
230 trailer_reached = False
231
232 def check_required_header_comments() -> None:
233 """
234 The EPS specification requires that some headers exist.
235 This should be checked when the header comments formally end,
236 when image data starts, or when the file ends, whichever comes first.
237 """
238 if "PS-Adobe" not in self.info:
239 msg = 'EPS header missing "%!PS-Adobe" comment'
240 raise SyntaxError(msg)
241 if "BoundingBox" not in self.info:
242 msg = 'EPS header missing "%%BoundingBox" comment'
243 raise SyntaxError(msg)
244
245 def _read_comment(s: str) -> bool:
246 nonlocal reading_trailer_comments
247 try:
248 m = split.match(s)
249 except re.error as e:
250 msg = "not an EPS file"
251 raise SyntaxError(msg) from e
252
253 if not m:
254 return False
255
256 k, v = m.group(1, 2)
257 self.info[k] = v
258 if k == "BoundingBox":
259 if v == "(atend)":
260 reading_trailer_comments = True
261 elif not self._size or (trailer_reached and reading_trailer_comments):
262 try:
263 # Note: The DSC spec says that BoundingBox
264 # fields should be integers, but some drivers
265 # put floating point values there anyway.
266 box = [int(float(i)) for i in v.split()]
267 self._size = box[2] - box[0], box[3] - box[1]
268 self.tile = [("eps", (0, 0) + self.size, offset, (length, box))]
269 except Exception:
270 pass
271 return True
272
273 while True:
274 byte = self.fp.read(1)
275 if byte == b"":
276 # if we didn't read a byte we must be at the end of the file
277 if bytes_read == 0:
278 if reading_header_comments:
279 check_required_header_comments()
280 break
281 elif byte in b"\r\n":
282 # if we read a line ending character, ignore it and parse what
283 # we have already read. if we haven't read any other characters,
284 # continue reading
285 if bytes_read == 0:
286 continue
287 else:
288 # ASCII/hexadecimal lines in an EPS file must not exceed
289 # 255 characters, not including line ending characters
290 if bytes_read >= 255:
291 # only enforce this for lines starting with a "%",
292 # otherwise assume it's binary data
293 if byte_arr[0] == ord("%"):
294 msg = "not an EPS file"
295 raise SyntaxError(msg)
296 else:
297 if reading_header_comments:
298 check_required_header_comments()
299 reading_header_comments = False
300 # reset bytes_read so we can keep reading
301 # data until the end of the line
302 bytes_read = 0
303 byte_arr[bytes_read] = byte[0]
304 bytes_read += 1
305 continue
306
307 if reading_header_comments:
308 # Load EPS header
309
310 # if this line doesn't start with a "%",
311 # or does start with "%%EndComments",
312 # then we've reached the end of the header/comments
313 if byte_arr[0] != ord("%") or bytes_mv[:13] == b"%%EndComments":
314 check_required_header_comments()
315 reading_header_comments = False
316 continue
317
318 s = str(bytes_mv[:bytes_read], "latin-1")
319 if not _read_comment(s):
320 m = field.match(s)
321 if m:
322 k = m.group(1)
323 if k[:8] == "PS-Adobe":
324 self.info["PS-Adobe"] = k[9:]
325 else:
326 self.info[k] = ""
327 elif s[0] == "%":
328 # handle non-DSC PostScript comments that some
329 # tools mistakenly put in the Comments section
330 pass
331 else:
332 msg = "bad EPS header"
333 raise OSError(msg)
334 elif bytes_mv[:11] == b"%ImageData:":
335 # Check for an "ImageData" descriptor
336 # https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#50577413_pgfId-1035096
337
338 # Values:
339 # columns
340 # rows
341 # bit depth (1 or 8)
342 # mode (1: L, 2: LAB, 3: RGB, 4: CMYK)
343 # number of padding channels
344 # block size (number of bytes per row per channel)
345 # binary/ascii (1: binary, 2: ascii)
346 # data start identifier (the image data follows after a single line
347 # consisting only of this quoted value)
348 image_data_values = byte_arr[11:bytes_read].split(None, 7)
349 columns, rows, bit_depth, mode_id = (
350 int(value) for value in image_data_values[:4]
351 )
352
353 if bit_depth == 1:
354 self._mode = "1"
355 elif bit_depth == 8:
356 try:
357 self._mode = self.mode_map[mode_id]
358 except ValueError:
359 break
360 else:
361 break
362
363 self._size = columns, rows
364 return
365 elif bytes_mv[:5] == b"%%EOF":
366 break
367 elif trailer_reached and reading_trailer_comments:
368 # Load EPS trailer
369 s = str(bytes_mv[:bytes_read], "latin-1")
370 _read_comment(s)
371 elif bytes_mv[:9] == b"%%Trailer":
372 trailer_reached = True
373 bytes_read = 0
374
375 if not self._size:
376 msg = "cannot determine EPS bounding box"
377 raise OSError(msg)
378
379 def _find_offset(self, fp):
380 s = fp.read(4)
381
382 if s == b"%!PS":
383 # for HEAD without binary preview
384 fp.seek(0, io.SEEK_END)
385 length = fp.tell()
386 offset = 0
387 elif i32(s) == 0xC6D3D0C5:
388 # FIX for: Some EPS file not handled correctly / issue #302
389 # EPS can contain binary data
390 # or start directly with latin coding
391 # more info see:
392 # https://web.archive.org/web/20160528181353/http://partners.adobe.com/public/developer/en/ps/5002.EPSF_Spec.pdf
393 s = fp.read(8)
394 offset = i32(s)
395 length = i32(s, 4)
396 else:
397 msg = "not an EPS file"
398 raise SyntaxError(msg)
399
400 return length, offset
401
402 def load(self, scale=1, transparency=False):
403 # Load EPS via Ghostscript
404 if self.tile:
405 self.im = Ghostscript(self.tile, self.size, self.fp, scale, transparency)
406 self._mode = self.im.mode
407 self._size = self.im.size
408 self.tile = []
409 return Image.Image.load(self)
410
411 def load_seek(self, pos: int) -> None:
412 # we can't incrementally load, so force ImageFile.parser to
413 # use our custom load method by defining this method.
414 pass
415
416
417# --------------------------------------------------------------------
418
419
420def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes, eps: int = 1) -> None:
421 """EPS Writer for the Python Imaging Library."""
422
423 # make sure image data is available
424 im.load()
425
426 # determine PostScript image mode
427 if im.mode == "L":
428 operator = (8, 1, b"image")
429 elif im.mode == "RGB":
430 operator = (8, 3, b"false 3 colorimage")
431 elif im.mode == "CMYK":
432 operator = (8, 4, b"false 4 colorimage")
433 else:
434 msg = "image mode is not supported"
435 raise ValueError(msg)
436
437 if eps:
438 # write EPS header
439 fp.write(b"%!PS-Adobe-3.0 EPSF-3.0\n")
440 fp.write(b"%%Creator: PIL 0.1 EpsEncode\n")
441 # fp.write("%%CreationDate: %s"...)
442 fp.write(b"%%%%BoundingBox: 0 0 %d %d\n" % im.size)
443 fp.write(b"%%Pages: 1\n")
444 fp.write(b"%%EndComments\n")
445 fp.write(b"%%Page: 1 1\n")
446 fp.write(b"%%ImageData: %d %d " % im.size)
447 fp.write(b'%d %d 0 1 1 "%s"\n' % operator)
448
449 # image header
450 fp.write(b"gsave\n")
451 fp.write(b"10 dict begin\n")
452 fp.write(b"/buf %d string def\n" % (im.size[0] * operator[1]))
453 fp.write(b"%d %d scale\n" % im.size)
454 fp.write(b"%d %d 8\n" % im.size) # <= bits
455 fp.write(b"[%d 0 0 -%d 0 %d]\n" % (im.size[0], im.size[1], im.size[1]))
456 fp.write(b"{ currentfile buf readhexstring pop } bind\n")
457 fp.write(operator[2] + b"\n")
458 if hasattr(fp, "flush"):
459 fp.flush()
460
461 ImageFile._save(im, fp, [("eps", (0, 0) + im.size, 0, None)])
462
463 fp.write(b"\n%%%%EndBinary\n")
464 fp.write(b"grestore end\n")
465 if hasattr(fp, "flush"):
466 fp.flush()
467
468
469# --------------------------------------------------------------------
470
471
472Image.register_open(EpsImageFile.format, EpsImageFile, _accept)
473
474Image.register_save(EpsImageFile.format, _save)
475
476Image.register_extensions(EpsImageFile.format, [".ps", ".eps"])
477
478Image.register_mime(EpsImageFile.format, "application/postscript")