aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.rst10
-rw-r--r--docs/conf.py6
-rw-r--r--docs/index.rst1
-rw-r--r--docs/settings.rst69
-rw-r--r--docs/usage.rst61
-rw-r--r--setup.py2
-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.html2
-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.py44
-rw-r--r--wkhtmltopdf/tests.py299
-rw-r--r--wkhtmltopdf/utils.py173
-rw-r--r--wkhtmltopdf/views.py311
17 files changed, 870 insertions, 108 deletions
diff --git a/README.rst b/README.rst
index ee7a099..925f543 100644
--- a/README.rst
+++ b/README.rst
@@ -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,
+ }
diff --git a/setup.py b/setup.py
index 86c0b44..865b096 100644
--- a/setup.py
+++ b/setup.py
@@ -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)