source: xhtml2odt.py @ 57f5ae7

Revision 57f5ae7, 26.5 KB checked in by Aurélien Bompard <aurelien@…>, 23 months ago (diff)

Make it easier to run from the extracted tarball

  • Property mode set to 100755
Line 
1#!/usr/bin/env python
2
3"""
4xhtml2odt - XHTML to ODT XML transformation
5===========================================
6
7Copyright (C) 2009-2010 Aurelien Bompard
8
9This script can convert a wiki page to the OpenDocument Text (ODT) format,
10standardized as ISO/IEC 26300:2006, and the native format of office suites such
11as OpenOffice.org, KOffice, and others.
12
13It uses a template ODT file which will be filled with the converted content of
14the XHTML page.
15
16Website: http://xhtml2odt.org
17
18Inspired by the work on docbook2odt_, by Roman Fordinal
19
20.. _docbook2odt: http://open.comsultia.com/docbook2odf/
21
22
23Usage
24-----
25
26Call the script with the :option:`--help` option to see all the available
27options.  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
46The 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
76
77License
78-------
79
80GNU LGPL v2.1 or later: http://www.gnu.org/licenses/lgpl-2.1.html
81
82This program is free software; you can redistribute it and/or
83modify it under the terms of the GNU Lesser General Public
84License as published by the Free Software Foundation; either
85version 2.1 of the License, or (at your option) any later version.
86
87This program is distributed in the hope that it will be useful,
88but WITHOUT ANY WARRANTY; without even the implied warranty of
89MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
90Library General Public License for more details.
91
92Code
93----
94"""
95
96import tempfile
97import shutil
98import re
99import os
100import sys
101import zipfile
102import urllib2
103import urlparse
104import hashlib
105import mimetypes
106from StringIO import StringIO
107from optparse import OptionParser
108
109import tidy
110from lxml import etree
111from PIL import Image
112
113#pylint#: disable-msg=C0301,C0111
114
115INSTALL_PATH = os.path.dirname(__file__)
116
117INCH_TO_CM = 2.54
118CHARSET = "utf-8"
119
120__version__ = 0.1
121
122class ODTExportError(Exception):
123    """Base exception for ODT conversion errors"""
124    pass
125
126class HTMLFile(object):
127    """
128    This class contains the HTML document to convert to ODT. The HTML code
129    will be run through Tidy to ensure that is is valid and well-formed
130    XHTML.
131
132    :ivar options: An OptionParser-result object containing the options for
133        processing.
134    :type options: OptionsParser-result object
135    :ivar html: The HTML code.
136    :type html: ``str``
137    """
138
139    def __init__(self, options):
140        self.options = options
141        self.html = ""
142
143    def read(self):
144        """
145        Read the HTML file from :attr:`options`.input, run it through Tidy, and
146        filter using the selected ID (if applicable).
147        """
148        in_file = open(self.options.input)
149        self.html = in_file.read()
150        in_file.close()
151        self.cleanup()
152        if self.options.htmlid:
153            self.select_id()
154
155    def cleanup(self):
156        """
157        Run the HTML code from the :attr:`html` instance variable through Tidy.
158        """
159        tidy_options = dict(output_xhtml=1, add_xml_decl=1, indent=1,
160                            tidy_mark=0, #input_encoding=str(self.charset),
161                            output_encoding='utf8', doctype='auto',
162                            wrap=0, char_encoding='utf8')
163        self.html = str(tidy.parseString(self.html, **tidy_options))
164        if not self.html:
165            raise ODTExportError(
166                        "Tidy could not clean up the document, aborting.")
167        # Replace nbsp with entity
168        # http://www.mail-archive.com/analog-help@lists.meer.net/msg03670.html
169        self.html = self.html.replace("&nbsp;", "&#160;")
170        # Tidy creates newlines after <pre> (by indenting)
171        self.html = re.sub('<pre([^>]*)>\n', '<pre\\1>', self.html)
172
173    def select_id(self):
174        """
175        Replace the HTML content by an element in the content. The element
176        is selected by its HTML ID.
177        """
178        try:
179            html_tree = etree.fromstring(self.html)
180        except etree.XMLSyntaxError:
181            if self.options.verbose:
182                raise
183            else:
184                raise ODTExportError("The XHTML is still not valid after "
185                                     "Tidy's work, I can't convert it.")
186        selected = html_tree.xpath("//*[@id='%s']" % self.options.htmlid)
187        if selected:
188            self.html = etree.tostring(selected[0], method="html")
189        else:
190            print >> sys.stderr, "Can't find the selected HTML id: %s, " \
191                                 % self.options.htmlid \
192                                +"converting everything."
193
194
195class ODTFile(object):
196    """Handles the conversion and production of an ODT file"""
197
198    def __init__(self, options):
199        self.options = options
200        self.xml = {
201            "content": "",
202            "styles": "",
203        }
204        self.tmpdir = tempfile.mkdtemp(prefix="xhtml2odt-")
205        self.zfile = None
206        self._added_images = []
207
208    def open(self):
209        """
210        Uncompress the template ODT file, and read the content.xml and
211        styles.xml files into memory.
212        """
213        self.zfile = zipfile.ZipFile(self.options.template, "r")
214        for name in self.zfile.namelist():
215            fname = os.path.join(self.tmpdir, name)
216            if not os.path.exists(os.path.dirname(fname)):
217                os.makedirs(os.path.dirname(fname))
218            if name[-1] == "/":
219                if not os.path.exists(fname):
220                    os.mkdir(fname)
221                continue
222            fname_h = open(fname, "w")
223            fname_h.write(self.zfile.read(name))
224            fname_h.close()
225        for xmlfile in self.xml:
226            self.xml[xmlfile] = self.zfile.read("%s.xml" % xmlfile)
227
228    def import_xhtml(self, xhtml):
229        """
230        Main function to run the conversion process:
231
232        * XHTML import
233        * conversion to ODT XML
234        * insertion into the ODT template
235        * adding of the missing styles
236
237        The next logical step is to use the :meth:`save` method.
238
239        :param xhtml: the XHTML content to import
240        :type  xhtml: str
241        """
242        odt = self.xhtml_to_odt(xhtml)
243        self.insert_content(odt)
244        self.add_styles()
245
246    def xhtml_to_odt(self, xhtml):
247        """
248        Converts the XHTML content into ODT.
249
250        :param xhtml: the XHTML content to import
251        :type  xhtml: str
252        :returns: the ODT XML from the conversion
253        :rtype: str
254        """
255        xsl_dir = os.path.join(INSTALL_PATH, 'xsl')
256        xslt_doc = etree.parse(os.path.join(xsl_dir, "xhtml2odt.xsl"))
257        transform = etree.XSLT(xslt_doc)
258        xhtml = self.handle_images(xhtml)
259        xhtml = self.handle_links(xhtml)
260        try:
261            xhtml = etree.fromstring(xhtml) # must be valid xml at this point
262        except etree.XMLSyntaxError:
263            if self.options.verbose:
264                raise
265            else:
266                raise ODTExportError("The XHTML is still not valid after "
267                                     "Tidy's work, I can't convert it.")
268        params = {
269            "url": "/",
270            "heading_minus_level": str(self.options.top_header_level - 1),
271        }
272        if self.options.verbose:
273            params["debug"] = "1"
274        if self.options.img_width:
275            if hasattr(etree.XSLT, "strparam"):
276                params["img_default_width"] = etree.XSLT.strparam(
277                                                self.options.img_width)
278            else: # lxml < 2.2
279                params["img_default_width"] = "'%s'" % self.options.img_width
280        if self.options.img_height:
281            if hasattr(etree.XSLT, "strparam"):
282                params["img_default_height"] = etree.XSLT.strparam(
283                                                self.options.img_height)
284            else: # lxml < 2.2
285                params["img_default_height"] = "'%s'" % self.options.img_height
286        odt = transform(xhtml, **params)
287        # DEBUG
288        #print str(odt)
289        return str(odt).replace('<?xml version="1.0" encoding="utf-8"?>','')
290
291    def handle_images(self, xhtml):
292        """
293        Handling of image tags in the XHTML. Local and remote images are
294        handled differently: see the :meth:`handle_local_img` and
295        :meth:`handle_remote_img` methods for details.
296
297        :param xhtml: the XHTML content to import
298        :type  xhtml: str
299        :returns: XHTML with normalized ``img`` tags
300        :rtype: str
301        """
302        # Handle local images
303        xhtml = re.sub('<img [^>]*src="([^"]+)"[^>]*>',
304                      self.handle_local_img, xhtml)
305        # Handle remote images
306        if self.options.with_network:
307            xhtml = re.sub('<img [^>]*src="(https?://[^"]+)"[^>]*>',
308                          self.handle_remote_img, xhtml)
309        #print xhtml
310        return xhtml
311
312    def handle_local_img(self, img_mo):
313        """
314        Handling of local images. This method should be called as a callback on
315        each ``img`` tag.
316
317        Find the real path of the image file and use the :meth:`handle_img`
318        method to flag it for inclusion in the ODT file.
319
320        This implementation downloads the files that come from the same domain
321        as the XHTML document cames from, but server-based export plugins can
322        just retrieve it from the local disk, using either the
323        ``DOCUMENT_ROOT`` or any appropriate method (depending on the web
324        application you're writing an export plugin for).
325
326        :param img_mo: the match object from the `re.sub` callback
327        """
328        log("handling local image: %s" % img_mo.group(1), self.options.verbose)
329        src = img_mo.group(1)
330        if src.count("://") and not src.startswith("file://"):
331            # This is an absolute link, don't touch it
332            return img_mo.group()
333        if src.startswith("file://"):
334            filename = src[7:]
335        elif src.startswith("/"):
336            filename = src
337        else: # relative link
338            filename = os.path.join(os.path.dirname(self.options.input), src)
339        if os.path.exists(filename):
340            return self.handle_img(img_mo.group(), src, filename)
341        if src.startswith("file://") or not self.options.url:
342            # There's nothing we can do here
343            return img_mo.group()
344        newsrc = urlparse.urljoin(self.options.url, os.path.normpath(src))
345        if not self.options.with_network:
346            # Don't download it, just update the URL
347            return img_mo.group().replace(src, newsrc)
348        try:
349            tmpfile = self.download_img(newsrc)
350        except (urllib2.HTTPError, urllib2.URLError):
351            log("Failed getting %s" % newsrc, self.options.verbose)
352            return img_mo.group()
353        ret = self.handle_img(img_mo.group(), src, tmpfile)
354        os.remove(tmpfile)
355        return ret
356
357    def handle_remote_img(self, img_mo):
358        """
359        Downloads remote images to a temporary file and flags them for
360        inclusion using the :meth:`handle_img` method.
361
362        :param img_mo: the match object from the `re.sub` callback
363        """
364        log('handling remote image: %s' % img_mo.group(), self.options.verbose)
365        src = img_mo.group(1)
366        try:
367            tmpfile = self.download_img(src)
368        except (urllib2.HTTPError, urllib2.URLError):
369            return img_mo.group()
370        ret = self.handle_img(img_mo.group(), src, tmpfile)
371        os.remove(tmpfile)
372        return ret
373
374    def download_img(self, src):
375        """
376        Downloads the given image to a temporary location.
377
378        :param src: the URL to download
379        :type  src: str
380        """
381        log('Downloading image: %s' % src, self.options.verbose)
382        # TODO: proxy support
383        remoteimg = urllib2.urlopen(src)
384        tmpimg_fd, tmpfile = tempfile.mkstemp()
385        tmpimg = os.fdopen(tmpimg_fd, 'w')
386        tmpimg.write(remoteimg.read())
387        tmpimg.close()
388        remoteimg.close()
389        return tmpfile
390
391    def handle_img(self, full_tag, src, filename):
392        """
393        Imports an image into the ODT file.
394
395        :param full_tag: the full ``img`` tag in the original XHTML document
396        :type  full_tag: str
397        :param src: the ``src`` attribute of the ``img`` tag
398        :type  src: str
399        :param filename: the path to the image file on the local disk
400        :type  filename: str
401        """
402        log('Importing image: %s' % filename, self.options.verbose)
403        if not os.path.exists(filename):
404            raise ODTExportError('Image "%s" is not readable or does not exist'
405                                 % filename)
406        # TODO: generate a filename (with tempfile.mkstemp) to avoid weird
407        # filenames. Maybe use img.format for the extension
408        if not os.path.exists(os.path.join(self.tmpdir, "Pictures")):
409            os.mkdir(os.path.join(self.tmpdir, "Pictures"))
410        newname = ( hashlib.md5(filename).hexdigest()
411                    + os.path.splitext(filename)[1] )
412        shutil.copy(filename, os.path.join(self.tmpdir, "Pictures", newname))
413        self._added_images.append(os.path.join("Pictures", newname))
414        full_tag = full_tag.replace('src="%s"' % src,
415                                    'src="Pictures/%s"' % newname)
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 update_manifest(self):
533        manifest_path = os.path.join(self.tmpdir, "META-INF", "manifest.xml")
534        if not os.path.exists(manifest_path):
535            return
536        manifest = etree.parse(manifest_path)
537        manifest_root = manifest.getroot()
538        manifest_ns = "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"
539        for img in self._added_images:
540            mime_type = mimetypes.guess_type(img, strict=False)[0]
541            if mime_type is None:
542                continue
543            img_el = etree.SubElement(
544                        manifest_root,
545                        "{%s}file-entry" % manifest_ns,
546                        {"{%s}media-type" % manifest_ns: mime_type,
547                         "{%s}full-path" % manifest_ns: img,
548                        })
549        manifest.write(manifest_path)
550
551    def compile(self):
552        """
553        Writes the in-memory ODT XML content and styles to the disk
554        """
555        # Store the new content
556        for xmlfile in self.xml:
557            xmlf = open(os.path.join(self.tmpdir, "%s.xml" % xmlfile), "w")
558            xmlf.write(self.xml[xmlfile])
559            xmlf.close()
560        self.update_manifest()
561
562    def _build_zip(self, document):
563        """
564        Zips the working directory into a :class:`zipfile.ZipFile` object
565
566        :param document: where the :class:`ZipFile` will be stored
567        :type  document: str or file-like object
568        """
569        newzf = zipfile.ZipFile(document, "w", zipfile.ZIP_DEFLATED)
570        for root, dirs, files in os.walk(self.tmpdir):
571            for cur_file in files:
572                realpath = os.path.join(root, cur_file)
573                to_skip = len(self.tmpdir) + 1
574                internalpath = os.path.join(root[to_skip:], cur_file)
575                newzf.write(realpath, internalpath)
576        newzf.close()
577
578    def save(self, output=None):
579        """
580        General method to save the in-memory content to an ODT file on the disk.
581
582        If :attr:`output` is ``None``, the document is returned.
583
584        :param output: where the document should be saved, see the :option:`-o`
585            option.
586        :type  output: str or file-like object or ``None``
587        :returns: if output is None: the ODT document ; or else ``None``.
588        """
589        self.compile()
590        if output:
591            document = output
592        else:
593            document = StringIO()
594        self._build_zip(document)
595        shutil.rmtree(self.tmpdir)
596        if not output:
597            return document.getvalue()
598
599
600def log(msg, verbose=False):
601    """
602    Simple method to log if we're in verbose mode (with the :option:`-v`
603    option).
604    """
605    if verbose:
606        sys.stderr.write(msg+"\n")
607
608def get_options():
609    """
610    Parses the command-line options.
611    """
612    usage = "usage: %prog [options] -i input -o output -t template.odt"
613    parser = OptionParser(usage=usage)
614    parser.add_option("--version", dest="version", action="store_true",
615                      help="Show the version and exit")
616    parser.add_option("-i", "--input", dest="input", metavar="FILE",
617                      help="Read the html from this file")
618    parser.add_option("-o", "--output", dest="output", metavar="FILE",
619                      help="Location of the output ODT file")
620    parser.add_option("-t", "--template", dest="template", metavar="FILE",
621                      help="Location of the template ODT file")
622    parser.add_option("-u", "--url", dest="url",
623                      help="Use this URL for relative links")
624    parser.add_option("-v", "--verbose", dest="verbose",
625                      action="store_true", default=False,
626                      help="Show what's going on")
627    parser.add_option("--html-id", dest="htmlid", metavar="ID",
628                      help="Only export from the element with this ID")
629    parser.add_option("--replace", dest="replace_keyword",
630                      default="ODT-INSERT", metavar="KEYWORD",
631                      help="Keyword to replace in the ODT template "
632                      "(default is %default)")
633    parser.add_option("--cut-start", dest="cut_start",
634                      default="ODT-CUT-START", metavar="KEYWORD",
635                      help="Keyword to start cutting text from the ODT "
636                      "template (default is %default)")
637    parser.add_option("--cut-stop", dest="cut_stop",
638                      default="ODT-CUT-STOP", metavar="KEYWORD",
639                      help="Keyword to stop cutting text from the ODT "
640                      "template (default is %default)")
641    parser.add_option("--top-header-level", dest="top_header_level",
642                      type="int", default="1", metavar="LEVEL",
643                      help="Level of highest header in the HTML "
644                      "(default is %default)")
645    parser.add_option("--img-default-width", dest="img_width",
646                      metavar="WIDTH", default="8cm",
647                      help="Default image width (default is %default)")
648    parser.add_option("--img-default-height", dest="img_height",
649                      metavar="HEIGHT", default="6cm",
650                      help="Default image height (default is %default)")
651    parser.add_option("--dpi", dest="img_dpi", type="int",
652                      default=96, metavar="DPI", help="Screen resolution "
653                      "in Dots Per Inch (default is %default)")
654    parser.add_option("--no-network", dest="with_network",
655                      action="store_false", default=True,
656                      help="Do not download remote images")
657    options, args = parser.parse_args()
658    if options.version:
659        print "xhtml2odt %s" % __version__
660        sys.exit(0)
661    if len(args) > 0:
662        parser.error("illegal arguments: %s"% ", ".join(args))
663    if not options.input:
664        parser.error("No input provided")
665    if not options.output:
666        parser.error("No output provided")
667    if not options.template:
668        default_template = os.path.join(INSTALL_PATH, "template.odt")
669        if os.path.exists(default_template):
670            options.template = default_template
671        else:
672            parser.error("No ODT template provided")
673    if not os.path.exists(options.input):
674        parser.error("Can't find input file: %s" % options.input)
675    if not os.path.exists(options.template):
676        parser.error("Can't find template file: %s" % options.template)
677    return options
678
679def main():
680    """
681    Main function, called when the script is invoked on the command line.
682    """
683    options = get_options()
684    try:
685        htmlfile = HTMLFile(options)
686        htmlfile.read()
687        odtfile = ODTFile(options)
688        odtfile.open()
689        odtfile.import_xhtml(htmlfile.html)
690        odtfile.save(options.output)
691    except ODTExportError, ex:
692        print >> sys.stderr, ex
693        print >> sys.stderr, "Conversion failed."
694        sys.exit(1)
695
696if __name__ == '__main__':
697    main()
698
Note: See TracBrowser for help on using the repository browser.