diff options
-rw-r--r-- | README.rst | 10 | ||||
-rw-r--r-- | docs/conf.py | 6 | ||||
-rw-r--r-- | docs/index.rst | 1 | ||||
-rw-r--r-- | docs/settings.rst | 69 | ||||
-rw-r--r-- | docs/usage.rst | 61 | ||||
-rw-r--r-- | setup.py | 2 | ||||
-rw-r--r-- | wkhtmltopdf/_testproject/__init__.py (renamed from testproject/__init__.py) | 0 | ||||
-rw-r--r-- | wkhtmltopdf/_testproject/manage.py (renamed from testproject/manage.py) | 0 | ||||
-rw-r--r-- | wkhtmltopdf/_testproject/requirements.txt (renamed from testproject/requirements.txt) | 0 | ||||
-rw-r--r-- | wkhtmltopdf/_testproject/settings.py (renamed from testproject/settings.py) | 0 | ||||
-rw-r--r-- | wkhtmltopdf/_testproject/templates/footer.html | 2 | ||||
-rw-r--r-- | wkhtmltopdf/_testproject/templates/sample.html (renamed from testproject/templates/sample.html) | 0 | ||||
-rw-r--r-- | wkhtmltopdf/_testproject/urls.py (renamed from testproject/urls.py) | 0 | ||||
-rw-r--r-- | wkhtmltopdf/subprocess.py | 44 | ||||
-rw-r--r-- | wkhtmltopdf/tests.py | 299 | ||||
-rw-r--r-- | wkhtmltopdf/utils.py | 173 | ||||
-rw-r--r-- | wkhtmltopdf/views.py | 311 |
17 files changed, 870 insertions, 108 deletions
@@ -34,3 +34,13 @@ specific execuatable: e.g.: in ``settings.py``:: WKHTMLTOPDF_CMD = '/path/to/my/wkhtmltopdf' + +You may also set +``WKHTMLTOPDF_CMD_OPTIONS`` +in ``settings.py`` to a dictionary of default command-line options. + +The default is:: + + WKHTMLTOPDF_CMD_OPTIONS = { + 'quiet': True, + } diff --git a/docs/conf.py b/docs/conf.py index a8227a3..21a748a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -13,7 +13,7 @@ import sys, os -from wkhtmltopdf import get_version +import wkhtmltopdf # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -50,9 +50,9 @@ copyright = u'2012, Incuna Ltd' # built documents. # # The short X.Y version. -version = get_version() +version = wkhtmltopdf.__version__ # The full version, including alpha/beta/rc tags. -release = get_version() +release = wkhtmltopdf.__version__ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/index.rst b/docs/index.rst index 560d8af..817db3f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -56,3 +56,4 @@ Contents installation usage + settings diff --git a/docs/settings.rst b/docs/settings.rst new file mode 100644 index 0000000..7b5116a --- /dev/null +++ b/docs/settings.rst @@ -0,0 +1,69 @@ +Settings +======== + +Available settings +------------------ + +Here's a full list of available settings, +in alphabetical order, +and their default values. + +WKHTMLTOPDF_CMD +~~~~~~~~~~~~~~~ + +Default: ``'wkhtmltopdf'`` + +The name of the ``wkhtmltopdf`` binary. + +If there are no path components, +this app will look for the binary using the default OS paths. + +WKHTMLTOPDF_CMD_OPTIONS +~~~~~~~~~~~~~~~~~~~~~~~ + +Default: ``{'quiet': True}`` + +A dictionary of command-line arguments to pass to the ``wkhtmltopdf`` +binary. +Keys are the name of the flag and values are arguments for the flag. + +To pass a simple flag, +for example: +``wkhtmltopdf --disable-javascript``: + +.. code-block:: python + + WKHTMLTOPDF_CMD_OPTIONS = {'disable-javascript': True} + +To pass a flag with an argument, +for example: +``wkhtmltopdf --title 'TPS Report'``: + +.. code-block:: python + + WKHTMLTOPDF_CMD_OPTIONS = {'title': 'TPS Report'} + + +WKHTMLTOPDF_DEBUG +~~~~~~~~~~~~~~~~~ + +Default: same as :py:data:`settings.DEBUG` + +A boolean that turns on/off debug mode. + +WKHTMLTOPDF_ENV +~~~~~~~~~~~~~~~ + +Default: ``None`` + +An optional dictionary of environment variables to override, +when running the ``wkhtmltopdf`` binary. +Keys are the name of the environment variable. + +A common use of this is to set the ``DISPLAY`` environment variable +to another X server, +when using ``wkhtmltopdf --use-xserver``: + +.. code-block:: python + + WKHTMLTOPDF_ENV = {'DISPLAY': ':2'} diff --git a/docs/usage.rst b/docs/usage.rst index 8f8d797..7d031d1 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -1,19 +1,48 @@ Usage ===== -The ``PDFTemplateView`` takes a selection of variables, most of which get passed -to the underlying wkhtmltopdf binary. The exceptions are: +The :py:class:`PDFTemplateView` is a Django class-based view. +By default, it uses :py:class:`PDFTemplateResponse` to render an HTML +template to PDF. +It accepts the following class attributes: -* filename -* footer_template -* header_template -* response -* template_name +:py:attr:`template_name` + The full name of a template to use as the body of the PDF. -wkhtmltopdf options can be found by running ``wkhtmltopdf --help``. Unfortunately -they don't provide hosted documentation. Any variables you pass to django-wkhtmltopdf -need to be underscored. They will be converted to hyphenated variables for use with -the wkhtmltopdf binary. +:py:attr:`header_template` + Optional. + The full name of a template to use as the header on each page. + +:py:attr:`footer_template` + Optional. + The full name of a template to use as the footer on each page. + +:py:attr:`filename` + The filename to use when responding with an attachment containing + the PDF. + Default is ``'rendered_pdf.pdf'``. + + If ``None``, the view returns the PDF output inline, + not as an attachment. + +:py:attr:`response_class` + The response class to be returned by :py:meth:`render_to_response` + method. + Default is :py:class:`PDFTemplateResponse`. + +:py:attr:`html_response_class` + The response class to be returned by :py:meth:`render_to_response` + method, when rendering as HTML. + See note below. + Default is :py:class:`TemplateResponse`. + +:py:attr:`cmd_options` + The dictionary of command-line arguments passed to the underlying + ``wkhtmltopdf`` binary. + Default is ``{}``. + + wkhtmltopdf options can be found by running ``wkhtmltopdf --help``. + Unfortunately they don't provide hosted documentation. .. note:: @@ -24,7 +53,7 @@ the wkhtmltopdf binary. Simple Example -------------- -Point a URL at PDFTemplateView: +Point a URL at :py:class:`PDFTemplateView`: .. code-block:: python @@ -43,7 +72,8 @@ Point a URL at PDFTemplateView: Advanced Example ---------------- -Point a URL (as above) at your own view that subclasses ``PDFTemplateView`` and +Point a URL (as above) at your own view that subclasses +:py:class:`PDFTemplateView` and override the sections you need to. .. code-block:: python @@ -53,6 +83,7 @@ and override the sections you need to. class MyPDF(PDFTemplateView): filename = 'my_pdf.pdf' - margin_top = 3 template_name = 'my_template.html' - + cmd_options = { + 'margin-top': 3, + } @@ -13,5 +13,7 @@ setup( author=wkhtmltopdf.__author__, author_email='admin@incuna.com', url='http://incuna.com/', + install_requires=['Django>=1.3'], + zip_safe=False, ) diff --git a/testproject/__init__.py b/wkhtmltopdf/_testproject/__init__.py index e69de29..e69de29 100644 --- a/testproject/__init__.py +++ b/wkhtmltopdf/_testproject/__init__.py diff --git a/testproject/manage.py b/wkhtmltopdf/_testproject/manage.py index 3e4eedc..3e4eedc 100644 --- a/testproject/manage.py +++ b/wkhtmltopdf/_testproject/manage.py diff --git a/testproject/requirements.txt b/wkhtmltopdf/_testproject/requirements.txt index 74c18fb..74c18fb 100644 --- a/testproject/requirements.txt +++ b/wkhtmltopdf/_testproject/requirements.txt diff --git a/testproject/settings.py b/wkhtmltopdf/_testproject/settings.py index 0454766..0454766 100644 --- a/testproject/settings.py +++ b/wkhtmltopdf/_testproject/settings.py diff --git a/wkhtmltopdf/_testproject/templates/footer.html b/wkhtmltopdf/_testproject/templates/footer.html new file mode 100644 index 0000000..3ae09cb --- /dev/null +++ b/wkhtmltopdf/_testproject/templates/footer.html @@ -0,0 +1,2 @@ +MEDIA_URL = {{ MEDIA_URL }} +STATIC_URL = {{ STATIC_URL }} diff --git a/testproject/templates/sample.html b/wkhtmltopdf/_testproject/templates/sample.html index 3dfbdbb..3dfbdbb 100644 --- a/testproject/templates/sample.html +++ b/wkhtmltopdf/_testproject/templates/sample.html diff --git a/testproject/urls.py b/wkhtmltopdf/_testproject/urls.py index 039e61a..039e61a 100644 --- a/testproject/urls.py +++ b/wkhtmltopdf/_testproject/urls.py diff --git a/wkhtmltopdf/subprocess.py b/wkhtmltopdf/subprocess.py new file mode 100644 index 0000000..d0abacd --- /dev/null +++ b/wkhtmltopdf/subprocess.py @@ -0,0 +1,44 @@ +from __future__ import absolute_import + +from subprocess import * + + +# Provide Python 2.7's check_output() function. +try: + check_output +except NameError: + def check_output(*popenargs, **kwargs): + r"""Run command with arguments and return its output as a byte string. + + If the exit code was non-zero it raises a CalledProcessError. The + CalledProcessError object will have the return code in the returncode + attribute and output in the output attribute. + + The arguments are the same as for the Popen constructor. Example: + + >>> check_output(["ls", "-l", "/dev/null"]) + 'crw-rw-rw- 1 root root 1, 3 Oct 18 2007 /dev/null\n' + + The stdout argument is not allowed as it is used internally. + To capture standard error in the result, use stderr=STDOUT. + + >>> check_output(["/bin/sh", "-c", + ... "ls -l non_existent_file ; exit 0"], + ... stderr=STDOUT) + 'ls: non_existent_file: No such file or directory\n' + """ + if 'stdout' in kwargs: + raise ValueError('stdout argument not allowed, it will be overridden.') + process = Popen(stdout=PIPE, *popenargs, **kwargs) + output, unused_err = process.communicate() + retcode = process.poll() + if retcode: + cmd = kwargs.get("args") + if cmd is None: + cmd = popenargs[0] + error = CalledProcessError(retcode, cmd) + # Add the output attribute to CalledProcessError, which + # doesn't exist until Python 2.7. + error.output = output + raise error + return output diff --git a/wkhtmltopdf/tests.py b/wkhtmltopdf/tests.py index 5eb733f..3bdc481 100644 --- a/wkhtmltopdf/tests.py +++ b/wkhtmltopdf/tests.py @@ -1,13 +1,304 @@ +# -*- coding: utf-8 -*- + +from __future__ import absolute_import + +import os +import sys +import warnings + from django.test import TestCase +from django.test.client import RequestFactory + +from .subprocess import CalledProcessError +from .utils import (override_settings, + _options_to_args, template_to_temp_file, wkhtmltopdf) +from .views import (PDFResponse, PdfResponse, PDFTemplateResponse, + PDFTemplateView, PdfTemplateView) -from utils import template_to_temp_file class TestUtils(TestCase): + def setUp(self): + # Clear standard error + self._stderr = sys.stderr + sys.stderr = open(os.devnull, 'w') + + def tearDown(self): + sys.stderr = self._stderr + + def test_options_to_args(self): + self.assertEqual(_options_to_args(), []) + self.assertEqual(_options_to_args(heart=u'♥', verbose=True, + file_name='file-name'), + ['--file-name', 'file-name', + '--heart', u'♥', + '--verbose']) + + def test_wkhtmltopdf(self): + """Should run wkhtmltopdf to generate a PDF""" + title = 'A test template.' + temp_file = template_to_temp_file('sample.html', {'title': title}) + pdf_output = None + try: + # Standard call + pdf_output = wkhtmltopdf(pages=[temp_file]) + self.assertTrue(pdf_output.startswith('%PDF'), pdf_output) + + # Single page + pdf_output = wkhtmltopdf(pages=temp_file) + self.assertTrue(pdf_output.startswith('%PDF'), pdf_output) + + # Unicode + pdf_output = wkhtmltopdf(pages=[temp_file], title=u'♥') + self.assertTrue(pdf_output.startswith('%PDF'), pdf_output) + + # Invalid arguments + self.assertRaises(CalledProcessError, + wkhtmltopdf, pages=[]) + finally: + if os.path.exists(temp_file): + os.remove(temp_file) + def test_template_to_temp_file(self): """Should render a template to a temporary file.""" title = 'A test template.' temp_file = template_to_temp_file('sample.html', {'title': title}) - with open(temp_file, 'r') as f: - saved_content = f.read() - self.assertTrue(title in saved_content) + try: + with open(temp_file, 'r') as f: + saved_content = f.read() + self.assertTrue(title in saved_content) + finally: + if os.path.exists(temp_file): + os.remove(temp_file) + + +class TestViews(TestCase): + def test_pdf_response(self): + """Should generate the correct HttpResponse object and mimetype""" + # 404 + response = PDFResponse(content='', status=404) + self.assertEqual(response.status_code, 404) + self.assertEqual(response.content, '') + self.assertEqual(response['Content-Type'], 'application/pdf') + self.assertFalse(response.has_header('Content-Disposition')) + + content = '%PDF-1.4\n%%EOF' + # Without filename + response = PDFResponse(content=content) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, content) + self.assertEqual(response['Content-Type'], 'application/pdf') + self.assertFalse(response.has_header('Content-Disposition')) + + # With filename + response = PDFResponse(content=content, filename="nospace.pdf") + self.assertEqual(response['Content-Disposition'], + 'attachment; filename="nospace.pdf"') + response = PDFResponse(content=content, filename="one space.pdf") + self.assertEqual(response['Content-Disposition'], + 'attachment; filename="one space.pdf"') + response = PDFResponse(content=content, filename="4'5\".pdf") + self.assertEqual(response['Content-Disposition'], + 'attachment; filename="4\'5.pdf"') + response = PDFResponse(content=content, filename=u"♥.pdf") + self.assertEqual(response['Content-Disposition'], + 'attachment; filename="?.pdf"') + + # Content-Type + response = PDFResponse(content=content, + content_type='application/x-pdf') + self.assertEqual(response['Content-Type'], 'application/x-pdf') + response = PDFResponse(content=content, + mimetype='application/x-pdf') + self.assertEqual(response['Content-Type'], 'application/x-pdf') + + def test_pdf_template_response(self): + """Test PDFTemplateResponse.""" + from django.conf import settings + + with override_settings( + MEDIA_URL='/media/', + MEDIA_ROOT='/tmp/media', + STATIC_URL='/static/', + STATIC_ROOT='/tmp/static', + TEMPLATE_CONTEXT_PROCESSORS=[ + 'django.core.context_processors.media', + 'django.core.context_processors.static', + ], + TEMPLATE_LOADERS=['django.template.loaders.filesystem.Loader'], + TEMPLATE_DIRS=[os.path.join(os.path.dirname(__file__), + '_testproject', 'templates')], + WKHTMLTOPDF_DEBUG=False, + ): + # Setup sample.html + template = 'sample.html' + context = {'title': 'Heading'} + request = RequestFactory().get('/') + response = PDFTemplateResponse(request=request, + template=template, + context=context) + self.assertEqual(response._request, request) + self.assertEqual(response.template_name, template) + self.assertEqual(response.context_data, context) + self.assertEqual(response.filename, None) + self.assertEqual(response.header_template, None) + self.assertEqual(response.footer_template, None) + self.assertEqual(response.cmd_options, {}) + self.assertFalse(response.has_header('Content-Disposition')) + + # Render to temporary file + tempfile = response.render_to_temporary_file(template) + tempfile.seek(0) + html_content = tempfile.read() + self.assertTrue(html_content.startswith('<html>')) + self.assertTrue('<h1>{title}</h1>'.format(**context) + in html_content) + + pdf_content = response.rendered_content + self.assertTrue(pdf_content.startswith('%PDF-')) + self.assertTrue(pdf_content.endswith('%%EOF\n')) + + # Footer + filename = 'output.pdf' + footer_template = 'footer.html' + cmd_options = {'title': 'Test PDF'} + response = PDFTemplateResponse(request=request, + template=template, + context=context, + filename=filename, + footer_template=footer_template, + cmd_options=cmd_options) + self.assertEqual(response.filename, filename) + self.assertEqual(response.header_template, None) + self.assertEqual(response.footer_template, footer_template) + self.assertEqual(response.cmd_options, cmd_options) + self.assertTrue(response.has_header('Content-Disposition')) + + tempfile = response.render_to_temporary_file(footer_template) + tempfile.seek(0) + footer_content = tempfile.read() + + media_url = 'MEDIA_URL = file://{0}/'.format(settings.MEDIA_ROOT) + self.assertTrue( + media_url in footer_content, + "{0!r} not in {1!r}".format(media_url, footer_content) + ) + + static_url = 'STATIC_URL = file://{0}/'.format(settings.STATIC_ROOT) + self.assertTrue( + static_url in footer_content, + "{0!r} not in {1!r}".format(static_url, footer_content) + ) + + pdf_content = response.rendered_content + self.assertTrue('\0'.join('{title}'.format(**cmd_options)) + in pdf_content) + + # Override settings + response = PDFTemplateResponse(request=request, + template=template, + context=context, + filename=filename, + footer_template=footer_template, + cmd_options=cmd_options, + override_settings={ + 'STATIC_URL': 'file:///tmp/s/' + }) + tempfile = response.render_to_temporary_file(footer_template) + tempfile.seek(0) + footer_content = tempfile.read() + + static_url = 'STATIC_URL = {0}'.format('file:///tmp/s/') + self.assertTrue( + static_url in footer_content, + "{0!r} not in {1!r}".format(static_url, footer_content) + ) + self.assertEqual(settings.STATIC_URL, '/static/') + + def test_pdf_template_view(self): + """Test PDFTemplateView.""" + with override_settings( + MEDIA_URL='/media/', + STATIC_URL='/static/', + TEMPLATE_CONTEXT_PROCESSORS=[ + 'django.core.context_processors.media', + 'django.core.context_processors.static', + ], + TEMPLATE_LOADERS=['django.template.loaders.filesystem.Loader'], + TEMPLATE_DIRS=[os.path.join(os.path.dirname(__file__), + '_testproject', 'templates')], + WKHTMLTOPDF_DEBUG=False, + ): + # Setup sample.html + template = 'sample.html' + filename = 'output.pdf' + view = PDFTemplateView.as_view(filename=filename, + template_name=template, + footer_template='footer.html') + + # As PDF + request = RequestFactory().get('/') + response = view(request) + self.assertEqual(response.status_code, 200) + response.render() + self.assertEqual(response['Content-Disposition'], + 'attachment; filename="{0}"'.format(filename)) + self.assertTrue(response.content.startswith('%PDF-')) + self.assertTrue(response.content.endswith('%%EOF\n')) + + # As HTML + request = RequestFactory().get('/?as=html') + response = view(request) + self.assertEqual(response.status_code, 200) + response.render() + self.assertFalse(response.has_header('Content-Disposition')) + self.assertTrue(response.content.startswith('<html>')) + + # POST + request = RequestFactory().post('/') + response = view(request) + self.assertEqual(response.status_code, 405) + + def test_get_cmd_options(self): + # Default cmd_options + view = PDFTemplateView() + self.assertEqual(view.cmd_options, PDFTemplateView.cmd_options) + self.assertEqual(PDFTemplateView.cmd_options, {}) + + # Instantiate with new cmd_options + cmd_options = {'orientation': 'landscape'} + view = PDFTemplateView(cmd_options=cmd_options) + self.assertEqual(view.cmd_options, cmd_options) + self.assertEqual(PDFTemplateView.cmd_options, {}) + + # Update local instance of cmd_options + view = PDFTemplateView() + view.cmd_options.update(cmd_options) + self.assertEqual(view.cmd_options, cmd_options) + self.assertEqual(PDFTemplateView.cmd_options, {}) + def test_deprecated(self): + """Should warn when using deprecated views.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + PdfTemplateView() + self.assertEqual(len(w), 1) + self.assertEqual(w[0].category, PendingDeprecationWarning) + self.assertTrue( + 'PDFTemplateView' in str(w[0].message), + "'PDFTemplateView' not in {0!r}".format(w[0].message)) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + PdfResponse(None, None) + self.assertEqual(len(w), 1) + self.assertEqual(w[0].category, PendingDeprecationWarning) + self.assertTrue( + 'PDFResponse' in str(w[0].message), + "'PDFResponse' not in {0!r}".format(w[0].message)) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + PDFTemplateView().get_pdf_kwargs() + self.assertEqual(len(w), 1) + self.assertEqual(w[0].category, PendingDeprecationWarning) + self.assertTrue( + 'get_pdf_kwargs()' in str(w[0].message), + "'get_pdf_kwargs()' not in {0!r}".format(w[0].message)) diff --git a/wkhtmltopdf/utils.py b/wkhtmltopdf/utils.py index cf01f43..29e4ef6 100644 --- a/wkhtmltopdf/utils.py +++ b/wkhtmltopdf/utils.py @@ -1,19 +1,40 @@ +from __future__ import absolute_import + +from copy import copy +from itertools import chain from os import fdopen -from subprocess import Popen, PIPE, CalledProcessError +import os +import sys from tempfile import mkstemp +import urllib +import warnings from django.conf import settings from django.template import loader from django.utils.encoding import smart_str -WKHTMLTOPDF_CMD = getattr(settings, 'WKHTMLTOPDF_CMD', 'wkhtmltopdf') +from .subprocess import check_output + + +def _options_to_args(**options): + """Converts ``options`` into a string of command-line arguments.""" + flags = [] + for name in sorted(options): + value = options[name] + if value is None: + continue + flags.append('--' + name.replace('_', '-')) + if value is not True: + flags.append(unicode(value)) + return flags + def wkhtmltopdf(pages, output=None, **kwargs): """ Converts html to PDF using http://code.google.com/p/wkhtmltopdf/. pages: List of file paths or URLs of the html to be converted. - output: Optional output file path. + output: Optional output file path. If None, the output is returned. **kwargs: Passed to wkhtmltopdf via _extra_args() (See https://github.com/antialize/wkhtmltopdf/blob/master/README_WKHTMLTOPDF for acceptable args.) @@ -21,48 +42,146 @@ def wkhtmltopdf(pages, output=None, **kwargs): {'footer_html': 'http://example.com/foot.html'} becomes '--footer-html http://example.com/foot.html' - Where there is no value passed, use a blank string. e.g.: - {'disable_javascript': ''} + + Where there is no value passed, use True. e.g.: + {'disable_javascript': True} + becomes: + '--disable-javascript' + + To disable a default option, use None. e.g: + {'quiet': None'} becomes: - '--disable-javascript ' + '' example usage: - wkhtmltopdf(html_path="~/example.html", + wkhtmltopdf(pages=['/tmp/example.html'], dpi=300, - orientation="Landscape", - disable_javascript="") + orientation='Landscape', + disable_javascript=True) """ - - def _extra_args(**kwargs): - """Converts kwargs into a string of flags to be passed to wkhtmltopdf.""" - flags = '' - for k, v in kwargs.items(): - flags += ' --%s %s' % (k.replace('_', '-'), v) - return flags - - if not isinstance(pages, list): + if isinstance(pages, basestring): + # Support a single page. pages = [pages] - kwargs['quiet'] = '' - args = '%s %s %s %s' % (WKHTMLTOPDF_CMD, _extra_args(**kwargs), ' '.join(pages), output or '-') + if output is None: + # Standard output. + output = '-' - process = Popen(args, stdout=PIPE, shell=True) - stdoutdata, stderrdata = process.communicate() + # Default options: + options = getattr(settings, 'WKHTMLTOPDF_CMD_OPTIONS', None) + if options is None: + options = {'quiet': True} + else: + options = copy(options) + options.update(kwargs) - if process.returncode != 0: - raise CalledProcessError(process.returncode, args) + env = getattr(settings, 'WKHTMLTOPDF_ENV', None) + if env is not None: + env = dict(os.environ, **env) - if output is None: - output = stdoutdata + cmd = getattr(settings, 'WKHTMLTOPDF_CMD', 'wkhtmltopdf') + args = list(chain([cmd], + _options_to_args(**options), + list(pages), + [output])) + return check_output(args, stderr=sys.stderr, env=env) - return output def template_to_temp_file(template_name, dictionary=None, context_instance=None): """ Renders a template to a temp file, and returns the path of the file. """ + warnings.warn('template_to_temp_file is deprecated in favour of PDFResponse. It will be removed in version 1.', + PendingDeprecationWarning, 2) file_descriptor, tempfile_path = mkstemp(suffix='.html') with fdopen(file_descriptor, 'wt') as f: f.write(smart_str(loader.render_to_string(template_name, dictionary=dictionary, context_instance=context_instance))) return tempfile_path + +def content_disposition_filename(filename): + """ + Sanitize a file name to be used in the Content-Disposition HTTP + header. + + Even if the standard is quite permissive in terms of + characters, there are a lot of edge cases that are not supported by + different browsers. + + See http://greenbytes.de/tech/tc2231/#attmultinstances for more details. + """ + filename = filename.replace(';', '').replace('"', '') + return http_quote(filename) + + +def http_quote(string): + """ + Given a unicode string, will do its dandiest to give you back a + valid ascii charset string you can use in, say, http headers and the + like. + """ + if isinstance(string, unicode): + try: + import unidecode + string = unidecode.unidecode(string) + except ImportError: + string = string.encode('ascii', 'replace') + # Wrap in double-quotes for ; , and the like + return '"{0!s}"'.format(string.replace('\\', '\\\\').replace('"', '\\"')) + + +def pathname2fileurl(pathname): + """Returns a file:// URL for pathname. Handles OS-specific conversions.""" + return 'file://' + urllib.pathname2url(pathname) + + +try: + # From Django 1.4 + from django.conf import override_settings +except ImportError: + class override_settings(object): + """ + Acts as either a decorator, or a context manager. If it's a decorator it + takes a function and returns a wrapped function. If it's a contextmanager + it's used with the ``with`` statement. In either event entering/exiting + are called before and after, respectively, the function/block is executed. + """ + def __init__(self, **kwargs): + self.options = kwargs + self.wrapped = settings._wrapped + + def __enter__(self): + self.enable() + + def __exit__(self, exc_type, exc_value, traceback): + self.disable() + + def __call__(self, test_func): + from django.test import TransactionTestCase + if isinstance(test_func, type) and issubclass(test_func, TransactionTestCase): + original_pre_setup = test_func._pre_setup + original_post_teardown = test_func._post_teardown + def _pre_setup(innerself): + self.enable() + original_pre_setup(innerself) + def _post_teardown(innerself): + original_post_teardown(innerself) + self.disable() + test_func._pre_setup = _pre_setup + test_func._post_teardown = _post_teardown + return test_func + else: + @wraps(test_func) + def inner(*args, **kwargs): + with self: + return test_func(*args, **kwargs) + return inner + + def enable(self): + override = copy(settings._wrapped) + for key, new_value in self.options.items(): + setattr(override, key, new_value) + settings._wrapped = override + + def disable(self): + settings._wrapped = self.wrapped diff --git a/wkhtmltopdf/views.py b/wkhtmltopdf/views.py index 164f94f..fff98c8 100644 --- a/wkhtmltopdf/views.py +++ b/wkhtmltopdf/views.py @@ -1,66 +1,283 @@ -import os +from __future__ import absolute_import + from re import compile +from tempfile import NamedTemporaryFile +import warnings from django.conf import settings -from django.contrib.sites.models import Site -from django.template.context import RequestContext -from django.template.response import HttpResponse +from django.http import HttpResponse +from django.template.response import TemplateResponse from django.views.generic import TemplateView -from wkhtmltopdf.utils import template_to_temp_file, wkhtmltopdf +from .utils import (content_disposition_filename, override_settings, + pathname2fileurl, wkhtmltopdf) class PDFResponse(HttpResponse): - def __init__(self, content, *args, **kwargs): - filename = kwargs.pop('filename', None) - super(PDFResponse, self).__init__(content, 'application/pdf', *args, **kwargs) + """HttpResponse that sets the headers for PDF output.""" + + def __init__(self, content, mimetype=None, status=200, + content_type=None, filename=None, *args, **kwargs): + + if content_type is None: + content_type = 'application/pdf' + + super(PDFResponse, self).__init__(content=content, + mimetype=mimetype, + status=status, + content_type=content_type) + self.set_filename(filename) + + def set_filename(self, filename): + self.filename = filename if filename: + filename = content_disposition_filename(filename) header_content = 'attachment; filename={0}'.format(filename) - self.__setitem__('Content-Disposition', header_content) + self['Content-Disposition'] = header_content + else: + del self['Content-Disposition'] class PdfResponse(PDFResponse): def __init__(self, content, filename): - warning = '''PdfResponse is deprecated in favour of PDFResponse. It will be removed in version 1.''' - raise PendingDeprecationWarning(warning) - super(PdfResponse, self).__init__(content, filename) + warnings.warn('PdfResponse is deprecated in favour of PDFResponse. It will be removed in version 1.', + PendingDeprecationWarning, 2) + super(PdfResponse, self).__init__(content, filename=filename) + + +class PDFTemplateResponse(TemplateResponse, PDFResponse): + """Renders a Template into a PDF using wkhtmltopdf""" + + def __init__(self, request, template, context=None, mimetype=None, + status=None, content_type=None, current_app=None, + filename=None, header_template=None, footer_template=None, + cmd_options=None, override_settings=None, + *args, **kwargs): + + super(PDFTemplateResponse, self).__init__(request=request, + template=template, + context=context, + mimetype=mimetype, + status=status, + content_type=content_type, + current_app=None, + *args, **kwargs) + self.set_filename(filename) + + self.header_template = header_template + self.footer_template = footer_template + + if cmd_options is None: + cmd_options = {} + self.cmd_options = cmd_options + + self.override_settings = override_settings + + def render_to_temporary_file(self, template_name, mode='w+b', bufsize=-1, + suffix='', prefix='tmp', dir=None, + delete=True): + template = self.resolve_template(template_name) + + # Since many things require a sensible settings.MEDIA_URL and + # settings.STATIC_URL, including TEMPLATE_CONTEXT_PROCESSORS; + # the settings themselves need to be overridden when rendering. + # + # This allows django-wkhtmltopdf to play nicely with the + # staticfiles app, for instance. + with override_settings(**self.get_override_settings()): + context = self.resolve_context(self.context_data) + content = template.render(context) + + tempfile = NamedTemporaryFile(mode=mode, bufsize=bufsize, + suffix=suffix, prefix=prefix, + dir=dir, delete=delete) + try: + tempfile.write(content) + tempfile.flush() + return tempfile + except: + # Clean-up tempfile if an Exception is raised. + tempfile.close() + raise + + def convert_to_pdf(self, filename, + header_filename=None, footer_filename=None): + cmd_options = self.cmd_options.copy() + # Clobber header_html and footer_html only if filenames are + # provided. These keys may be in self.cmd_options as hardcoded + # static files. + if header_filename is not None: + cmd_options['header_html'] = header_filename + if footer_filename is not None: + cmd_options['footer_html'] = footer_filename + return wkhtmltopdf(pages=[filename], **cmd_options) + + @property + def rendered_content(self): + """Returns the freshly rendered content for the template and context + described by the PDFResponse. + + This *does not* set the final content of the response. To set the + response content, you must either call render(), or set the + content explicitly using the value of this property. + """ + debug = getattr(settings, 'WKHTMLTOPDF_DEBUG', settings.DEBUG) + + input_file = header_file = footer_file = None + header_filename = footer_filename = None + + try: + input_file = self.render_to_temporary_file( + template_name=self.template_name, + prefix='wkhtmltopdf', suffix='.html', + delete=(not debug) + ) + + if self.header_template: + header_file = self.render_to_temporary_file( + template_name=self.header_template, + prefix='wkhtmltopdf', suffix='.html', + delete=(not debug) + ) + header_filename = header_file.name + + if self.footer_template: + footer_file = self.render_to_temporary_file( + template_name=self.footer_template, + prefix='wkhtmltopdf', suffix='.html', + delete=(not debug) + ) + footer_filename = footer_file.name + + return self.convert_to_pdf(filename=input_file.name, + header_filename=header_filename, + footer_filename=footer_filename) + finally: + # Clean up temporary files + for f in filter(None, (input_file, header_file, footer_file)): + f.close() + + def get_override_settings(self): + """Returns a dictionary of settings to override for response_class""" + overrides = { + 'MEDIA_ROOT': settings.MEDIA_ROOT, + 'MEDIA_URL': settings.MEDIA_URL, + 'STATIC_ROOT': settings.STATIC_ROOT, + 'STATIC_URL': settings.STATIC_URL, + } + if self.override_settings is not None: + overrides.update(self.override_settings) + + has_scheme = compile(r'^[^:/]+://') + + # If MEDIA_URL doesn't have a scheme, we transform it into a + # file:// URL based on MEDIA_ROOT. + urls = [('MEDIA_URL', 'MEDIA_ROOT'), + ('STATIC_URL', 'STATIC_ROOT')] + for url, root in urls: + if not has_scheme.match(overrides[url]): + overrides[url] = pathname2fileurl(overrides[root]) + if not overrides[url].endswith('/'): + overrides[url] += '/' + + return overrides class PDFTemplateView(TemplateView): + """Class-based view for HTML templates rendered to PDF.""" + + # Filename for downloaded PDF. If None, the response is inline. filename = 'rendered_pdf.pdf' - footer_template = None + + # Filenames for the content, header, and footer templates. + template_name = None header_template = None - orientation = 'portrait' - margin_bottom = 0 - margin_left = 0 - margin_right = 0 - margin_top = 0 - response = PDFResponse - _tmp_files = None + footer_template = None + + # TemplateResponse classes for PDF and HTML + response_class = PDFTemplateResponse + html_response_class = TemplateResponse + + # Command-line options to pass to wkhtmltopdf + cmd_options = { + # 'orientation': 'portrait', + # 'collate': True, + # 'quiet': None, + } def __init__(self, *args, **kwargs): - self._tmp_files = [] super(PDFTemplateView, self).__init__(*args, **kwargs) - def get(self, request, context_instance=None, *args, **kwargs): - if request.GET.get('as', '') == 'html': - return super(PDFTemplateView, self).get(request, *args, **kwargs) - - if context_instance: - self.context_instance = context_instance - else: - self.context_instance = RequestContext(request, self.get_context_data(**kwargs)) + # Copy self.cmd_options to prevent clobbering the class-level object. + self.cmd_options = self.cmd_options.copy() - page_path = template_to_temp_file(self.get_template_names(), self.get_context_data(), self.context_instance) - pdf_kwargs = self.get_pdf_kwargs() - output = wkhtmltopdf(page_path, **pdf_kwargs) - if self._tmp_files: - map(os.remove, self._tmp_files) - return self.response(output, filename=self.get_filename()) + def get(self, request, *args, **kwargs): + response_class = self.response_class + try: + if request.GET.get('as', '') == 'html': + # Use the html_response_class if HTML was requested. + self.response_class = self.html_response_class + return super(PDFTemplateView, self).get(request, + *args, **kwargs) + finally: + # Remove self.response_class + self.response_class = response_class def get_filename(self): return self.filename + def get_cmd_options(self): + return self.cmd_options + + def get_pdf_kwargs(self): + warnings.warn('PDFTemplateView.get_pdf_kwargs() is deprecated in favour of get_cmd_options(). It will be removed in version 1.', + PendingDeprecationWarning, 2) + return self.get_cmd_options() + + def render_to_response(self, context, **response_kwargs): + """ + Returns a PDF response with a template rendered with the given context. + """ + filename = response_kwargs.pop('filename', None) + cmd_options = response_kwargs.pop('cmd_options', None) + override_settings = response_kwargs.pop('override_settings', None) + + if issubclass(self.response_class, PDFTemplateResponse): + if filename is None: + filename = self.get_filename() + + if cmd_options is None: + cmd_options = self.get_cmd_options() + + return super(PDFTemplateView, self).render_to_response( + context=context, filename=filename, + header_template=self.header_template, + footer_template=self.footer_template, + cmd_options=cmd_options, override_settings=override_settings, + **response_kwargs + ) + else: + return super(PDFTemplateView, self).render_to_response( + context=context, + **response_kwargs + ) + + +class PdfTemplateView(PDFTemplateView): #TODO: Remove this in v1.0 + orientation = 'portrait' + margin_bottom = 0 + margin_left = 0 + margin_right = 0 + margin_top = 0 + + def __init__(self, *args, **kwargs): + warnings.warn('PdfTemplateView is deprecated in favour of PDFTemplateView. It will be removed in version 1.', + PendingDeprecationWarning, 2) + super(PdfTemplateView, self).__init__(*args, **kwargs) + + def get_cmd_options(self): + return self.get_pdf_kwargs() + def get_pdf_kwargs(self): kwargs = { 'margin_bottom': self.margin_bottom, @@ -69,28 +286,4 @@ class PDFTemplateView(TemplateView): 'margin_top': self.margin_top, 'orientation': self.orientation, } - if self.header_template: - kwargs['header_html'] = template_to_temp_file(self.header_template, self.get_context_data(), self.context_instance) - self._tmp_files.append(kwargs['header_html']) - if self.footer_template: - kwargs['footer_html'] = template_to_temp_file(self.footer_template, self.get_context_data(), self.context_instance) - self._tmp_files.append(kwargs['footer_html']) return kwargs - - def get_context_data(self, **kwargs): - context = super(PDFTemplateView, self).get_context_data(**kwargs) - - match_full_url = compile(r'^https?://') - if not match_full_url.match(settings.STATIC_URL): - context['STATIC_URL'] = 'http://' + Site.objects.get_current().domain + settings.STATIC_URL - if not match_full_url.match(settings.MEDIA_URL): - context['MEDIA_URL'] = 'http://' + Site.objects.get_current().domain + settings.MEDIA_URL - - return context - - -class PdfTemplateView(PDFTemplateView): #TODO: Remove this in v1.0 - def as_view(cls, **initkwargs): - warning = '''PdfTemplateView is deprecated in favour of PDFTemplateView. It will be removed in version 1.''' - raise PendingDeprecationWarning(warning) - return super(PdfTemplateView, cls).as_view(**initkwargs) |