Make image inline threshold a parameter (#1), and remove the para.padding parameter
[xhtml2odt:xhtml2odt.git] / xhtml2odt.py
1 #!/usr/bin/env python
2
3 """
4 xhtml2odt - XHTML to ODT XML transformation
5 ===========================================
6
7 Copyright (C) 2009-2010 Aurelien Bompard
8
9 This script can convert a wiki page to the OpenDocument Text (ODT) format,
10 standardized as ISO/IEC 26300:2006, and the native format of office suites such
11 as OpenOffice.org, KOffice, and others.
12
13 It uses a template ODT file which will be filled with the converted content of
14 the XHTML page.
15
16 Website: http://xhtml2odt.org
17
18 Inspired by the work on docbook2odt_, by Roman Fordinal
19
20 .. _docbook2odt: http://open.comsultia.com/docbook2odf/
21
22
23 Usage
24 -----
25
26 Call the script with the :option:`--help` option to see all the available
27 options.  The main options are:
28
29 .. cmdoption:: -i <file>, --input <file>
30
31    The HTML file to read from.
32
33 .. cmdoption:: -o <file>, --output <file>
34
35    The ODT file to export to (will be overwritten if already present).
36
37 .. cmdoption:: -t <file>, --template <file>
38
39    The ODT file to use as a template (must be readable).
40
41 .. cmdoption:: -v
42
43    Be verbose (enables logging)
44
45
46 The full help message is::
47
48     Usage: xhtml2odt.py [options] -i input -o output -t template.odt
49
50     Options:
51       -h, --help            show this help message and exit
52       -i FILE, --input=FILE
53                             Read the html from this file
54       -o FILE, --output=FILE
55                             Location of the output ODT file
56       -t FILE, --template=FILE
57                             Location of the template ODT file
58       -u URL, --url=URL     Use this URL for relative links
59       -v, --verbose         Show what's going on
60       --html-id=ID          Only export from the element with this ID
61       --replace=KEYWORD     Keyword to replace in the ODT template (default is
62                             ODT-INSERT)
63       --cut-start=KEYWORD   Keyword to start cutting text from the ODT template
64                             (default is ODT-CUT-START)
65       --cut-stop=KEYWORD    Keyword to stop cutting text from the ODT template
66                             (default is ODT-CUT-STOP)
67       --top-header-level=LEVEL
68                             Level of highest header in the HTML (default is 1)
69       --img-default-width=WIDTH
70                             Default image width (default is 8cm)
71       --img-default-height=HEIGHT
72                             Default image height (default is 6cm)
73       --dpi=DPI             Screen resolution in Dots Per Inch (default is 96)
74       --no-network          Do not download remote images
75       --stylesdir=DIR       Override the style templates directory
76
77
78 License
79 -------
80
81 GNU LGPL v2.1 or later: http://www.gnu.org/licenses/lgpl-2.1.html
82
83 This program is free software; you can redistribute it and/or
84 modify it under the terms of the GNU Lesser General Public
85 License as published by the Free Software Foundation; either
86 version 2.1 of the License, or (at your option) any later version.
87
88 This program is distributed in the hope that it will be useful,
89 but WITHOUT ANY WARRANTY; without even the implied warranty of
90 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
91 Library General Public License for more details.
92
93 Code
94 ----
95 """
96
97 import tempfile
98 import shutil
99 import re
100 import os
101 import sys
102 import zipfile
103 import urllib2
104 import urlparse
105 from StringIO import StringIO
106 from optparse import OptionParser
107
108 import tidy
109 from lxml import etree
110 from PIL import Image
111
112 #pylint#: disable-msg=C0301,C0111
113
114 INSTALL_PATH = "."
115
116 INCH_TO_CM = 2.54
117 CHARSET = "utf-8"
118
119 __version__ = 0.1
120
121 class ODTExportError(Exception):
122     """Base exception for ODT conversion errors"""
123     pass
124
125 class HTMLFile(object):
126     """
127     This class contains the HTML document to convert to ODT. The HTML code
128     will be run through Tidy to ensure that is is valid and well-formed
129     XHTML.
130
131     :ivar options: An OptionParser-result object containing the options for
132         processing.
133     :type options: OptionsParser-result object
134     :ivar html: The HTML code.
135     :type html: ``str``
136     """
137
138     def __init__(self, options):
139         self.options = options
140         self.html = ""
141
142     def read(self):
143         """
144         Read the HTML file from :attr:`options`.input, run it through Tidy, and
145         filter using the selected ID (if applicable).
146         """
147         in_file = open(self.options.input)
148         self.html = in_file.read()
149         in_file.close()
150         self.cleanup()
151         if self.options.htmlid:
152             self.select_id()
153
154     def cleanup(self):
155         """
156         Run the HTML code from the :attr:`html` instance variable through Tidy.
157         """
158         tidy_options = dict(output_xhtml=1, add_xml_decl=1, indent=1,
159                             tidy_mark=0, #input_encoding=str(self.charset),
160                             output_encoding='utf8', doctype='auto',
161                             wrap=0, char_encoding='utf8')
162         self.html = str(tidy.parseString(self.html, **tidy_options))
163         if not self.html:
164             raise ODTExportError(
165                         "Tidy could not clean up the document, aborting.")
166         # Replace nbsp with entity
167         # http://www.mail-archive.com/analog-help@lists.meer.net/msg03670.html
168         self.html = self.html.replace("&nbsp;", "&#160;")
169         # Tidy creates newlines after <pre> (by indenting)
170         self.html = re.sub('<pre([^>]*)>\n', '<pre\\1>', self.html)
171
172     def select_id(self):
173         """
174         Replace the HTML content by an element in the content. The element
175         is selected by its HTML ID.
176         """
177         try:
178             html_tree = etree.fromstring(self.html)
179         except etree.XMLSyntaxError:
180             if self.options.verbose:
181                 raise
182             else:
183                 raise ODTExportError("The XHTML is still not valid after "
184                                      "Tidy's work, I can't convert it.")
185         selected = html_tree.xpath("//*[@id='%s']" % self.options.htmlid)
186         if selected:
187             self.html = etree.tostring(selected[0], method="html")
188         else:
189             print >> sys.stderr, "Can't find the selected HTML id: %s, " \
190                                  % self.options.htmlid \
191                                 +"converting everything."
192
193
194 class ODTFile(object):
195     """Handles the conversion and production of an ODT file"""
196
197     def __init__(self, options):
198         self.options = options
199         self.template_dirs = []
200         if options.stylesdir:
201             self.template_dirs.append(options.stylesdir)
202         self.template_dirs.append(
203             os.path.join(INSTALL_PATH, "styles")
204         )
205         self.xml = {
206             "content": "",
207             "styles": "",
208         }
209         self.tmpdir = tempfile.mkdtemp(prefix="xhtml2odt-")
210         self.zfile = None
211
212     def open(self):
213         """
214         Uncompress the template ODT file, and read the content.xml and
215         styles.xml files into memory.
216         """
217         self.zfile = zipfile.ZipFile(self.options.template, "r")
218         for name in self.zfile.namelist():
219             fname = os.path.join(self.tmpdir, name)
220             if not os.path.exists(os.path.dirname(fname)):
221                 os.makedirs(os.path.dirname(fname))
222             if name[-1] == "/":
223                 if not os.path.exists(fname):
224                     os.mkdir(fname)
225                 continue
226             fname_h = open(fname, "w")
227             fname_h.write(self.zfile.read(name))
228             fname_h.close()
229         for xmlfile in self.xml:
230             self.xml[xmlfile] = self.zfile.read("%s.xml" % xmlfile)
231
232     def import_xhtml(self, xhtml):
233         """
234         Main function to run the conversion process:
235
236         * XHTML import
237         * conversion to ODT XML
238         * insertion into the ODT template
239         * adding of the missing styles
240
241         The next logical step is to use the :meth:`save` method.
242
243         :param xhtml: the XHTML content to import
244         :type  xhtml: str
245         """
246         odt = self.xhtml_to_odt(xhtml)
247         self.insert_content(odt)
248         self.add_styles()
249
250     def xhtml_to_odt(self, xhtml):
251         """
252         Converts the XHTML content into ODT.
253
254         :param xhtml: the XHTML content to import
255         :type  xhtml: str
256         :returns: the ODT XML from the conversion
257         :rtype: str
258         """
259         xsl_dir = os.path.join(INSTALL_PATH, 'xsl')
260         xslt_doc = etree.parse(os.path.join(xsl_dir, "xhtml2odt.xsl"))
261         transform = etree.XSLT(xslt_doc)
262         xhtml = self.handle_images(xhtml)
263         xhtml = self.handle_links(xhtml)
264         try:
265             xhtml = etree.fromstring(xhtml) # must be valid xml at this point
266         except etree.XMLSyntaxError:
267             if self.options.verbose:
268                 raise
269             else:
270                 raise ODTExportError("The XHTML is still not valid after "
271                                      "Tidy's work, I can't convert it.")
272         params = {
273             "url": "/",
274             "heading_minus_level": str(self.options.top_header_level - 1),
275         }
276         if self.options.verbose:
277             params["debug"] = "1"
278         if self.options.img_width:
279             if hasattr(etree.XSLT, "strparam"):
280                 params["img_default_width"] = etree.XSLT.strparam(
281                                                 self.options.img_width)
282             else: # lxml < 2.2
283                 params["img_default_width"] = "'%s'" % self.options.img_width
284         if self.options.img_height:
285             if hasattr(etree.XSLT, "strparam"):
286                 params["img_default_height"] = etree.XSLT.strparam(
287                                                 self.options.img_height)
288             else: # lxml < 2.2
289                 params["img_default_height"] = "'%s'" % self.options.img_height
290         odt = transform(xhtml, **params)
291         return str(odt).replace('<?xml version="1.0" encoding="utf-8"?>','')
292
293     def handle_images(self, xhtml):
294         """
295         Handling of image tags in the XHTML. Local and remote images are
296         handled differently: see the :meth:`handle_local_img` and
297         :meth:`handle_remote_img` methods for details.
298
299         :param xhtml: the XHTML content to import
300         :type  xhtml: str
301         :returns: XHTML with normalized ``img`` tags
302         :rtype: str
303         """
304         # Handle local images
305         xhtml = re.sub('<img [^>]*src="([^"]+)"[^>]*>',
306                       self.handle_local_img, xhtml)
307         # Handle remote images
308         if self.options.with_network:
309             xhtml = re.sub('<img [^>]*src="(https?://[^"]+)"[^>]*>',
310                           self.handle_remote_img, xhtml)
311         #print xhtml
312         return xhtml
313
314     def handle_local_img(self, img_mo):
315         """
316         Handling of local images. This method should be called as a callback on
317         each ``img`` tag.
318
319         Find the real path of the image file and use the :meth:`handle_img`
320         method to flag it for inclusion in the ODT file.
321
322         This implementation downloads the files that come from the same domain
323         as the XHTML document cames from, but server-based export plugins can
324         just retrieve it from the local disk, using either the
325         ``DOCUMENT_ROOT`` or any appropriate method (depending on the web
326         application you're writing an export plugin for).
327
328         :param img_mo: the match object from the `re.sub` callback
329         """
330         log("handling local image: %s" % img_mo.group(1), self.options.verbose)
331         src = img_mo.group(1)
332         if src.count("://") and not src.startswith("file://"):
333             # This is an absolute link, don't touch it
334             return img_mo.group()
335         if src.startswith("file://"):
336             filename = src[7:]
337         elif src.startswith("/"):
338             filename = src
339         else: # relative link
340             filename = os.path.join(os.path.dirname(self.options.input), src)
341         if os.path.exists(filename):
342             return self.handle_img(img_mo.group(), src, filename)
343         if src.startswith("file://") or not self.options.url:
344             # There's nothing we can do here
345             return img_mo.group()
346         newsrc = urlparse.urljoin(self.options.url, os.path.normpath(src))
347         if not self.options.with_network:
348             # Don't download it, just update the URL
349             return img_mo.group().replace(src, newsrc)
350         try:
351             tmpfile = self.download_img(newsrc)
352         except (urllib2.HTTPError, urllib2.URLError):
353             log("Failed getting %s" % newsrc, self.options.verbose)
354             return img_mo.group()
355         ret = self.handle_img(img_mo.group(), src, tmpfile)
356         os.remove(tmpfile)
357         return ret
358
359     def handle_remote_img(self, img_mo):
360         """
361         Downloads remote images to a temporary file and flags them for
362         inclusion using the :meth:`handle_img` method.
363
364         :param img_mo: the match object from the `re.sub` callback
365         """
366         log('handling remote image: %s' % img_mo.group(), self.options.verbose)
367         src = img_mo.group(1)
368         try:
369             tmpfile = self.download_img(src)
370         except (urllib2.HTTPError, urllib2.URLError):
371             return img_mo.group()
372         ret = self.handle_img(img_mo.group(), src, tmpfile)
373         os.remove(tmpfile)
374         return ret
375
376     def download_img(self, src):
377         """
378         Downloads the given image to a temporary location.
379
380         :param src: the URL to download
381         :type  src: str
382         """
383         log('Downloading image: %s' % src, self.options.verbose)
384         # TODO: proxy support
385         remoteimg = urllib2.urlopen(src)
386         tmpimg_fd, tmpfile = tempfile.mkstemp()
387         tmpimg = os.fdopen(tmpimg_fd, 'w')
388         tmpimg.write(remoteimg.read())
389         tmpimg.close()
390         remoteimg.close()
391         return tmpfile
392
393     def handle_img(self, full_tag, src, filename):
394         """
395         Imports an image into the ODT file.
396
397         :param full_tag: the full ``img`` tag in the original XHTML document
398         :type  full_tag: str
399         :param src: the ``src`` attribute of the ``img`` tag
400         :type  src: str
401         :param filename: the path to the image file on the local disk
402         :type  filename: str
403         """
404         log('Importing image: %s' % filename, self.options.verbose)
405         if not os.path.exists(filename):
406             raise ODTExportError('Image "%s" is not readable or does not exist'
407                                  % filename)
408         # TODO: generate a filename (with tempfile.mkstemp) to avoid weird
409         # filenames. Maybe use img.format for the extension
410         if not os.path.exists(os.path.join(self.tmpdir, "Pictures")):
411             os.mkdir(os.path.join(self.tmpdir, "Pictures"))
412         shutil.copy(filename, os.path.join(self.tmpdir, "Pictures",
413                                            os.path.basename(filename)))
414         full_tag = full_tag.replace('src="%s"' % src,
415                     'src="Pictures/%s"' % os.path.basename(filename))
416         try:
417             img = Image.open(filename)
418         except IOError:
419             log('Failed to identify image: %s' % filename,
420                 self.options.verbose)
421         else:
422             width, height = img.size
423             log('Detected size: %spx x %spx' % (width, height),
424                 self.options.verbose)
425             width_mo = re.search('width="([0-9]+)(?:px)?"', full_tag)
426             height_mo = re.search('height="([0-9]+)(?:px)?"', full_tag)
427             if width_mo and height_mo:
428                 log('Forced size: %spx x %spx.' % (width_mo.group(),
429                         height_mo.group()), self.options.verbose)
430                 width = float(width_mo.group(1)) / self.options.img_dpi \
431                             * INCH_TO_CM
432                 height = float(height_mo.group(1)) / self.options.img_dpi \
433                             * INCH_TO_CM
434                 full_tag = full_tag.replace(width_mo.group(), "")\
435                                    .replace(height_mo.group(), "")
436             elif width_mo and not height_mo:
437                 newwidth = float(width_mo.group(1)) / \
438                            float(self.options.img_dpi) * INCH_TO_CM
439                 height = height * newwidth / width
440                 width = newwidth
441                 log('Forced width: %spx. Size will be: %scm x %scm' %
442                     (width_mo.group(1), width, height), self.options.verbose)
443                 full_tag = full_tag.replace(width_mo.group(), "")
444             elif not width_mo and height_mo:
445                 newheight = float(height_mo.group(1)) / \
446                             float(self.options.img_dpi) * INCH_TO_CM
447                 width = width * newheight / height
448                 height = newheight
449                 log('Forced height: %spx. Size will be: %scm x %scm' %
450                     (height_mo.group(1), height, width), self.options.verbose)
451                 full_tag = full_tag.replace(height_mo.group(), "")
452             else:
453                 width = width / float(self.options.img_dpi) * INCH_TO_CM
454                 height = height / float(self.options.img_dpi) * INCH_TO_CM
455                 log('Size converted to: %scm x %scm' % (height, width),
456                         self.options.verbose)
457             full_tag = full_tag.replace('<img',
458                     '<img width="%scm" height="%scm"' % (width, height))
459         return full_tag
460
461     def handle_links(self, xhtml):
462         """
463         Turn relative links into absolute links using the :meth:`handle_links`
464         method.
465         """
466         # Handle local images
467         xhtml = re.sub('<a [^>]*href="([^"]+)"',
468                       self.handle_relative_links, xhtml)
469         return xhtml
470
471     def handle_relative_links(self, link_mo):
472         """
473         Do the actual conversion of links from relative to absolute. This
474         method is used as a callback by the :meth:`handle_links` method.
475         """
476         href = link_mo.group(1)
477         if href.startswith("file://") or not self.options.url:
478             # There's nothing we can do here
479             return link_mo.group()
480         if href.count("://"):
481             # This is an absolute link, don't touch it
482             return link_mo.group()
483         log("handling relative link: %s" % href, self.options.verbose)
484         newhref = urlparse.urljoin(self.options.url, os.path.normpath(href))
485         return link_mo.group().replace(href, newhref)
486
487     def insert_content(self, content):
488         """
489         Insert ODT XML content into the ``content.xml`` file, replacing the
490         keywords if needed.
491
492         :param content: ODT XML content to insert
493         :type  content: str
494         """
495         if self.options.replace_keyword and \
496             self.xml["content"].count(self.options.replace_keyword) > 0:
497             self.xml["content"] = re.sub(
498                     "<text:p[^>]*>" +
499                     re.escape(self.options.replace_keyword)
500                     +"</text:p>", content, self.xml["content"])
501         else:
502             self.xml["content"] = self.xml["content"].replace(
503                 '</office:text>',
504                 content + '</office:text>')
505         # Cut unwanted text
506         if self.options.cut_start \
507                 and self.xml["content"].count(self.options.cut_start) > 0 \
508                 and self.options.cut_stop \
509                 and self.xml["content"].count(self.options.cut_stop) > 0:
510             self.xml["content"] = re.sub(
511                     re.escape(self.options.cut_start)
512                     + ".*" +
513                     re.escape(self.options.cut_stop),
514                     "", self.xml["content"])
515
516     def add_styles(self):
517         """
518         Scans the ODT XML for used styles that would not be already included in
519         the ODT template, and adds those missing styles.
520         """
521         xsl_dir = os.path.join(INSTALL_PATH, 'xsl')
522         xslt_doc = etree.parse(os.path.join(xsl_dir, "styles.xsl"))
523         transform = etree.XSLT(xslt_doc)
524         contentxml = etree.fromstring(self.xml["content"])
525         stylesxml = etree.fromstring(self.xml["styles"])
526         params = {}
527         if self.options.verbose:
528             params["debug"] = "1"
529         self.xml["content"] = str(transform(contentxml, **params))
530         self.xml["styles"] = str(transform(stylesxml, **params))
531
532     def compile(self):
533         """
534         Writes the in-memory ODT XML content and styles to the disk
535         """
536         # Store the new content
537         for xmlfile in self.xml:
538             xmlf = open(os.path.join(self.tmpdir, "%s.xml" % xmlfile), "w")
539             xmlf.write(self.xml[xmlfile])
540             xmlf.close()
541
542     def _build_zip(self, document):
543         """
544         Zips the working directory into a :class:`zipfile.ZipFile` object
545
546         :param document: where the :class:`ZipFile` will be stored
547         :type  document: str or file-like object
548         """
549         newzf = zipfile.ZipFile(document, "w", zipfile.ZIP_DEFLATED)
550         for root, dirs, files in os.walk(self.tmpdir):
551             for cur_file in files:
552                 realpath = os.path.join(root, cur_file)
553                 to_skip = len(self.tmpdir) + 1
554                 internalpath = os.path.join(root[to_skip:], cur_file)
555                 newzf.write(realpath, internalpath)
556         newzf.close()
557
558     def save(self, output=None):
559         """
560         General method to save the in-memory content to an ODT file on the disk.
561
562         If :attr:`output` is ``None``, the document is returned.
563
564         :param output: where the document should be saved, see the :option:`-o`
565             option.
566         :type  output: str or file-like object or ``None``
567         :returns: if output is None: the ODT document ; or else ``None``.
568         """
569         self.compile()
570         if output:
571             document = output
572         else:
573             document = StringIO()
574         self._build_zip(document)
575         shutil.rmtree(self.tmpdir)
576         if not output:
577             return document.getvalue()
578
579
580 def log(msg, verbose=False):
581     """
582     Simple method to log if we're in verbose mode (with the :option:`-v`
583     option).
584     """
585     if verbose:
586         sys.stderr.write(msg+"\n")
587
588 def get_options():
589     """
590     Parses the command-line options.
591     """
592     usage = "usage: %prog [options] -i input -o output -t template.odt"
593     parser = OptionParser(usage=usage)
594     parser.add_option("--version", dest="version", action="store_true",
595                       help="Show the version and exit")
596     parser.add_option("-i", "--input", dest="input", metavar="FILE",
597                       help="Read the html from this file")
598     parser.add_option("-o", "--output", dest="output", metavar="FILE",
599                       help="Location of the output ODT file")
600     parser.add_option("-t", "--template", dest="template", metavar="FILE",
601                       help="Location of the template ODT file")
602     parser.add_option("-u", "--url", dest="url",
603                       help="Use this URL for relative links")
604     parser.add_option("-v", "--verbose", dest="verbose",
605                       action="store_true", default=False,
606                       help="Show what's going on")
607     parser.add_option("--html-id", dest="htmlid", metavar="ID",
608                       help="Only export from the element with this ID")
609     parser.add_option("--replace", dest="replace_keyword",
610                       default="ODT-INSERT", metavar="KEYWORD",
611                       help="Keyword to replace in the ODT template "
612                       "(default is %default)")
613     parser.add_option("--cut-start", dest="cut_start",
614                       default="ODT-CUT-START", metavar="KEYWORD",
615                       help="Keyword to start cutting text from the ODT "
616                       "template (default is %default)")
617     parser.add_option("--cut-stop", dest="cut_stop",
618                       default="ODT-CUT-STOP", metavar="KEYWORD",
619                       help="Keyword to stop cutting text from the ODT "
620                       "template (default is %default)")
621     parser.add_option("--top-header-level", dest="top_header_level",
622                       type="int", default="1", metavar="LEVEL",
623                       help="Level of highest header in the HTML "
624                       "(default is %default)")
625     parser.add_option("--img-default-width", dest="img_width",
626                       metavar="WIDTH", default="8cm",
627                       help="Default image width (default is %default)")
628     parser.add_option("--img-default-height", dest="img_height",
629                       metavar="HEIGHT", default="6cm",
630                       help="Default image height (default is %default)")
631     parser.add_option("--dpi", dest="img_dpi", type="int",
632                       default=96, metavar="DPI", help="Screen resolution "
633                       "in Dots Per Inch (default is %default)")
634     parser.add_option("--no-network", dest="with_network",
635                       action="store_false", default=True,
636                       help="Do not download remote images")
637     parser.add_option("--stylesdir", dest="stylesdir", metavar="DIR",
638                       help="Override the style templates directory")
639     options, args = parser.parse_args()
640     if options.version:
641         print "xhtml2odt %s" % __version__
642         sys.exit(0)
643     if len(args) > 0:
644         parser.error("illegal arguments: %s"% ", ".join(args))
645     if not options.input:
646         parser.error("No input provided")
647     if not options.output:
648         parser.error("No output provided")
649     if not options.template:
650         default_template = os.path.join(INSTALL_PATH, "template.odt")
651         if os.path.exists(default_template):
652             options.template = default_template
653         else:
654             parser.error("No ODT template provided")
655     if not os.path.exists(options.input):
656         parser.error("Can't find input file: %s" % options.input)
657     if not os.path.exists(options.template):
658         parser.error("Can't find template file: %s" % options.template)
659     return options
660
661 def main():
662     """
663     Main function, called when the script is invoked on the command line.
664     """
665     options = get_options()
666     try:
667         htmlfile = HTMLFile(options)
668         htmlfile.read()
669         odtfile = ODTFile(options)
670         odtfile.open()
671         odtfile.import_xhtml(htmlfile.html)
672         odtfile.save(options.output)
673     except ODTExportError, ex:
674         print >> sys.stderr, ex
675         print >> sys.stderr, "Conversion failed."
676         sys.exit(1)
677
678 if __name__ == '__main__':
679     main()
680