cgi output function that shuts down stdout rather than dies
[objavi2.git] / objavi.cgi
blob529fed7ca3ec74c284b6c59242d743e3822772e5
1 #!/usr/bin/python
3 # Part of Objavi2, which turns html manuals into books
5 # Copyright (C) 2009 Douglas Bagnall
7 # This program is free software; you can redistribute it and/or modify
8 # it under the terms of the GNU General Public License as published by
9 # the Free Software Foundation; either version 2 of the License, or
10 # (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
17 # You should have received a copy of the GNU General Public License along
18 # with this program; if not, write to the Free Software Foundation, Inc.,
19 # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
21 """Make a pdf from the specified book."""
22 from __future__ import with_statement
24 import os, sys
25 import re, time
26 from pprint import pformat
28 from objavi.fmbook import log, Book, make_book_name, HTTP_HOST
29 from objavi import config
30 from objavi.cgi_utils import parse_args, optionise, listify, shift_file
31 from objavi.cgi_utils import output_blob_and_exit, is_utf8, isfloat, isfloat_or_auto, is_isbn
32 from objavi.twiki_wrapper import get_book_list
34 FORM_TEMPLATE = os.path.abspath('templates/form.html')
35 PROGRESS_TEMPLATE = os.path.abspath('templates/progress.html')
37 # ARG_VALIDATORS is a mapping between the expected cgi arguments and
38 # functions to validate their values. (None means no validation).
39 ARG_VALIDATORS = {
40 "book": re.compile(r'^([\w-]+/?)*[\w-]+$').match, # can be: BlahBlah/Blah_Blah
41 "css": is_utf8, # an url, empty (for default), or css content
42 "title": lambda x: len(x) < 999 and is_utf8(x),
43 #"header": None, # header text, UNUSED
44 "isbn": is_isbn,
45 "license": config.LICENSES.__contains__,
46 "server": config.SERVER_DEFAULTS.__contains__,
47 "engine": config.ENGINES.__contains__,
48 "booksize": config.PAGE_SIZE_DATA.__contains__,
49 "page_width": isfloat,
50 "page_height": isfloat,
51 "gutter": isfloat_or_auto,
52 "top_margin": isfloat_or_auto,
53 "side_margin": isfloat_or_auto,
54 "bottom_margin": isfloat_or_auto,
55 "columns": isfloat_or_auto,
56 "column_margin": isfloat_or_auto,
57 "cgi-context": lambda x: x.lower() in '1true0false',
58 "mode": config.CGI_MODES.__contains__,
59 "pdftype": lambda x: config.CGI_MODES.get(x, [False])[0],
60 "rotate": u"yes".__eq__,
61 "grey_scale": u"yes".__eq__,
62 "destination": config.CGI_DESTINATIONS.__contains__,
63 "toc_header": is_utf8,
64 "max-age": isfloat,
67 __doc__ += '\nValid arguments are: %s.\n' % ', '.join(ARG_VALIDATORS.keys())
70 def get_server_list():
71 return sorted(k for k, v in config.SERVER_DEFAULTS.items() if v['display'])
74 def get_size_list():
75 #order by increasing areal size.
76 def calc_size(name, pointsize, klass):
77 if pointsize:
78 mmx = pointsize[0] * config.POINT_2_MM
79 mmy = pointsize[1] * config.POINT_2_MM
80 return (mmx * mmy, name, klass,
81 '%s (%dmm x %dmm)' % (name, mmx, mmy))
83 return (0, name, klass, name) # presumably 'custom'
85 return [x[1:] for x in sorted(calc_size(k, v.get('pointsize'), v.get('class', ''))
86 for k, v in config.PAGE_SIZE_DATA.iteritems())
89 def get_default_css(server=config.DEFAULT_SERVER, mode='book'):
90 """Get the default CSS text for the selected server"""
91 log(server)
92 cssfile = config.SERVER_DEFAULTS[server]['css-%s' % mode]
93 log(cssfile)
94 f = open(cssfile)
95 s = f.read()
96 f.close()
97 return s
99 def font_links():
100 """Links to various example pdfs."""
101 links = []
102 for script in os.listdir(config.FONT_EXAMPLE_SCRIPT_DIR):
103 if not script.isalnum():
104 log("warning: font-sample %s won't work; skipping" % script)
105 continue
106 links.append('<a href="%s?script=%s">%s</a>' % (config.FONT_LIST_URL, script, script))
107 return links
110 def make_progress_page(book, bookname, mode, destination='html'):
111 """Return a function that will notify the user of progress. In
112 CGI context this means making an html page to display the
113 messages, which are then sent as javascript snippets on the same
114 connection."""
115 if not CGI_CONTEXT or destination != 'html':
116 return lambda message: '******* got message "%s"' %message
118 print "Content-type: text/html; charset=utf-8\n"
119 f = open(PROGRESS_TEMPLATE)
120 template = f.read()
121 f.close()
122 progress_list = ''.join('<li id="%s">%s</li>\n' % x[:2] for x in config.PROGRESS_POINTS
123 if mode in x[2])
125 d = {
126 'book': book,
127 'bookname': bookname,
128 'progress_list': progress_list,
130 print template % d
131 def progress_notifier(message):
132 try:
133 if message.startswith('ERROR:'):
134 log('got an error! %r' % message)
135 print ('<b class="error-message">'
136 '%s\n'
137 '</b></body></html>' % message
139 else:
140 print ('<script type="text/javascript">\n'
141 'objavi_show_progress("%s");\n'
142 '</script>' % message
144 if message == config.FINISHED_MESSAGE:
145 print '</body></html>'
146 sys.stdout.flush()
147 except ValueError, e:
148 log("failed to send message %r, got exception %r" % (message, e))
149 return progress_notifier
152 def get_page_settings(args):
153 """Find the size and any optional layout settings.
155 args['booksize'] is either a keyword describing a size or
156 'custom'. If it is custom, the form is inspected for specific
157 dimensions -- otherwise these are ignored.
159 The margins, gutter, number of columns, and column
160 margins all set themselves automatically based on the page
161 dimensions, but they can be overridden. Any that are are
162 collected here."""
163 # get all the values including sizes first
164 # the sizes are found as 'page_width' and 'page_height',
165 # but the Book class expects them as a 'pointsize' tuple, so
166 # they are easily ignored.
167 settings = {}
168 for k, extrema in config.PAGE_EXTREMA.iteritems():
169 try:
170 v = float(args.get(k))
171 except (ValueError, TypeError):
172 #log("don't like %r as a float value for %s!" % (args.get(k), k))
173 continue
174 min_val, max_val, multiplier = extrema
175 if v < min_val or v > max_val:
176 log('rejecting %s: outside %s' % (v,) + extrema)
177 else:
178 log('found %s=%s' % (k, v))
179 settings[k] = v * multiplier #convert to points in many cases
181 # now if args['size'] is not 'custom', the width and height found
182 # above are ignored.
183 size = args.get('booksize', config.DEFAULT_SIZE)
184 settings.update(config.PAGE_SIZE_DATA[size])
186 #if args['mode'] is 'newspaper', then the number of columns is
187 #automatically determined unless set -- otherwise default is 1.
188 if args.get('mode') == 'newspaper' and settings.get('columns') is None:
189 settings['columns'] = 'auto'
191 if args.get('grey_scale'):
192 settings['grey_scale'] = True
194 if size == 'custom':
195 #will raise KeyError if width, height aren't set
196 settings['pointsize'] = (settings['page_width'], settings['page_height'])
197 del settings['page_width']
198 del settings['page_height']
200 settings['engine'] = args.get('engine', config.DEFAULT_ENGINE)
201 return settings
204 def output_and_exit(f):
205 """Decorator: prefix function output with http headers and exit
206 immediately after."""
207 def output(args):
208 if CGI_CONTEXT:
209 print "Content-type: text/html; charset=utf-8\n"
210 f(args)
211 sys.exit()
212 return output
215 @output_and_exit
216 def mode_booklist(args):
217 print optionise(get_book_list(args.get('server', config.DEFAULT_SERVER)),
218 default=args.get('book'))
220 @output_and_exit
221 def mode_css(args):
222 #XX sending as text/html, but it doesn't really matter
223 print get_default_css(args.get('server', config.DEFAULT_SERVER), args.get('pdftype', 'book'))
226 @output_and_exit
227 def mode_form(args):
228 f = open(FORM_TEMPLATE)
229 template = f.read()
230 f.close()
231 f = open(config.FONT_LIST_INCLUDE)
232 font_list = [x.strip() for x in f if x.strip()]
233 f.close()
234 server = args.get('server', config.DEFAULT_SERVER)
235 book = args.get('book')
236 size = args.get('booksize', config.DEFAULT_SIZE)
237 engine = args.get('engine', config.DEFAULT_ENGINE)
238 d = {
239 'server_options': optionise(get_server_list(), default=server),
240 'book_options': optionise(get_book_list(server), default=book),
241 'size_options': optionise(get_size_list(), default=size),
242 'engines': optionise(config.ENGINES.keys(), default=engine),
243 'pdf_types': optionise(sorted(k for k, v in config.CGI_MODES.iteritems() if v[0])),
244 'css': get_default_css(server),
245 'font_links': listify(font_links()),
246 'font_list': listify(font_list),
247 'default_license' : config.DEFAULT_LICENSE,
248 'licenses' : optionise(config.LICENSES, default=config.DEFAULT_LICENSE),
249 'yes': 'yes',
250 None: '',
253 form = []
254 for id, title, type, source, classes, epilogue in config.FORM_INPUTS:
255 val = d.get(source, '')
256 e = config.FORM_ELEMENT_TYPES[type] % locals()
257 form.append('\n<div id="%(id)s_div" class="form-item %(classes)s">\n'
258 '<div class="input_title">%(title)s</div>\n'
259 '<div class="input_contents"> %(e)s %(epilogue)s\n</div>'
260 '</div>\n' % locals())
262 if True:
263 _valid_inputs = set(ARG_VALIDATORS)
264 _form_inputs = set(x[0] for x in config.FORM_INPUTS if x[2] != 'ul')
265 log("valid but not used inputs: %s" % (_valid_inputs - _form_inputs))
266 log("invalid form inputs: %s" % (_form_inputs - _valid_inputs))
268 print template % {'form': ''.join(form)}
271 def output_multi(book, mimetype, destination):
272 if destination == 'download':
273 f = open(book.publish_file)
274 data = f.read()
275 f.close()
276 output_blob_and_exit(data, mimetype, book.bookname)
277 else:
278 if HTTP_HOST:
279 bookurl = "http://%s/books/%s" % (HTTP_HOST, book.bookname,)
280 else:
281 bookurl = "books/%s" % (book.bookname,)
283 if destination == 'archive.org':
284 details_url, s3url = book.publish_s3()
285 output_blob_and_exit("%s\n%s" % (bookurl, details_url), 'text/plain')
286 elif destination == 'nowhere':
287 output_blob_and_exit(bookurl, 'text/plain')
290 def mode_book(args):
291 # so we're making a pdf.
292 mode = args.get('mode', 'book')
293 bookid = args.get('book')
294 server = args.get('server', config.DEFAULT_SERVER)
295 page_settings = get_page_settings(args)
296 bookname = make_book_name(bookid, server)
297 destination = args.get('destination', config.DEFAULT_CGI_DESTINATION)
298 progress_bar = make_progress_page(bookid, bookname, mode, destination)
300 with Book(bookid, server, bookname, page_settings=page_settings,
301 watcher=progress_bar, isbn=args.get('isbn'),
302 license=args.get('license'), title=args.get('title'),
303 max_age=float(args.get('max-age', -1))) as book:
304 if CGI_CONTEXT:
305 book.spawn_x()
307 if 'toc_header' in args:
308 book.toc_header = args['toc_header'].decode('utf-8')
309 book.load_book()
310 book.add_css(args.get('css'), mode)
311 book.add_section_titles()
313 if mode == 'book':
314 book.make_book_pdf()
315 elif mode in ('web', 'newspaper'):
316 book.make_simple_pdf(mode)
317 if "rotate" in args:
318 book.rotate180()
320 book.publish_pdf()
321 output_multi(book, "application/pdf", destination)
323 #These ones are similar enough to be handled by the one function
324 mode_newspaper = mode_book
325 mode_web = mode_book
328 def mode_openoffice(args):
329 """Make an openoffice document. A whole lot of the inputs have no
330 effect."""
331 bookid = args.get('book')
332 server = args.get('server', config.DEFAULT_SERVER)
333 bookname = make_book_name(bookid, server, '.odt')
334 destination = args.get('destination', config.DEFAULT_CGI_DESTINATION)
335 progress_bar = make_progress_page(bookid, bookname, 'openoffice', destination)
337 with Book(bookid, server, bookname,
338 watcher=progress_bar, isbn=args.get('isbn'),
339 license=args.get('license'), title=args.get('title'),
340 max_age=float(args.get('max-age', -1))) as book:
341 if CGI_CONTEXT:
342 book.spawn_x()
343 book.load_book()
344 book.add_css(args.get('css'), 'openoffice')
345 book.add_section_titles()
346 book.make_oo_doc()
347 output_multi(book, "application/vnd.oasis.opendocument.text", destination)
350 def mode_epub(args):
351 log('making epub with\n%s' % pformat(args))
352 #XXX need to catch and process lack of necessary arguments.
353 bookid = args.get('book')
354 server = args.get('server', config.DEFAULT_SERVER)
355 bookname = make_book_name(bookid, server, '.epub')
356 destination = args.get('destination', config.DEFAULT_CGI_DESTINATION)
357 progress_bar = make_progress_page(bookid, bookname, 'epub', destination)
359 with Book(bookid, server, bookname=bookname,
360 watcher=progress_bar, title=args.get('title'),
361 max_age=float(args.get('max-age', -1))) as book:
363 book.make_epub(use_cache=config.USE_CACHED_IMAGES)
364 output_multi(book, "application/epub+zip", destination)
367 def mode_bookizip(args):
368 log('making bookizip with\n%s' % pformat(args))
369 bookid = args.get('book')
370 server = args.get('server', config.DEFAULT_SERVER)
371 bookname = make_book_name(bookid, server, '.zip')
373 destination = args.get('destination', config.DEFAULT_CGI_DESTINATION)
374 progress_bar = make_progress_page(bookid, bookname, 'bookizip', destination)
376 with Book(bookid, server, bookname=bookname,
377 watcher=progress_bar, title=args.get('title'),
378 max_age=float(args.get('max-age', -1))) as book:
379 book.publish_bookizip()
380 output_multi(book, config.BOOKIZIP_MIMETYPE, destination)
383 def main():
384 args = parse_args(ARG_VALIDATORS)
385 mode = args.get('mode')
386 if mode is None and 'book' in args:
387 mode = 'book'
389 global CGI_CONTEXT
390 CGI_CONTEXT = 'SERVER_NAME' in os.environ or args.get('cgi-context', 'no').lower() in '1true'
392 if not args and not CGI_CONTEXT:
393 print __doc__
394 sys.exit()
396 output_function = globals().get('mode_%s' % mode, mode_form)
397 output_function(args)
399 if __name__ == '__main__':
400 if config.CGITB_DOMAINS and os.environ.get('REMOTE_ADDR') in config.CGITB_DOMAINS:
401 import cgitb
402 cgitb.enable()
403 main()