QGIS/tests/src/python/qgis_local_server.py
2017-03-05 10:21:24 +01:00

836 lines
29 KiB
Python

# -*- coding: utf-8 -*-
"""Convenience interface to a locally spawned QGIS Server, e.g. for unit tests
.. note:: This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
"""
__author__ = 'Larry Shaffer'
__date__ = '2014/02/11'
__copyright__ = 'Copyright 2014, The QGIS Project'
# This will get replaced with a git SHA1 when you do a git archive
__revision__ = '$Format:%H$'
import sys
import os
import shutil
import platform
import subprocess
import time
import urllib.request
import urllib.parse
import urllib.error
import urllib.request
import urllib.error
import urllib.parse
import tempfile
from utilities import (
unitTestDataPath,
getExecutablePath,
openInBrowserTab,
getTempfilePath
)
# allow import error to be raised if qgis is not on sys.path
try:
# noinspection PyUnresolvedReferences
from qgis.core import QgsRectangle, QgsCoordinateReferenceSystem
except ImportError as e:
raise ImportError(str(e) + '\n\nPlace path to pyqgis modules on sys.path,'
' or assign to PYTHONPATH')
FCGIBIN = None
MAPSERV = None
SERVRUN = False
class ServerProcessError(Exception):
def __init__(self, title, msg, err=''):
msg += '\n' + ('\n' + str(err).strip() + '\n' if err else '')
self.msg = """
#----------------------------------------------------------------#
{0}
{1}
#----------------------------------------------------------------#
""".format(title, msg)
def __str__(self):
return self.msg
class ServerProcess(object):
def __init__(self):
self._startenv = None
self._startcmd = []
self._stopcmd = []
self._restartcmd = []
self._statuscmd = []
self._process = None
""":type : subprocess.Popen"""
self._win = self._mac = self._linux = self._unix = False
self._dist = ()
self._resolve_platform()
# noinspection PyMethodMayBeStatic
def _run(self, cmd, env=None):
# print repr(cmd)
p = subprocess.Popen(cmd,
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
env=env,
close_fds=True)
err = p.communicate()[1]
if err:
if p:
p.kill()
# noinspection PyUnusedLocal
p = None
cmd_s = repr(cmd).strip() + ('\n' + 'ENV: ' + repr(env).strip() +
'\n' if env is not None else '')
raise ServerProcessError('Server process command failed',
cmd_s, err)
return p
def start(self):
if self.running():
return
self._process = self._run(self._startcmd, env=self._startenv)
def stop(self):
if not self.running():
return False
if self._stopcmd:
self._run(self._stopcmd)
else:
self._process.terminate()
self._process = None
return True
def restart(self):
if self._restartcmd:
self._run(self._restartcmd)
else:
self.stop()
self.start()
def running(self):
running = False
if self._statuscmd:
try:
subprocess.check_call(self._statuscmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
running = True
except subprocess.CalledProcessError:
running = False
elif self._process:
running = self._process.poll() is None
return running
def set_startenv(self, env):
self._startenv = env
def set_startcmd(self, cmd):
self._startcmd = cmd
def set_stopcmd(self, cmd):
self._stopcmd = cmd
def set_restartcmd(self, cmd):
self._restartcmd = cmd
def set_statuscmd(self, cmd):
self._statuscmd = cmd
def process(self):
return self._process
def pid(self):
pid = 0
if self._process:
pid = self._process.pid
return pid
def _resolve_platform(self):
s = platform.system().lower()
self._linux = s.startswith('lin')
self._mac = s.startswith('dar')
self._unix = self._linux or self._mac
self._win = s.startswith('win')
self._dist = platform.dist()
class WebServerProcess(ServerProcess):
def __init__(self, kind, exe, conf_dir, temp_dir):
ServerProcess.__init__(self)
sufx = 'unix' if self._unix else 'win'
if kind == 'lighttpd':
conf = os.path.join(conf_dir, 'lighttpd', 'config',
'lighttpd_{0}.conf'.format(sufx))
self.set_startenv({'QGIS_SERVER_TEMP_DIR': temp_dir})
init_scr_dir = os.path.join(conf_dir, 'lighttpd', 'scripts')
if self._mac:
init_scr = os.path.join(init_scr_dir, 'lighttpd_mac.sh')
self.set_startcmd([init_scr, 'start', exe, conf, temp_dir])
self.set_stopcmd([init_scr, 'stop'])
self.set_restartcmd([init_scr, 'restart', exe, conf, temp_dir])
self.set_statuscmd([init_scr, 'status'])
elif self._linux:
dist = self._dist[0].lower()
if dist == 'debian' or dist == 'ubuntu':
init_scr = os.path.join(init_scr_dir, 'lighttpd_debian.sh')
self.set_startcmd([
init_scr, 'start', exe, temp_dir, conf])
self.set_stopcmd([init_scr, 'stop', exe, temp_dir])
self.set_restartcmd([
init_scr, 'restart', exe, temp_dir, conf])
self.set_statuscmd([init_scr, 'status', exe, temp_dir])
elif dist == 'fedora' or dist == 'rhel': # are these correct?
pass
else: # win
pass
class FcgiServerProcess(ServerProcess):
def __init__(self, kind, exe, fcgi_bin, conf_dir, temp_dir):
ServerProcess.__init__(self)
if kind == 'spawn-fcgi':
if self._unix:
fcgi_sock = os.path.join(temp_dir, 'var', 'run',
'qgs_mapserv.sock')
init_scr_dir = os.path.join(conf_dir, 'fcgi', 'scripts')
self.set_startenv({
'QGIS_LOG_FILE':
os.path.join(temp_dir, 'log', 'qgis_server.log')})
if self._mac:
init_scr = os.path.join(init_scr_dir, 'spawn_fcgi_mac.sh')
self.set_startcmd([init_scr, 'start', exe, fcgi_sock,
temp_dir + fcgi_bin, temp_dir])
self.set_stopcmd([init_scr, 'stop'])
self.set_restartcmd([init_scr, 'restart', exe, fcgi_sock,
temp_dir + fcgi_bin, temp_dir])
self.set_statuscmd([init_scr, 'status'])
elif self._linux:
dist = self._dist[0].lower()
if dist == 'debian' or dist == 'ubuntu':
init_scr = os.path.join(init_scr_dir,
'spawn_fcgi_debian.sh')
self.set_startcmd([
init_scr, 'start', exe, fcgi_sock,
temp_dir + fcgi_bin, temp_dir])
self.set_stopcmd([
init_scr, 'stop', exe, fcgi_sock,
temp_dir + fcgi_bin, temp_dir])
self.set_restartcmd([
init_scr, 'restart', exe, fcgi_sock,
temp_dir + fcgi_bin, temp_dir])
self.set_statuscmd([
init_scr, 'status', exe, fcgi_sock,
temp_dir + fcgi_bin, temp_dir])
elif dist == 'fedora' or dist == 'rhel':
pass
else: # win
pass
# noinspection PyPep8Naming,PyShadowingNames
class QgisLocalServer(object):
def __init__(self, fcgi_bin):
msg = 'FCGI binary not found at:\n{0}'.format(fcgi_bin)
assert os.path.exists(fcgi_bin), msg
msg = "FCGI binary not 'qgis_mapserv.fcgi':"
assert fcgi_bin.endswith('qgis_mapserv.fcgi'), msg
# hardcoded url, makes all this automated
self._ip = '127.0.0.1'
self._port = '8448'
self._web_url = 'http://{0}:{1}'.format(self._ip, self._port)
self._fcgibin_path = '/cgi-bin/qgis_mapserv.fcgi'
self._fcgi_url = '{0}{1}'.format(self._web_url, self._fcgibin_path)
self._conf_dir = unitTestDataPath('qgis_local_server')
self._fcgiserv_process = self._webserv_process = None
self._fcgiserv_bin = fcgi_bin
self._fcgiserv_path = self._webserv_path = ''
self._fcgiserv_kind = self._webserv_kind = ''
self._temp_dir = ''
self._web_dir = ''
servers = [
('spawn-fcgi', 'lighttpd')
# ('fcgiwrap', 'nginx'),
# ('uwsgi', 'nginx'),
]
chkd = ''
for fcgi, web in servers:
fcgi_path = getExecutablePath(fcgi)
web_path = getExecutablePath(web)
if fcgi_path and web_path:
self._fcgiserv_path = fcgi_path
self._webserv_path = web_path
self._fcgiserv_kind = fcgi
self._webserv_kind = web
break
else:
chkd += "Find '{0}': {1}\n".format(fcgi, fcgi_path)
chkd += "Find '{0}': {1}\n\n".format(web, web_path)
if not (self._fcgiserv_path and self._webserv_path):
raise ServerProcessError(
'Could not locate server binaries',
chkd,
'Make sure one of the sets of servers is available on PATH'
)
self._temp_dir = tempfile.mkdtemp()
self._setup_temp_dir()
# initialize the servers
self._fcgiserv_process = FcgiServerProcess(
self._fcgiserv_kind, self._fcgiserv_path,
self._fcgibin_path, self._conf_dir, self._temp_dir)
self._webserv_process = WebServerProcess(
self._webserv_kind, self._webserv_path,
self._conf_dir, self._temp_dir)
# stop any leftover processes, if possible
self.stop_processes()
def startup(self, chkcapa=False):
if not os.path.exists(self._temp_dir):
self._setup_temp_dir()
self.start_processes()
if chkcapa:
self.check_server_capabilities()
def shutdown(self):
self.stop_processes()
self.remove_temp_dir()
def start_processes(self):
self._fcgiserv_process.start()
self._webserv_process.start()
def stop_processes(self):
self._fcgiserv_process.stop()
self._webserv_process.stop()
def restart_processes(self):
self._fcgiserv_process.restart()
self._webserv_process.restart()
def fcgi_server_process(self):
return self._fcgiserv_process
def web_server_process(self):
return self._webserv_process
def processes_running(self):
return (self._fcgiserv_process.running() and
self._webserv_process.running())
def config_dir(self):
return self._conf_dir
def web_dir(self):
return self._web_dir
def open_web_dir(self):
self._open_fs_item(self._web_dir)
def web_dir_install(self, items, src_dir=''):
msg = 'Items parameter should be passed in as a list'
assert isinstance(items, list), msg
for item in items:
if item.startswith('.') or item.endswith('~'):
continue
path = item
if src_dir:
path = os.path.join(src_dir, item)
try:
if os.path.isfile(path):
shutil.copy2(path, self._web_dir)
elif os.path.isdir(path):
shutil.copytree(path, self._web_dir)
except Exception as err:
raise ServerProcessError('Failed to copy to web directory:',
item,
str(err))
def clear_web_dir(self):
for f in os.listdir(self._web_dir):
path = os.path.join(self._web_dir, f)
try:
if os.path.isfile(path):
os.unlink(path)
else:
shutil.rmtree(path)
except Exception as err:
raise ServerProcessError('Failed to clear web directory', err)
def temp_dir(self):
return self._temp_dir
def open_temp_dir(self):
self._open_fs_item(self._temp_dir)
def remove_temp_dir(self):
if os.path.exists(self._temp_dir):
shutil.rmtree(self._temp_dir)
def ip(self):
return self._ip
def port(self):
return self._port
def web_url(self):
return self._web_url
def fcgi_url(self):
return self._fcgi_url
def check_server_capabilities(self):
params = {
'SERVICE': 'WMS',
'VERSION': '1.3.0',
'REQUEST': 'GetCapabilities'
}
if not self.get_capabilities(params, False)[0]:
self.shutdown()
raise ServerProcessError(
'Local QGIS Server shutdown',
'Test QGIS Server is not accessible at:\n' + self._fcgi_url,
'Error: failed to retrieve server capabilities'
)
def get_capabilities(self, params, browser=False):
assert self.processes_running(), 'Server processes not running'
params = self._params_to_upper(params)
if (('REQUEST' in params and params['REQUEST'] != 'GetCapabilities') or
'REQUEST' not in params):
params['REQUEST'] = 'GetCapabilities'
url = self._fcgi_url + '?' + self.process_params(params)
res = urllib.request.urlopen(url)
xml = res.read().decode('utf-8')
if browser:
tmp_name = getTempfilePath('html')
with open(tmp_name, 'wt') as temp_html:
temp_html.write(xml)
url = tmp_name
openInBrowserTab(url)
return False, ''
success = ('error reading the project file' in xml or
'WMS_Capabilities' in xml)
return success, xml
def get_map(self, params, browser=False):
assert self.processes_running(), 'Server processes not running'
msg = ('Map request parameters should be passed in as a dict '
'(key case can be mixed)')
assert isinstance(params, dict), msg
params = self._params_to_upper(params)
try:
proj = params['MAP']
except KeyError as err:
raise KeyError(str(err) + '\nMAP not found in parameters dict')
if not os.path.exists(proj):
msg = '{0}'.format(proj)
w_proj = os.path.join(self._web_dir, proj)
if os.path.exists(w_proj):
params['MAP'] = w_proj
else:
msg += '\n or\n' + w_proj
raise ServerProcessError(
'GetMap Request Error',
'Project not found at:\n{0}'.format(msg)
)
if (('REQUEST' in params and params['REQUEST'] != 'GetMap') or
'REQUEST' not in params):
params['REQUEST'] = 'GetMap'
url = self._fcgi_url + '?' + self.process_params(params)
if browser:
openInBrowserTab(url)
return False, ''
# try until qgis_mapserv.fcgi process is available (for 20 seconds)
# on some platforms the fcgi_server_process is a daemon handling the
# launch of the fcgi-spawner, which may be running quickly, but the
# qgis_mapserv.fcgi spawned process is not yet accepting connections
resp = None
tmp_png = None
# noinspection PyUnusedLocal
filepath = ''
# noinspection PyUnusedLocal
success = False
start_time = time.time()
while time.time() - start_time < 20:
resp = None
try:
tmp_png = urllib.request.urlopen(url)
except urllib.error.HTTPError as resp:
if resp.code == 503 or resp.code == 500:
time.sleep(1)
else:
raise ServerProcessError(
'Web/FCGI Process Request HTTPError',
'Could not connect to process: ' + str(resp.code),
resp.message
)
except urllib.error.URLError as resp:
raise ServerProcessError(
'Web/FCGI Process Request URLError',
'Could not connect to process',
resp.reason
)
else:
delta = time.time() - start_time
print(('Seconds elapsed for server GetMap: ' + str(delta)))
break
if resp is not None:
raise ServerProcessError(
'Web/FCGI Process Request Error',
'Could not connect to process: ' + str(resp.code)
)
if (tmp_png is not None and
tmp_png.info().getmaintype() == 'image' and
tmp_png.info().getheader('Content-Type') == 'image/png'):
filepath = getTempfilePath('png')
with open(filepath, 'wb') as temp_image:
temp_image.write(tmp_png.read())
success = True
else:
raise ServerProcessError(
'FCGI Process Request Error',
'No valid PNG output'
)
return success, filepath, url
def process_params(self, params):
# set all keys to uppercase
params = self._params_to_upper(params)
# convert all convenience objects to compatible strings
self._convert_instances(params)
# encode params
return urllib.parse.urlencode(params, True)
@staticmethod
def _params_to_upper(params):
return dict((k.upper(), v) for k, v in list(params.items()))
@staticmethod
def _convert_instances(params):
if not params:
return
if ('LAYERS' in params and
isinstance(params['LAYERS'], list)):
params['LAYERS'] = ','.join(params['LAYERS'])
if ('BBOX' in params and
isinstance(params['BBOX'], QgsRectangle)):
# not needed for QGIS's 1.3.0 server?
# # invert x, y of rect and set precision to 16
# rect = self.params['BBOX']
# bbox = ','.join(map(lambda x: '{0:0.16f}'.format(x),
# [rect.yMinimum(), rect.xMinimum(),
# rect.yMaximum(), rect.xMaximum()]))
params['BBOX'] = \
params['BBOX'].toString(1).replace(' : ', ',')
if ('CRS' in params and
isinstance(params['CRS'], QgsCoordinateReferenceSystem)):
params['CRS'] = params['CRS'].authid()
def _setup_temp_dir(self):
self._web_dir = os.path.join(self._temp_dir, 'www', 'htdocs')
cgi_bin = os.path.join(self._temp_dir, 'cgi-bin')
os.makedirs(cgi_bin, mode=0o755)
os.makedirs(os.path.join(self._temp_dir, 'log'), mode=0o755)
os.makedirs(os.path.join(self._temp_dir, 'var', 'run'), mode=0o755)
os.makedirs(self._web_dir, mode=0o755)
# symlink or copy in components
shutil.copy2(os.path.join(self._conf_dir, 'index.html'), self._web_dir)
if not platform.system().lower().startswith('win'):
# symlink allow valid runningFromBuildDir results
os.symlink(self._fcgiserv_bin,
os.path.join(cgi_bin,
os.path.basename(self._fcgiserv_bin)))
else:
# TODO: what to do here for Win runningFromBuildDir?
# copy qgisbuildpath.txt from output/bin directory, too?
shutil.copy2(self._fcgiserv_bin, cgi_bin)
@staticmethod
def _exe_path(exe):
exe_exts = []
if (platform.system().lower().startswith('win') and
"PATHEXT" in os.environ):
exe_exts = os.environ["PATHEXT"].split(os.pathsep)
for path in os.environ["PATH"].split(os.pathsep):
exe_path = os.path.join(path, exe)
if os.path.exists(exe_path):
return exe_path
for ext in exe_exts:
if os.path.exists(exe_path + ext):
return exe_path
return ''
@staticmethod
def _open_fs_item(item):
if not os.path.exists(item):
return
s = platform.system().lower()
if s.startswith('dar'):
subprocess.call(['open', item])
elif s.startswith('lin'):
# xdg-open "$1" &> /dev/null &
subprocess.call(['xdg-open', item])
elif s.startswith('win'):
subprocess.call([item])
else: # ?
pass
# noinspection PyPep8Naming
def getLocalServer():
""" Start a local test server controller that independently manages Web and
FCGI-spawn processes.
Input
NIL
Output
handle to QgsLocalServer, that's been tested to be valid, then shutdown
If MAPSERV is already running the handle to it will be returned.
Before unit test class add:
MAPSERV = getLocalServer()
IMPORTANT: When using MAPSERV in a test class, ensure to set these:
@classmethod
def setUpClass(cls):
MAPSERV.startup()
This ensures the subprocesses are started and the temp directory is created.
@classmethod
def tearDownClass(cls):
MAPSERV.shutdown()
# or, when testing, instead of shutdown...
# MAPSERV.stop_processes()
# MAPSERV.open_temp_dir()
This ensures the subprocesses are stopped and the temp directory is removed.
If this is not used, the server processes may continue to run after tests.
If you need to restart the qgis_mapserv.fcgi spawning process to show
changes to project settings, consider adding:
def setUp(self):
'''Run before each test.'''
# web server stays up across all tests
MAPSERV.fcgi_server_process().start()
def tearDown(self):
'''Run after each test.'''
# web server stays up across all tests
MAPSERV.fcgi_server_process().stop()
:rtype: QgisLocalServer
"""
global SERVRUN # pylint: disable=W0603
global MAPSERV # pylint: disable=W0603
if SERVRUN:
msg = 'Local server has already failed to launch or run'
assert MAPSERV is not None, msg
else:
SERVRUN = True
global FCGIBIN # pylint: disable=W0603
if FCGIBIN is None:
msg = 'Could not find QGIS_PREFIX_PATH (build directory) in environ'
assert 'QGIS_PREFIX_PATH' in os.environ, msg
fcgi_path = os.path.join(os.environ['QGIS_PREFIX_PATH'], 'bin',
'qgis_mapserv.fcgi')
msg = 'Could not locate qgis_mapserv.fcgi in build/bin directory'
assert os.path.exists(fcgi_path), msg
FCGIBIN = fcgi_path
if MAPSERV is None:
# On QgisLocalServer init, Web and FCGI-spawn executables are located,
# configurations to start/stop/restart those processes (relative to
# host platform) are loaded into controller, a temporary web
# directory is created, and the FCGI binary copied to its cgi-bin.
srv = QgisLocalServer(FCGIBIN)
# noinspection PyStatementEffect
""":type : QgisLocalServer"""
try:
msg = 'Temp web directory could not be created'
assert os.path.exists(srv.temp_dir()), msg
# install test project components to temporary web directory
test_proj_dir = os.path.join(srv.config_dir(), 'test-project')
srv.web_dir_install(os.listdir(test_proj_dir), test_proj_dir)
# verify they were copied
msg = 'Test project could not be copied to temp web directory'
res = os.path.exists(os.path.join(srv.web_dir(), 'test-server.qgs'))
assert res, msg
# verify subprocess' status can be checked
msg = 'Server processes status could not be checked'
assert not srv.processes_running(), msg
# startup server subprocesses, and check capabilities
srv.startup()
msg = 'Server processes could not be started'
assert srv.processes_running(), msg
# verify web server (try for 30 seconds)
start_time = time.time()
res = None
while time.time() - start_time < 30:
time.sleep(1)
try:
res = urllib.request.urlopen(srv.web_url())
if res.getcode() == 200:
break
except urllib.error.URLError:
pass
msg = 'Web server basic access to root index.html failed'
# print repr(res)
assert (res is not None and
res.getcode() == 200 and
'Web Server Working' in res.read().decode('utf-8')), msg
# verify basic wms service
params = {
'SERVICE': 'WMS',
'VERSION': '1.3.0',
'REQUEST': 'GetCapabilities'
}
msg = '\nFCGI server failed to return capabilities'
assert srv.get_capabilities(params, False)[0], msg
params = {
'SERVICE': 'WMS',
'VERSION': '1.3.0',
'REQUEST': 'GetCapabilities',
'MAP': 'test-server.qgs'
}
msg = '\nFCGI server failed to return capabilities for project'
assert srv.get_capabilities(params, False)[0], msg
# verify the subprocesses can be stopped and controller shutdown
srv.shutdown() # should remove temp directory (and test project)
msg = 'Server processes could not be stopped'
assert not srv.processes_running(), msg
msg = 'Temp web directory could not be removed'
assert not os.path.exists(srv.temp_dir()), msg
MAPSERV = srv
except AssertionError as err:
srv.shutdown()
raise AssertionError(err)
return MAPSERV
if __name__ == '__main__':
# NOTE: see test_qgis_local_server.py for CTest suite
import argparse
parser = argparse.ArgumentParser()
parser.add_argument(
'fcgi', metavar='fcgi-bin-path',
help='Path to qgis_mapserv.fcgi'
)
args = parser.parse_args()
fcgi = os.path.realpath(args.fcgi)
if not os.path.isabs(fcgi) or not os.path.exists(fcgi):
print('qgis_mapserv.fcgi not resolved to existing absolute path.')
sys.exit(1)
local_srv = QgisLocalServer(fcgi)
proj_dir = os.path.join(local_srv.config_dir(), 'test-project')
local_srv.web_dir_install(os.listdir(proj_dir), proj_dir)
# local_srv.open_temp_dir()
# sys.exit()
# creating crs needs app instance to access /resources/srs.db
# crs = QgsCoordinateReferenceSystem()
# default for labeling test data sources: WGS 84 / UTM zone 13N
# crs.createFromSrid(32613)
req_params = {
'SERVICE': 'WMS',
'VERSION': '1.3.0',
'REQUEST': 'GetMap',
# 'MAP': os.path.join(local_srv.web_dir(), 'test-server.qgs'),
'MAP': 'test-server.qgs',
# layer stacking order for rendering: bottom,to,top
'LAYERS': ['background', 'aoi'], # or 'background,aoi'
'STYLES': ',',
# 'CRS': QgsCoordinateReferenceSystem obj
'CRS': 'EPSG:32613',
# 'BBOX': QgsRectangle(606510, 4823130, 612510, 4827130)
'BBOX': '606510,4823130,612510,4827130',
'FORMAT': 'image/png', # or: 'image/png; mode=8bit'
'WIDTH': '600',
'HEIGHT': '400',
'DPI': '72',
'MAP_RESOLUTION': '72',
'FORMAT_OPTIONS': 'dpi:72',
'TRANSPARENT': 'FALSE',
'IgnoreGetMapUrl': '1'
}
# local_srv.web_server_process().start()
# openInBrowserTab('http://127.0.0.1:8448')
# local_srv.web_server_process().stop()
# sys.exit()
local_srv.startup(False)
openInBrowserTab('http://127.0.0.1:8448')
try:
local_srv.check_server_capabilities()
# open resultant png with system
result, png, url = local_srv.get_map(req_params)
finally:
local_srv.shutdown()
if result:
# print png
openInBrowserTab('file://' + png)
else:
raise ServerProcessError('GetMap Test', 'Failed to generate PNG')