Index: trac/mimeview/api.py
===================================================================
--- trac/mimeview/api.py	(revision 3282)
+++ trac/mimeview/api.py	(working copy)
@@ -23,7 +23,7 @@
 
 from trac.config import IntOption, Option
 from trac.core import *
-from trac.util import to_utf8, to_unicode
+from trac.util import to_utf8, to_unicode, sorted
 from trac.util.markup import escape, Markup, Fragment, html
 
 
@@ -213,11 +213,29 @@
         annotation data."""
 
 
+class IContentConverter(Interface):
+    """An extension point interface for generic MIME based content
+    conversion."""
+
+    def get_conversions():
+        """Return an iterable of tuples in the form (key, name, in_mimetype,
+        out_mimetype, quality) representing the MIME conversions supported and
+        the quality ratio of the conversion in the range 0 to 9, where 0 means
+        no support and 9 means "perfect" support. eg. ('latex', ' LaTeX',
+        'text/x-trac-wiki', 'text/plain', 8)"""
+
+    def convert_content(req, mimetype, content, key, filename=None, url=None):
+        """Convert the given content from mimetype to the output MIME type
+        represented by key. Returns a tuple in the form (content,
+        output_mime_type)."""
+
+
 class Mimeview(Component):
     """A generic class to prettify data, typically source code."""
 
     renderers = ExtensionPoint(IHTMLPreviewRenderer)
     annotators = ExtensionPoint(IHTMLPreviewAnnotator)
+    converters = ExtensionPoint(IContentConverter)
 
     default_charset = Option('trac', 'default_charset', 'iso-8859-15',
         """Charset to be used when in doubt.""")
@@ -230,6 +248,58 @@
 
     # Public API
 
+    def get_supported_conversions(self, mimetype):
+        """Return a list of target MIME types in same form as
+        `IContentConverter.get_conversions()`, but with the converter
+        component appended. Output is ordered from best to worst quality."""
+        converters = []
+        for converter in self.converters:
+            for descriptor in converter.get_conversions():
+                if descriptor[2] == mimetype and descriptor[4] > 0:
+                    converters.append(tuple(descriptor) + (converter,))
+        converters = sorted(converters, key=lambda i: i[2], reverse=True)
+        return converters
+
+    def convert_content(self, req, mimetype, content, key, filename=None,
+                        url=None):
+        """Convert the given content to the target MIME type represented by
+        `key`, which can be either a MIME type or a key. Returns a tuple of
+        (content, output_mime_type)."""
+        if not content:
+            return ('', 'text/plain;charset=utf-8')
+
+        # Ensure we have a MIME type for this content
+        full_mimetype = mimetype
+        if not full_mimetype:
+            if hasattr(content, 'read'):
+                content = content.read(self.get_max_preview_size())
+            full_mimetype = self.get_mimetype(filename, content)
+        if full_mimetype:
+            mimetype = full_mimetype.split(';')[0].strip() # split off charset
+        else:
+            mimetype = full_mimetype = 'text/plain' # fallback if not binary
+
+        # Choose best converter
+        candidates = self.get_supported_conversions(mimetype)
+        candidates = [c for c in candidates if key in (c[0], c[3])]
+        if not candidates:
+            raise TracError('No available MIME conversions from %s to %s' %
+                            (mimetype, key))
+
+        # First candidate which converts successfully wins.
+        for c_key, name, input_mimettype, output_mimetype, quality, converter in candidates:
+            try:
+                output = converter.convert_content(req, mimetype, content,
+                                   c_key, filename, url)
+                if not output:
+                    continue
+                return output
+            except Exception, e:
+                self.log.warning('MIME conversion using %s failed (%s)'
+                                 % (converter, e), exc_info=True)
+        raise TracError('No available MIME conversions from %s to %s' %
+                        (mimetype, key))
+
     def get_annotation_types(self):
         """Generator that returns all available annotation types."""
         for annotator in self.annotators:
Index: trac/ticket/web_ui.py
===================================================================
--- trac/ticket/web_ui.py	(revision 3282)
+++ trac/ticket/web_ui.py	(working copy)
@@ -30,6 +30,7 @@
 from trac.web import IRequestHandler
 from trac.web.chrome import add_link, add_stylesheet, INavigationContributor
 from trac.wiki import wiki_to_html, wiki_to_oneliner
+from trac.mimeview.api import Mimeview
 
 
 class TicketModuleBase(Component):
@@ -255,6 +256,17 @@
 
         self._insert_ticket_data(req, db, ticket, reporter_id)
 
+        format = req.args.get('format')
+        if format:
+            content, output_type = Mimeview(self.env).convert_content(
+                                   req, 'application/x-trac-ticket', ticket,
+                                   format)
+            req.send_response(200)
+            req.send_header('Content-Type', output_type)
+            req.end_headers()
+            req.write(content)
+            return
+
         # If the ticket is being shown in the context of a query, add
         # links to help navigate in the query result set
         if 'query_tickets' in req.session:
@@ -274,6 +286,14 @@
                 add_link(req, 'up', req.session['query_href'])
 
         add_stylesheet(req, 'common/css/ticket.css')
+
+        # Add registered converters
+        for conversion in Mimeview(self.env).get_supported_conversions(
+                                             'application/x-trac-ticket'):
+            conversion_href = req.href.ticket(ticket.id, format=conversion[0])
+            add_link(req, 'alternate', conversion_href, conversion[1],
+                     conversion[3])
+
         return 'ticket.cs', None
 
     # ITimelineEventProvider methods
Index: trac/ticket/query.py
===================================================================
--- trac/ticket/query.py	(revision 3282)
+++ trac/ticket/query.py	(working copy)
@@ -29,8 +29,8 @@
 from trac.web.chrome import add_link, add_stylesheet, INavigationContributor
 from trac.wiki import wiki_to_html, wiki_to_oneliner, IWikiSyntaxProvider
 from trac.wiki.macros import WikiMacroBase
+from trac.mimeview.api import Mimeview, IContentConverter
 
-
 class QuerySyntaxError(Exception):
     """Exception raised when a ticket query cannot be parsed from a string."""
 
@@ -344,8 +344,26 @@
 
 class QueryModule(Component):
 
-    implements(IRequestHandler, INavigationContributor, IWikiSyntaxProvider)
+    implements(IRequestHandler, INavigationContributor, IWikiSyntaxProvider,
+               IContentConverter)
 
+    # IContentConverter methods
+    def get_conversions(self):
+        yield ('rss', 'RSS Feed', 'application/x-trac-query',
+               'application/rss+xml', 9)
+        yield ('csv', 'Comma-delimited Text', 'application/x-trac-query',
+               'text/plain', 9)
+        yield ('tab', 'Tab-delimited Text', 'application/x-trac-query',
+               'text/plain', 9)
+
+    def convert_content(self, req, mimetype, query, key, filename=None, url=None):
+        if key == 'rss':
+            return self.display_rss(req, query)
+        elif key == 'csv':
+            return self.display_csv(query)
+        elif key == 'tab':
+            return self.display_csv(query, '\t')
+
     # INavigationContributor methods
 
     def get_active_navigation_item(self, req):
@@ -392,12 +410,11 @@
                     del req.session[var]
             req.redirect(query.get_href())
 
-        add_link(req, 'alternate', query.get_href(format='rss'), 'RSS Feed',
-                 'application/rss+xml', 'rss')
-        add_link(req, 'alternate', query.get_href(format='csv'),
-                 'Comma-delimited Text', 'text/plain')
-        add_link(req, 'alternate', query.get_href(format='tab'),
-                 'Tab-delimited Text', 'text/plain')
+        # Add registered converters
+        for conversion in Mimeview(self.env).get_supported_conversions(
+                                             'application/x-trac-query'):
+            add_link(req, 'alternate', query.get_href(format=conversion[0]),
+                      conversion[1], conversion[3])
 
         constraints = {}
         for k, v in query.constraints.items():
@@ -415,17 +432,18 @@
         req.hdf['query.constraints'] = constraints
 
         format = req.args.get('format')
-        if format == 'rss':
-            self.display_rss(req, query)
-            return 'query_rss.cs', 'application/rss+xml'
-        elif format == 'csv':
-            self.display_csv(req, query)
-        elif format == 'tab':
-            self.display_csv(req, query, '\t')
-        else:
-            self.display_html(req, query)
-            return 'query.cs', None
+        if format:
+            content, output_type = Mimeview(self.env).convert_content(req,
+                                   'application/x-trac-query', query, format)
+            req.send_response(200)
+            req.send_header('Content-Type', output_type)
+            req.end_headers()
+            req.write(content)
+            return
 
+        self.display_html(req, query)
+        return 'query.cs', None
+
     # Internal methods
 
     def _get_constraints(self, req):
@@ -595,20 +613,18 @@
            self.env.is_component_enabled(ReportModule):
             req.hdf['query.report_href'] = req.href.report()
 
-    def display_csv(self, req, query, sep=','):
-        req.send_response(200)
-        req.send_header('Content-Type', 'text/plain;charset=utf-8')
-        req.end_headers()
-
+    def display_csv(self, query, sep=','):
+        content = StringIO()
         cols = query.get_columns()
-        req.write(sep.join([col for col in cols]) + CRLF)
+        content.write(sep.join([col for col in cols]) + CRLF)
 
         results = query.execute(self.env.get_db_cnx())
         for result in results:
-            req.write(sep.join([unicode(result[col]).replace(sep, '_')
-                                                    .replace('\n', ' ')
-                                                    .replace('\r', ' ')
-                                for col in cols]) + CRLF)
+            content.write(sep.join([unicode(result[col]).replace(sep, '_')
+                                                        .replace('\n', ' ')
+                                                        .replace('\r', ' ')
+                                    for col in cols]) + CRLF)
+        return (content.getvalue(), 'text/plain;charset=utf-8')
 
     def display_rss(self, req, query):
         query.verbose = True
@@ -630,6 +646,7 @@
                 groupdesc=query.groupdesc and 1 or None,
                 verbose=query.verbose and 1 or None,
                 **query.constraints)
+        return (req.hdf.render('query_rss.cs'), 'application/rss+xml')
 
     # IWikiSyntaxProvider methods
     
Index: trac/wiki/web_ui.py
===================================================================
--- trac/wiki/web_ui.py	(revision 3282)
+++ trac/wiki/web_ui.py	(working copy)
@@ -33,15 +33,24 @@
 from trac.wiki.api import IWikiPageManipulator
 from trac.wiki.model import WikiPage
 from trac.wiki.formatter import wiki_to_html, wiki_to_oneliner
+from trac.mimeview.api import Mimeview, IContentConverter
 
 
 class WikiModule(Component):
 
     implements(INavigationContributor, IPermissionRequestor, IRequestHandler,
-               ITimelineEventProvider, ISearchSource)
+               ITimelineEventProvider, ISearchSource, IContentConverter)
 
     page_manipulators = ExtensionPoint(IWikiPageManipulator)
 
+    # IContentConverter methods
+    def get_conversions(self):
+        yield ('txt', 'Plain Text', 'text/x-trac-wiki', 'text/plain', 9)
+
+    def convert_content(self, req, mimetype, content, key, filename=None,
+                        url=None):
+        return (content, 'text/plain;charset=utf-8')
+
     # INavigationContributor methods
 
     def get_active_navigation_item(self, req):
@@ -111,12 +120,16 @@
         elif action == 'history':
             self._render_history(req, db, page)
         else:
-            if req.args.get('format') == 'txt':
+            format = req.args.get('format')
+            if format:
+                content, output_type = Mimeview(self.env).convert_content(req,
+                                       'text/x-trac-wiki', page.text, format)
                 req.send_response(200)
-                req.send_header('Content-Type', 'text/plain;charset=utf-8')
+                req.send_header('Content-Type', output_type)
                 req.end_headers()
-                req.write(page.text)
+                req.write(content)
                 return
+
             self._render_view(req, db, page)
 
         req.hdf['wiki.action'] = action
@@ -362,8 +375,13 @@
             # Ask web spiders to not index old versions
             req.hdf['html.norobots'] = 1
 
-        txt_href = req.href.wiki(page.name, version=version, format='txt')
-        add_link(req, 'alternate', txt_href, 'Plain Text', 'text/plain')
+        # Add registered converters
+        for conversion in Mimeview(self.env).get_supported_conversions(
+                                             'text/x-trac-wiki'):
+            conversion_href = req.href.wiki(page.name, version=version,
+                                            format=conversion[0])
+            add_link(req, 'alternate', conversion_href, conversion[1],
+                     conversion[3])
 
         req.hdf['wiki'] = {'page_name': page.name, 'exists': page.exists,
                            'version': page.version, 'readonly': page.readonly}

