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