aboutsummaryrefslogtreecommitdiffstats
path: root/wkhtmltopdf
diff options
context:
space:
mode:
Diffstat (limited to 'wkhtmltopdf')
-rw-r--r--wkhtmltopdf/_testproject/__init__.py0
-rw-r--r--wkhtmltopdf/_testproject/manage.py14
-rw-r--r--wkhtmltopdf/_testproject/requirements.txt1
-rw-r--r--wkhtmltopdf/_testproject/settings.py152
-rw-r--r--wkhtmltopdf/_testproject/templates/footer.html2
-rw-r--r--wkhtmltopdf/_testproject/templates/sample.html7
-rw-r--r--wkhtmltopdf/_testproject/urls.py17
-rw-r--r--wkhtmltopdf/subprocess.py44
-rw-r--r--wkhtmltopdf/tests.py299
-rw-r--r--wkhtmltopdf/utils.py173
-rw-r--r--wkhtmltopdf/views.py311
11 files changed, 930 insertions, 90 deletions
diff --git a/wkhtmltopdf/_testproject/__init__.py b/wkhtmltopdf/_testproject/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/wkhtmltopdf/_testproject/__init__.py
diff --git a/wkhtmltopdf/_testproject/manage.py b/wkhtmltopdf/_testproject/manage.py
new file mode 100644
index 0000000..3e4eedc
--- /dev/null
+++ b/wkhtmltopdf/_testproject/manage.py
@@ -0,0 +1,14 @@
+#!/usr/bin/env python
+from django.core.management import execute_manager
+import imp
+try:
+ imp.find_module('settings') # Assumed to be in the same directory.
+except ImportError:
+ import sys
+ sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n" % __file__)
+ sys.exit(1)
+
+import settings
+
+if __name__ == "__main__":
+ execute_manager(settings)
diff --git a/wkhtmltopdf/_testproject/requirements.txt b/wkhtmltopdf/_testproject/requirements.txt
new file mode 100644
index 0000000..74c18fb
--- /dev/null
+++ b/wkhtmltopdf/_testproject/requirements.txt
@@ -0,0 +1 @@
+Django==1.3.1
diff --git a/wkhtmltopdf/_testproject/settings.py b/wkhtmltopdf/_testproject/settings.py
new file mode 100644
index 0000000..0454766
--- /dev/null
+++ b/wkhtmltopdf/_testproject/settings.py
@@ -0,0 +1,152 @@
+import os
+
+PROJECT_PATH = os.path.abspath(os.path.dirname(__file__))
+
+# Django settings for testproject project.
+
+DEBUG = True
+TEMPLATE_DEBUG = DEBUG
+
+ADMINS = (
+ # ('Your Name', 'your_email@example.com'),
+)
+
+MANAGERS = ADMINS
+
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'.
+ 'NAME': 'test.db', # Or path to database file if using sqlite3.
+ 'USER': '', # Not used with sqlite3.
+ 'PASSWORD': '', # Not used with sqlite3.
+ 'HOST': '', # Set to empty string for localhost. Not used with sqlite3.
+ 'PORT': '', # Set to empty string for default. Not used with sqlite3.
+ }
+}
+
+# Local time zone for this installation. Choices can be found here:
+# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
+# although not all choices may be available on all operating systems.
+# On Unix systems, a value of None will cause Django to use the same
+# timezone as the operating system.
+# If running in a Windows environment this must be set to the same as your
+# system time zone.
+TIME_ZONE = 'America/Chicago'
+
+# Language code for this installation. All choices can be found here:
+# http://www.i18nguy.com/unicode/language-identifiers.html
+LANGUAGE_CODE = 'en-us'
+
+SITE_ID = 1
+
+# If you set this to False, Django will make some optimizations so as not
+# to load the internationalization machinery.
+USE_I18N = True
+
+# If you set this to False, Django will not format dates, numbers and
+# calendars according to the current locale
+USE_L10N = True
+
+# Absolute filesystem path to the directory that will hold user-uploaded files.
+# Example: "/home/media/media.lawrence.com/media/"
+MEDIA_ROOT = ''
+
+# URL that handles the media served from MEDIA_ROOT. Make sure to use a
+# trailing slash.
+# Examples: "http://media.lawrence.com/media/", "http://example.com/media/"
+MEDIA_URL = ''
+
+# Absolute path to the directory static files should be collected to.
+# Don't put anything in this directory yourself; store your static files
+# in apps' "static/" subdirectories and in STATICFILES_DIRS.
+# Example: "/home/media/media.lawrence.com/static/"
+STATIC_ROOT = ''
+
+# URL prefix for static files.
+# Example: "http://media.lawrence.com/static/"
+STATIC_URL = '/static/'
+
+# URL prefix for admin static files -- CSS, JavaScript and images.
+# Make sure to use a trailing slash.
+# Examples: "http://foo.com/static/admin/", "/static/admin/".
+ADMIN_MEDIA_PREFIX = '/static/admin/'
+
+# Additional locations of static files
+STATICFILES_DIRS = (
+ # Put strings here, like "/home/html/static" or "C:/www/django/static".
+ # Always use forward slashes, even on Windows.
+ # Don't forget to use absolute paths, not relative paths.
+)
+
+# List of finder classes that know how to find static files in
+# various locations.
+STATICFILES_FINDERS = (
+ 'django.contrib.staticfiles.finders.FileSystemFinder',
+ 'django.contrib.staticfiles.finders.AppDirectoriesFinder',
+# 'django.contrib.staticfiles.finders.DefaultStorageFinder',
+)
+
+# Make this unique, and don't share it with anybody.
+SECRET_KEY = '$)v)0$h)^c5!h2wms8*wn1c!7)7dp@qb87h7q)zecp2@$pnv=g'
+
+# List of callables that know how to import templates from various sources.
+TEMPLATE_LOADERS = (
+ 'django.template.loaders.filesystem.Loader',
+ 'django.template.loaders.app_directories.Loader',
+# 'django.template.loaders.eggs.Loader',
+)
+
+MIDDLEWARE_CLASSES = (
+ 'django.middleware.common.CommonMiddleware',
+ 'django.contrib.sessions.middleware.SessionMiddleware',
+ 'django.middleware.csrf.CsrfViewMiddleware',
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'django.contrib.messages.middleware.MessageMiddleware',
+)
+
+ROOT_URLCONF = 'testproject.urls'
+
+TEMPLATE_DIRS = (
+ os.path.join(PROJECT_PATH, 'templates')
+ # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates".
+ # Always use forward slashes, even on Windows.
+ # Don't forget to use absolute paths, not relative paths.
+)
+
+INSTALLED_APPS = (
+ 'django.contrib.auth',
+ 'django.contrib.contenttypes',
+ 'django.contrib.sessions',
+ 'django.contrib.sites',
+ 'django.contrib.messages',
+ 'django.contrib.staticfiles',
+ # Uncomment the next line to enable the admin:
+ # 'django.contrib.admin',
+ # Uncomment the next line to enable admin documentation:
+ # 'django.contrib.admindocs',
+
+ 'wkhtmltopdf',
+)
+
+# A sample logging configuration. The only tangible logging
+# performed by this configuration is to send an email to
+# the site admins on every HTTP 500 error.
+# See http://docs.djangoproject.com/en/dev/topics/logging for
+# more details on how to customize your logging configuration.
+LOGGING = {
+ 'version': 1,
+ 'disable_existing_loggers': False,
+ 'handlers': {
+ 'mail_admins': {
+ 'level': 'ERROR',
+ 'class': 'django.utils.log.AdminEmailHandler'
+ }
+ },
+ 'loggers': {
+ 'django.request': {
+ 'handlers': ['mail_admins'],
+ 'level': 'ERROR',
+ 'propagate': True,
+ },
+ }
+}
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/wkhtmltopdf/_testproject/templates/sample.html b/wkhtmltopdf/_testproject/templates/sample.html
new file mode 100644
index 0000000..3dfbdbb
--- /dev/null
+++ b/wkhtmltopdf/_testproject/templates/sample.html
@@ -0,0 +1,7 @@
+<html>
+ <head>
+ </head>
+ <body>
+ <h1>{{ title }}</h1>
+ </body>
+</html>
diff --git a/wkhtmltopdf/_testproject/urls.py b/wkhtmltopdf/_testproject/urls.py
new file mode 100644
index 0000000..039e61a
--- /dev/null
+++ b/wkhtmltopdf/_testproject/urls.py
@@ -0,0 +1,17 @@
+from django.conf.urls.defaults import patterns, include, url
+
+# Uncomment the next two lines to enable the admin:
+# from django.contrib import admin
+# admin.autodiscover()
+
+urlpatterns = patterns('',
+ # Examples:
+ # url(r'^$', 'testproject.views.home', name='home'),
+ # url(r'^testproject/', include('testproject.foo.urls')),
+
+ # Uncomment the admin/doc line below to enable admin documentation:
+ # url(r'^admin/doc/', include('django.contrib.admindocs.urls')),
+
+ # Uncomment the next line to enable the admin:
+ # url(r'^admin/', include(admin.site.urls)),
+)
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)