Index: trac/attachment.py
===================================================================
--- trac/attachment.py	(revision 3348)
+++ trac/attachment.py	(working copy)
@@ -432,7 +432,7 @@
             fd.seek(0)
             
             binary = is_binary(str_data)
-            mime_type = mimeview.get_mimetype(attachment.filename, str_data)
+            mimetype = mimeview.get_mimetype(attachment.filename, str_data)
 
             # Eventually send the file directly
             format = req.args.get('format')
@@ -442,30 +442,29 @@
                     # contain malicious code enabling XSS attacks
                     req.send_header('Content-Disposition', 'attachment;' +
                                     'filename=' + attachment.filename)
-                if not mime_type or (self.render_unsafe_content and \
+                if not mimetype or (self.render_unsafe_content and \
                                      not binary and format == 'txt'):
-                    mime_type = 'text/plain'
-                if 'charset=' not in mime_type:
-                    charset = mimeview.get_charset(str_data, mime_type)
-                    mime_type = mime_type + '; charset=' + charset
-                req.send_file(attachment.path, mime_type)
+                    mimetype = 'text/plain'
+                full_mimetype = mimeview.get_mimetype_charset(
+                    attachment.filename, str_data, mimetype)
+                req.send_file(attachment.path, full_mimetype)
 
             # add ''Plain Text'' alternate link if needed
             if self.render_unsafe_content and not binary and \
-               not mime_type.startswith('text/plain'):
+               not mimetype.startswith('text/plain'):
                 plaintext_href = attachment.href(req, format='txt')
                 add_link(req, 'alternate', plaintext_href, 'Plain Text',
-                         mime_type)
+                         mimetype)
 
             # add ''Original Format'' alternate link (always)
             raw_href = attachment.href(req, format='raw')
-            add_link(req, 'alternate', raw_href, 'Original Format', mime_type)
+            add_link(req, 'alternate', raw_href, 'Original Format', mimetype)
 
             self.log.debug("Rendering preview of file %s with mime-type %s"
-                           % (attachment.filename, mime_type))
+                           % (attachment.filename, mimetype))
 
             req.hdf['attachment'] = mimeview.preview_to_hdf(
-                req, fd, os.fstat(fd.fileno()).st_size, mime_type,
+                req, fd, os.fstat(fd.fileno()).st_size, mimetype,
                 attachment.filename, raw_href, annotations=['lineno'])
         finally:
             fd.close()
Index: trac/mimeview/api.py
===================================================================
--- trac/mimeview/api.py	(revision 3349)
+++ trac/mimeview/api.py	(working copy)
@@ -27,8 +27,8 @@
  * taking advantage of existing conventions for the file name
  * examining the file content and applying various heuristics
 
-The module also knows how to convert the file content from one type
-to another type.
+The module also knows about conversions from one data type to another type,
+like conversions to text/html (this is no more a special case).
 
 In some cases, only the `url` pointing to the file's content is actually
 needed, that's why we avoid to read the file's content when it's not needed.
@@ -49,7 +49,8 @@
 
 
 __all__ = ['get_mimetype', 'is_binary', 'detect_unicode', 'Mimeview',
-           'content_to_unicode']
+           'content_to_unicode',
+           'combine_mimetype_charset', 'split_mimetype_charset']
 
 
 # Some common MIME types and their associated keywords and/or file extensions
@@ -153,6 +154,25 @@
                     return 'application/octet-stream'
         return mimetype
 
+def combine_mimetype_charset(mimetype, charset):
+    """Combine the MIME type and charset information in a single string."""
+    if mimetype and charset and not 'charset' in mimetype:
+        return '%s; charset=%s' % (mimetype, charset)
+    else:
+        return mimetype
+
+def split_mimetype_charset(full_mimetype):
+    """Return (mimetype, charset) from the combined information"""
+    mimetype = full_mimetype
+    charset = None
+    idx = full_mimetype.find(';')
+    if idx >= 0:
+        mimetype = full_mimetype[:idx].strip()
+        idx = full_mimetype.find('charset=', idx)
+        if idx >= -1:
+            charset = full_mimetype[idx+8:].strip()
+    return mimetype, charset
+
 def is_binary(data):
     """Detect binary content by checking the first thousand bytes for zeroes.
 
@@ -176,19 +196,20 @@
     else:
         return None
 
+# Deprecated (TODO: remove in 0.11)
+
 def content_to_unicode(env, content, mimetype):
-    """Retrieve an `unicode` object from a `content` to be previewed"""
-    mimeview = Mimeview(env)
-    if hasattr(content, 'read'):
-        content = content.read(mimeview.max_preview_size)
-    return mimeview.to_unicode(content, mimetype)
+    """Retrieve an `unicode` object from a `content` to be previewed.
+    ''Deprecated in 0.10.''
+    """
+    return Mimeview(env).fetch_content(content, mimetype)
 
 
 class IHTMLPreviewRenderer(Interface):
     """Extension point interface for components that add HTML renderers of
     specific content types to the `Mimeview` component.
 
-    (Deprecated)
+    Deprecated in 0.10. Implement `IContentConverter` instead.
     """
 
     # implementing classes should set this property to True if they
@@ -196,31 +217,12 @@
     expand_tabs = False
 
     def get_quality_ratio(mimetype):
-        """Return the level of support this renderer provides for the `content`
-        of the specified MIME type. The return value must be a number between
-        0 and 9, where 0 means no support and 9 means "perfect" support.
-        """
+        """Return the level of support this renderer provides"""
 
     def render(req, mimetype, content, filename=None, url=None):
-        """Render an XHTML preview of the raw `content`.
+        """Render an XHTML preview of the raw `content`."""
 
-        The `content` might be:
-         * a `str` object
-         * an `unicode` string
-         * any object with a `read` method, returning one of the above
 
-        It is assumed that the content will correspond to the given `mimetype`.
-
-        Besides the `content` value, the same content may eventually
-        be available through the `filename` or `url` parameters.
-        This is useful for renderers that embed objects, using <object> or
-        <img> instead of including the content inline.
-        
-        Can return the generated XHTML text as a single string or as an
-        iterable that yields strings. In the latter case, the list will
-        be considered to correspond to lines of text in the original content.
-        """
-
 class IHTMLPreviewAnnotator(Interface):
     """Extension point interface for components that can annotate an XHTML
     representation of file contents with additional information."""
@@ -238,28 +240,67 @@
         annotation data."""
 
 
+class Conversion(object):
+    """A data conversion specification.
+
+    The conversion goes from an `in_type` to an `out_type`.
+    A conversion is identified by a `key`, has a `name` and proposes
+    an `extension` that can be used for storing the converted data in a file.
+
+    The `quality` ratio of the conversion is a number in the range 0 to 9,
+    where 0 means no support and 9 means "perfect" support.
+
+    Finally, `expand_tabs` indicates whether a tab expansion should precede
+    the conversion attempt.
+
+    e.g. Conversion(key='latex', name='LaTeX', extension='tex',
+                    in_type='text/x-trac-wiki', out_type='text/x-tex',
+                    quality=8)
+    """
+
+    def __init__(self, key, name=None, extension='',
+                 in_type=None, out_type='text/html',
+                 quality=1, expand_tabs=False):
+        self.key = key
+        self.name = name or key
+        self.extension = extension
+        self.in_type = in_type
+        self.out_type = out_type
+        self.quality = quality
+        self.expand_tabs = expand_tabs
+
+    def __repr__(self):
+        return '<Conversion "%s" %s -> %s>' % \
+               (self.key, self.in_type, self.out_type) 
+
+
 class IContentConverter(Interface):
     """An extension point interface for generic MIME based content
     conversion."""
 
-    def get_supported_conversions():
-        """Return an iterable of tuples in the form (key, name, extension,
-        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', 'tex',
-        'text/x-trac-wiki', 'text/plain', 8)"""
+    def get_supported_conversions(mimetype):
+        """Check if conversion of `mimetype` is supported by this converter.
 
-    def convert_content(req, mimetype, content, key):
-        """Convert the given content from mimetype to the output MIME type
-        represented by key. Returns a tuple in the form (content,
-        output_mime_type)."""
+        Return an iterable of `Conversion` objects if this is the case.
+        """
 
+    def convert_content(req, conversion, content, filename, url):
+        """Convert the given `content` using the specified `conversion` object.
 
+        If not directly available through the `content` value,
+        the content may be available through the `filename` or `url`
+        arguments.
+        This can be useful for converters that can provide links to objects,
+        instead of having to inline the content.
+
+        Return the converted content.
+        """
+
+
 class Mimeview(Component):
     """A generic class to prettify data, typically source code."""
 
-    renderers = ExtensionPoint(IHTMLPreviewRenderer)
+    renderers = ExtensionPoint(IHTMLPreviewRenderer) # TODO: remove in 0.11
     annotators = ExtensionPoint(IHTMLPreviewAnnotator)
     converters = ExtensionPoint(IContentConverter)
 
@@ -282,60 +323,100 @@
     def __init__(self):
         self._mime_map = None
         
-    # Public API
+    # -- MIME type conversion
+    
+    def get_supported_conversions(self, mimetype, content=None, filename=None):
+        """Return a list of possible conversions for the given `content`.
 
-    def get_supported_conversions(self, mimetype):
-        """Return a list of target MIME types in same form as
-        `IContentConverter.get_supported_conversions()`, but with the converter
-        component appended. Output is ordered from best to worst quality."""
+        The input `mimetype` is inferred from the `content` and/or the
+        `filename`, if not given.
+
+        Return a list of (conversion,converter), ordered from best
+        to worst quality.
+        """
+        # Ensure we have a mimetype and only the mimetype, without the charset
+        if mimetype:
+            mimetype, charset = split_mimetype_charset(mimetype)
+        else:
+            mimetype = self.get_mimetype(filename, content) or 'text/plain'
+
+        # Build list of possible conversions, with their associated converters
         converters = []
         for converter in self.converters:
-            for k, n, e, im, om, q in converter.get_supported_conversions():
-                if im == mimetype and q > 0:
-                    converters.append((k, n, e, im, om, q, converter))
-        converters = sorted(converters, key=lambda i: i[-1], reverse=True)
-        return converters
+            print converter
+            for conversion in converter.get_supported_conversions(mimetype):
+                if conversion.quality > 0:
+                    converters.append((conversion, converter))
 
-    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, extension)."""
+        # ---- Backward compatibility support for IHTMLPreviewRenderer
+        class RendererWrapper(object):
+            def __init__(self, renderer):
+                self.renderer = renderer
+            def convert_content(self, req, conversion, content,
+                                filename=None, url=None):
+                return self.renderer.render(req, conversion.in_type,
+                                            content, filename, url)
+        for renderer in self.renderers:
+            qr = renderer.get_quality_ratio(mimetype)
+            if qr > 0:
+                expand_tabs = getattr(renderer, 'expand_tabs', False)
+                converters.append(
+                    (Conversion(key='', name='', extension=None,
+                                in_type=mimetype, out_type='text/html',
+                                quality=8, expand_tabs=expand_tabs),
+                     RendererWrapper(renderer)))
+        # ---- (to be removed in 0.11)
+
+        return sorted(converters, key=lambda c: c[0].quality, reverse=True)
+
+    def convert_content(self, req, content, mimetype, selector,
+                        filename=None, url=None):
+        """Convert the `content` to targeted MIME type specified by 'selector'.
+
+        The content has the MIME type `mimetype` and the target MIME type
+        is determined by `selector`, which can be either directly the
+        output MIME type or a key identifying the Conversion.
+
+        Returns a tuple of (content, output_mime_type, extension).
+        """
         if not content:
-            return ('', 'text/plain;charset=utf-8')
+            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.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
+        # Ensure we have the mimetype and the charset information
+        print `('cc', filename, content, mimetype)`
+        full_mimetype = self.get_mimetype_charset(filename, content, mimetype)
+        mimetype, charset = split_mimetype_charset(full_mimetype)
 
-        # Choose best converter
-        candidates = self.get_supported_conversions(mimetype)
-        candidates = [c for c in candidates if key in (c[0], c[4])]
+        # Filter the converters of `mimetype` that are matching `selector`
+        candidates = self.get_supported_conversions(mimetype, content, filename)
+        candidates = [c for c in candidates
+                      if selector in (c[0].key, c[0].out_type)]
         if not candidates:
             raise TracError('No available MIME conversions from %s to %s' %
-                            (mimetype, key))
+                            (mimetype, selector))
 
+        tab_expanded = False # we don't want to expand tabs more than once.
+
         # First candidate which converts successfully wins.
-        for ck, name, ext, input_mimettype, output_mimetype, quality, \
-                converter in candidates:
+        for conversion, converter in candidates:
+            if conversion.expand_tabs and not tab_expanded:
+                content = self.fetch_content(content, full_mimetype)
+                content = content.expandtabs(self.tab_width)
+                tab_expanded = True
             try:
-                output = converter.convert_content(req, mimetype, content, ck)
+                output = converter.convert_content(req, conversion, content,
+                                                   filename, url)
                 if not output:
                     continue
-                return (output[0], output[1], ext)
+                return (output[0], output[1], conversion.extension)
             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))
+        raise TracError('No MIME conversions from %s to %s succeeded' %
+                        (mimetype, selector))
 
+    # -- XHTML rendering and annotations (based on the conversion API)
+    
     def get_annotation_types(self):
         """Generator that returns all available annotation types."""
         for annotator in self.annotators:
@@ -343,76 +424,25 @@
 
     def render(self, req, mimetype, content, filename=None, url=None,
                annotations=None):
-        """Render an XHTML preview of the given `content`.
-
-        `content` is the same as an `IHTMLPreviewRenderer.render`'s
-        `content` argument.
-
-        The specified `mimetype` will be used to select the most appropriate
-        `IHTMLPreviewRenderer` implementation available for this MIME type.
-        If not given, the MIME type will be infered from the filename or the
-        content.
-
-        Return a string containing the XHTML text.
+        """Render an XHTML preview of the given `content`, with `annotations`.
         """
-        if not content:
-            return ''
-
-        # Ensure we have a MIME type for this content
-        full_mimetype = mimetype
-        if not full_mimetype:
-            if hasattr(content, 'read'):
-                content = content.read(self.max_preview_size)
-            full_mimetype = self.get_mimetype(filename, content)
-        if full_mimetype:
-            mimetype = full_mimetype.split(';')[0].strip() # split off charset
+        result, output_type, ext = self.convert_content(
+            req, content, mimetype, 'text/html', filename, url)
+        print `result`
+        if isinstance(result, Fragment):
+            return result
+        elif isinstance(result, basestring):
+            return Markup(to_unicode(result))
+        elif annotations:
+            return Markup(self._annotate(result, annotations))
         else:
-            mimetype = full_mimetype = 'text/plain' # fallback if not binary
+            buf = StringIO()
+            buf.write('<div class="code"><pre>')
+            for line in result:
+                buf.write(line + '\n')
+            buf.write('</pre></div>')
+            return Markup(buf.getvalue())
 
-        # Determine candidate `IHTMLPreviewRenderer`s
-        candidates = []
-        for renderer in self.renderers:
-            qr = renderer.get_quality_ratio(mimetype)
-            if qr > 0:
-                candidates.append((qr, renderer))
-        candidates.sort(lambda x,y: cmp(y[0], x[0]))
-
-        # First candidate which renders successfully wins.
-        # Also, we don't want to expand tabs more than once.
-        expanded_content = None
-        for qr, renderer in candidates:
-            try:
-                self.log.debug('Trying to render HTML preview using %s'
-                               % renderer.__class__.__name__)
-                # check if we need to perform a tab expansion
-                rendered_content = content
-                if getattr(renderer, 'expand_tabs', False):
-                    if expanded_content is None:
-                        content = content_to_unicode(self.env, content,
-                                                     full_mimetype)
-                        expanded_content = content.expandtabs(self.tab_width)
-                    rendered_content = expanded_content
-                result = renderer.render(req, full_mimetype, rendered_content,
-                                         filename, url)
-                if not result:
-                    continue
-                elif isinstance(result, Fragment):
-                    return result
-                elif isinstance(result, basestring):
-                    return Markup(to_unicode(result))
-                elif annotations:
-                    return Markup(self._annotate(result, annotations))
-                else:
-                    buf = StringIO()
-                    buf.write('<div class="code"><pre>')
-                    for line in result:
-                        buf.write(line + '\n')
-                    buf.write('</pre></div>')
-                    return Markup(buf.getvalue())
-            except Exception, e:
-                self.log.warning('HTML preview using %s failed (%s)'
-                                 % (renderer, e), exc_info=True)
-
     def _annotate(self, lines, annotations):
         buf = StringIO()
         buf.write('<table class="code"><thead><tr>')
@@ -447,10 +477,8 @@
         buf.write('</tbody></table>')
         return buf.getvalue()
 
-    def get_max_preview_size(self):
-        """Deprecated: use `max_preview_size` attribute directly."""
-        return self.max_preview_size
-
+    # -- MIME type and charset detection
+    
     def get_charset(self, content='', mimetype=None):
         """Infer the character encoding from the `content` or the `mimetype`.
 
@@ -459,26 +487,26 @@
         The charset will be determined using this order:
          * from the charset information present in the `mimetype` argument
          * auto-detection of the charset from the `content`
-         * the configured `default_charset` 
+         * the configured `default_charset`
         """
         if mimetype:
-            ctpos = mimetype.find('charset=')
-            if ctpos >= 0:
-                return mimetype[ctpos + 8:].strip()
+            mimetype, charset = split_mimetype_charset(mimetype)
+            if charset:
+                return charset
         if isinstance(content, str):
             utf = detect_unicode(content)
             if utf is not None:
                 return utf
+        # TODO: ICharsetDetector
         return self.default_charset
 
     def get_mimetype(self, filename, content=None):
         """Infer the MIME type from the `filename` or the `content`.
 
-        `content` is either a `str` or an `unicode` object.
+        `content` is either a `str` or an `unicode` object,
+        or something that can be `read`.
 
-        Return the detected MIME type, augmented by the
-        charset information (i.e. "<mimetype>; charset=..."),
-        or `None` if detection failed.
+        Return the detected MIME type or `None` if detection failed.
         """
         # Extend default extension to MIME type mappings with configured ones
         if not self._mime_map:
@@ -489,21 +517,34 @@
                     for keyword in assocations: # Note: [0] kept on purpose
                         self._mime_map[keyword] = assocations[0]
 
-        mimetype = get_mimetype(filename, content, self._mime_map)
+        # read the content only if there's no other way to get the mimetype
+        if hasattr(content, 'read'):
+            # first try to get the mimetype from the filename only
+            mimetype = get_mimetype(filename, None, self._mime_map)
+            if mimetype:
+                return mimetype
+            content = self.fetch_content(content, mimetype)
+        return get_mimetype(filename, content, self._mime_map)
+
+    def get_mimetype_charset(self, filename, content=None, mimetype=None):
+        """Retrieve combined mimetype and charset information.
+
+        If `mimetype` is given, we check if it provides the needed information,
+        otherwise we try to detect the mimetype and/or the charset.
+        """
+        print `('gmc', filename, content, mimetype)`
         charset = None
+        if not mimetype:
+            mimetype = self.get_mimetype(filename, content)
+        print `('gmc2', mimetype)`
         if mimetype:
+            if 'charset=' in mimetype:
+                return mimetype
             charset = self.get_charset(content, mimetype)
-        if mimetype and charset and not 'charset' in mimetype:
-            mimetype += '; charset=' + charset
-        return mimetype
+        return combine_mimetype_charset(mimetype, charset)
 
-    def to_utf8(self, content, mimetype=None):
-        """Convert an encoded `content` to utf-8.
-
-        ''Deprecated in 0.10. You should use `unicode` strings only.''
-        """
-        return to_utf8(content, self.get_charset(content, mimetype))
-
+    # -- Charset conversion
+    
     def to_unicode(self, content, mimetype=None, charset=None):
         """Convert `content` (an encoded `str` object) to an `unicode` object.
 
@@ -514,8 +555,35 @@
             charset = self.get_charset(content, mimetype)
         return to_unicode(content, charset)
 
+    def fetch_content(self, content, mimetype):
+        if hasattr(content, 'read'):
+            content = content.read(self.max_preview_size)
+        return self.to_unicode(content, mimetype)
+
+    # -- Deprecated API (TODO: remove in 0.11)
+
+    def get_max_preview_size(self):
+        """Deprecated: use `max_preview_size` attribute directly."""
+        return self.max_preview_size
+
+    def to_utf8(self, content, mimetype=None):
+        """Convert an encoded `content` to utf-8.
+
+        ''Deprecated in 0.10. You should use `unicode` strings only.''
+        """
+        return to_utf8(content, self.get_charset(content, mimetype))
+
+    # -- Utilities
+
     def configured_modes_mapping(self, renderer):
-        """Return a MIME type to `(mode,quality)` mapping for given `option`"""
+        """Utility for configurable custom converters
+
+        Return a MIME type to `(mode,quality)` mapping for given `option`,
+        assuming a format of comma-separated <mimetype>:<mode>:<quality>
+        associations.
+
+        See EnscriptConverter and SilverCityConverter.
+        """
         types, option = {}, '%s_modes' % renderer
         for mapping in self.config['mimeviewer'].getlist(option):
             if not mapping:
@@ -543,22 +611,26 @@
                                            url, annotations),
                     'raw_href': url}
 
-    def send_converted(self, req, in_type, content, selector, filename='file'):
+    def send_converted(self, req, content, mimetype, selector,
+                       filename='file'):
         """Helper method for converting `content` and sending it directly.
 
-        `selector` can be either a key or a MIME Type."""
+        `mimetype` is the type of the content.
+        `selector` can be either a key or the expected output MIME Type.
+        """
         from trac.web import RequestDone
-        content, output_type, ext = self.convert_content(req, in_type,
-                                                         content, selector)
+        content, output_type, ext = self.convert_content(
+            req, content, mimetype, selector, filename)
         req.send_response(200)
         req.send_header('Content-Type', output_type)
-        req.send_header('Content-Disposition', 'filename=%s.%s' % (filename,
-                                                                   ext))
+        req.send_header('Content-Disposition', 'filename=%s.%s' % 
+                        (filename, ext))
         req.end_headers()
         req.write(content)
         raise RequestDone        
         
 
+# utility for Mimeview._annotate
 def _html_splitlines(lines):
     """Tracks open and close tags in lines of HTML text and yields lines that
     have no tags spanning more than one line."""
@@ -608,63 +680,81 @@
                                                             number)
 
 
-# -- Default renderers
+# -- Default HTML converters (previously ''renderers'')
 
 class PlainTextRenderer(Component):
-    """HTML preview renderer for plain text, and fallback for any kind of text
-    for which no more specific renderer is available.
+    """Convert text to HTML-escaped text.
+
+    Will be used as a fallback for any kind of text
+    for which no more specific HTML converter is available.
     """
-    implements(IHTMLPreviewRenderer)
+    
+    implements(IContentConverter)
 
-    expand_tabs = True
-
+    # FIXME: make this configurable/reusable somehow (#2672)
     TREAT_AS_BINARY = [
         'application/pdf',
         'application/postscript',
         'application/rtf'
     ]
 
-    def get_quality_ratio(self, mimetype):
+    def get_supported_conversions(self, mimetype):
         if mimetype in self.TREAT_AS_BINARY:
-            return 0
-        return 1
-
-    def render(self, req, mimetype, content, filename=None, url=None):
+            return
+        yield Conversion(key='plain', name='Plain Text', extension='txt',
+                         in_type=mimetype, out_type='text/html',
+                         quality=mimetype=='text/plain' and 8 or 1,
+                         expand_tabs=True)
+        
+    def convert_content(self, req, conversion, content,
+                        filename=None, url=None):
         if is_binary(content):
             self.env.log.debug("Binary data; no preview available")
-            return
+        else:
+            self.env.log.debug("Using default plain text mimeviewer")
+            content = content_to_unicode(self.env, content, mimetype)
+            
+            buf = StringIO()
+            for line in content.splitlines():
+                buf.write(escape(line))
+            return Markup(buf.getvalue())
 
-        self.env.log.debug("Using default plain text mimeviewer")
-        content = content_to_unicode(self.env, content, mimetype)
-        for line in content.splitlines():
-            yield escape(line)
 
-
 class ImageRenderer(Component):
     """Inline image display. Here we don't need the `content` at all."""
-    implements(IHTMLPreviewRenderer)
+    
+    implements(IContentConverter)
 
-    def get_quality_ratio(self, mimetype):
+    def get_supported_conversions(self, mimetype):
         if mimetype.startswith('image/'):
-            return 8
-        return 0
+            yield Conversion(key='image', name='Image', extension=None,
+                             in_type=mimetype, out_type='text/html',
+                             quality=8)
 
-    def render(self, req, mimetype, content, filename=None, url=None):
+    def convert_content(self, req, conversion, content,
+                        filename=None, url=None):
         if url:
-            return html.DIV(html.IMG(src=url,alt=filename),
-                            class_="image-file")
+            return (html.DIV(html.IMG(src=url, alt=filename),
+                             class_="image-file"),
+                    'text/html', conversion.extension)
 
 
 class WikiTextRenderer(Component):
     """Render files containing Trac's own Wiki formatting markup."""
-    implements(IHTMLPreviewRenderer)
 
-    def get_quality_ratio(self, mimetype):
+    implements(IContentConverter)
+
+    def get_supported_conversions(self, mimetype):
         if mimetype in ('text/x-trac-wiki', 'application/x-trac-wiki'):
-            return 8
-        return 0
+            yield Conversion(key='wiki', name='Wiki', extension=None,
+                             in_type=mimetype, out_type='text/html',
+                             quality=8)
 
-    def render(self, req, mimetype, content, filename=None, url=None):
+    def convert_content(self, req, conversion, content,
+                        filename=None, url=None):
         from trac.wiki import wiki_to_html
-        return wiki_to_html(content_to_unicode(self.env, content, mimetype),
-                            self.env, req)
+        return (wiki_to_html(content_to_unicode(self.env, content,
+                                                conversion.in_type),
+                             self.env, req),
+                'text/html', 'txt')
+
Index: trac/ticket/web_ui.py
===================================================================
--- trac/ticket/web_ui.py	(revision 3348)
+++ trac/ticket/web_ui.py	(working copy)
@@ -33,7 +33,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, IContentConverter
+from trac.mimeview.api import Mimeview, IContentConverter, Conversion
 
 
 class TicketModuleBase(Component):
@@ -207,15 +207,22 @@
 
     # IContentConverter methods
 
-    def get_supported_conversions(self):
-        yield ('csv', 'Comma-delimited Text', 'csv',
-               'trac.ticket.Ticket', 'text/csv', 8)
-        yield ('tab', 'Tab-delimited Text', 'tsv',
-               'trac.ticket.Ticket', 'text/tab-separated-values', 8)
-        yield ('rss', 'RSS Feed', 'xml',
-               'trac.ticket.Ticket', 'application/rss+xml', 8)
+    def get_supported_conversions(self, content_type):
+        if content_type == 'trac.ticket.Ticket':
+            yield Conversion('csv', 'Comma-delimited Text', 'csv',
+                             in_type=content_type,
+                             out_type='text/csv', quality=8)
+            yield Conversion('tab', 'Tab-delimited Text', 'tsv',
+                             in_type=content_type,
+                             out_type='text/tab-separated-values',
+                             quality=8)
+            yield Conversion('rss', 'RSS Feed', 'xml',
+                             in_type=content_type,
+                             out_type='application/rss+xml',
+                             quality=8)
 
-    def convert_content(self, req, mimetype, ticket, key):
+    def convert_content(self, req, conversion, ticket, filename, url):
+        key = conversion.key
         if key == 'csv':
             return self.export_csv(ticket, mimetype='text/csv')
         elif key == 'tab':
@@ -282,7 +289,7 @@
         mime = Mimeview(self.env)
         format = req.args.get('format')
         if format:
-            mime.send_converted(req, 'trac.ticket.Ticket', ticket, format,
+            mime.send_converted(req, ticket, 'trac.ticket.Ticket', format,
                                 'ticket_%d' % ticket.id)
 
         # If the ticket is being shown in the context of a query, add
@@ -306,10 +313,11 @@
         add_stylesheet(req, 'common/css/ticket.css')
 
         # Add registered converters
-        for conversion in mime.get_supported_conversions('trac.ticket.Ticket'):
-            conversion_href = req.href.ticket(ticket.id, format=conversion[0])
-            add_link(req, 'alternate', conversion_href, conversion[1],
-                     conversion[3])
+        for conversion, converter in \
+                mime.get_supported_conversions('trac.ticket.Ticket'):
+            conversion_href = req.href.ticket(ticket.id, format=conversion.key)
+            add_link(req, 'alternate', conversion_href, conversion.name,
+                     conversion.out_type)
 
         return 'ticket.cs', None
 
@@ -420,6 +428,7 @@
         return (content.getvalue(), '%s;charset=utf-8' % mimetype)
         
     def export_rss(self, req, ticket):
+        print 'export_rss'
         db = self.env.get_db_cnx()
         changelog = ticket.get_changelog(db=db)
         curr_author = None
@@ -437,6 +446,7 @@
             changes[-1]['title'] = title
 
         for date, author, field, old, new in changelog:
+            print 'changelog'
             if date != curr_date or author != curr_author:
                 update_title()
                 change_summary = {}
@@ -467,6 +477,7 @@
                                                 'new': new}
         update_title()
         req.hdf['ticket.changes'] = changes
+        print 'about to be done'
         return (req.hdf.render('ticket_rss.cs'), 'application/rss+xml')
 
 
Index: trac/ticket/tests/conversion.py
===================================================================
--- trac/ticket/tests/conversion.py	(revision 3348)
+++ trac/ticket/tests/conversion.py	(working copy)
@@ -2,8 +2,9 @@
 from trac.util import sorted
 from trac.ticket.model import Ticket
 from trac.ticket.web_ui import TicketModule
-from trac.mimeview.api import Mimeview
+from trac.mimeview.api import Mimeview, Conversion
 from trac.web.clearsilver import HDFWrapper
+from trac.web.href import Href
 
 import unittest
 
@@ -15,7 +16,9 @@
         self.ticket_module = TicketModule(self.env)
         self.mimeview = Mimeview(self.env)
         self.req = Mock(hdf=HDFWrapper(['./templates']),
-                        base_path='/trac.cgi', path_info='')
+                        base_path='/trac.cgi', path_info='',
+                        href=Href('/trac.cgi'),
+                        abs_href=Href('http://example.org/trac.cgi'))
 
     def _create_a_ticket(self):
         # 1. Creating ticket
@@ -27,24 +30,32 @@
         return ticket
 
     def test_conversions(self):
-        conversions = self.mimeview.get_supported_conversions(
-            'trac.ticket.Ticket')
-        expected = sorted([('csv', 'Comma-delimited Text', 'csv',
-                           'trac.ticket.Ticket', 'text/csv', 8,
-                           self.ticket_module),
-                          ('tab', 'Tab-delimited Text', 'tsv',
-                           'trac.ticket.Ticket', 'text/tab-separated-values', 8,
-                           self.ticket_module),
-                           ('rss', 'RSS Feed', 'xml',
-                            'trac.ticket.Ticket', 'application/rss+xml', 8,
+        conversions = self.mimeview\
+                      .get_supported_conversions('trac.ticket.Ticket')
+        expected = sorted([(Conversion('csv', 'Comma-delimited Text', 'csv',
+                                       'trac.ticket.Ticket', 'text/csv', 8),
+                            self.ticket_module),
+                           (Conversion('tab', 'Tab-delimited Text', 'tsv',
+                                       'trac.ticket.Ticket',
+                                       'text/tab-separated-values', 8),
+                            self.ticket_module),
+                           (Conversion('rss', 'RSS Feed', 'xml',
+                                       'trac.ticket.Ticket',
+                                       'application/rss+xml', 8),
                             self.ticket_module)],
                           key=lambda i: i[-1], reverse=True)
-        self.assertEqual(expected, conversions)
+        for expected, actual in zip(expected, conversions):
+            self.assertEqual(expected[1], actual[1])
+            for attr in ('key', 'name', 'extension', 'in_type', 'out_type',
+                         'quality', 'expand_tabs'):
+                self.assertEqual(getattr(expected[0], attr),
+                                 getattr(actual[0], attr))
 
     def test_csv_conversion(self):
         ticket = self._create_a_ticket()
-        csv = self.mimeview.convert_content(self.req, 'trac.ticket.Ticket',
-                                            ticket, 'csv')
+        csv = self.mimeview.convert_content(self.req,
+                                            ticket, 'trac.ticket.Ticket',
+                                            'csv')
         self.assertEqual((u'id,summary,reporter,owner,description,keywords,cc'
                           '\r\nNone,Foo,santa,,Bar,,\r\n',
                           'text/csv;charset=utf-8', 'csv'), csv)
@@ -52,8 +63,9 @@
 
     def test_tab_conversion(self):
         ticket = self._create_a_ticket()
-        csv = self.mimeview.convert_content(self.req, 'trac.ticket.Ticket',
-                                            ticket, 'tab')
+        csv = self.mimeview.convert_content(self.req,
+                                            ticket, 'trac.ticket.Ticket',
+                                            'tab')
         self.assertEqual((u'id\tsummary\treporter\towner\tdescription\tkeywords'
                           '\tcc\r\nNone\tFoo\tsanta\t\tBar\t\t\r\n',
                           'text/tab-separated-values;charset=utf-8', 'tsv'),
@@ -63,7 +75,7 @@
         ticket = self._create_a_ticket()
         ticket.insert()
         content, mimetype, ext = self.mimeview.convert_content(
-            self.req, 'trac.ticket.Ticket', ticket, 'rss')
+            self.req, ticket, 'trac.ticket.Ticket', 'rss')
         self.assertEqual(('<?xml version="1.0"?>\n<!-- RSS generated by Trac v '
                           'on  -->\n<rss version="2.0">\n <channel>\n   '
                           '<title>Ticket </title>\n  <link></link>\n  '
Index: trac/ticket/query.py
===================================================================
--- trac/ticket/query.py	(revision 3348)
+++ trac/ticket/query.py	(working copy)
@@ -349,15 +349,21 @@
                IContentConverter)
 
     # IContentConverter methods
-    def get_supported_conversions(self):
-        yield ('rss', 'RSS Feed', 'xml',
-               'trac.ticket.Query', 'application/rss+xml', 8)
-        yield ('csv', 'Comma-delimited Text', 'csv',
-               'trac.ticket.Query', 'text/csv', 8)
-        yield ('tab', 'Tab-delimited Text', 'tsv',
-               'trac.ticket.Query', 'text/tab-separated-values', 8)
+    def get_supported_conversions(self, content_type):
+        if content_type == 'trac.ticket.Query':
+            yield Conversion('rss', 'RSS Feed', 'xml',
+                             in_type=content_type,
+                             out_type='application/rss+xml', quality=8)
+            yield Conversion('csv', 'Comma-delimited Text', 'csv',
+                             in_type=content_type,
+                             out_type='text/csv', quality=8)
+            yield Conversion('tab', 'Tab-delimited Text', 'tsv',
+                             in_type=content_type,
+                             out_type='text/tab-separated-values',
+                             quality=8)
 
-    def convert_content(self, req, mimetype, query, key):
+    def convert_content(self, req, conversion, query, filename=None, url=None):
+        key = conversion.key
         if key == 'rss':
             return self.export_rss(req, query)
         elif key == 'csv':
@@ -412,10 +418,10 @@
             req.redirect(query.get_href())
 
         # Add registered converters
-        for conversion in Mimeview(self.env).get_supported_conversions(
-                                             'trac.ticket.Query'):
-            add_link(req, 'alternate', query.get_href(format=conversion[0]),
-                      conversion[1], conversion[3])
+        for conversion, converter in \
+                Mimeview(self.env).get_supported_conversions('trac.ticket.Query'):
+            add_link(req, 'alternate', query.get_href(format=conversion.key),
+                     conversion.name, conversion.out_type)
 
         constraints = {}
         for k, v in query.constraints.items():
@@ -434,7 +440,7 @@
 
         format = req.args.get('format')
         if format:
-            Mimeview(self.env).send_converted(req, 'trac.ticket.Query', query,
+            Mimeview(self.env).send_converted(req, query, 'trac.ticket.Query',
                                               format, 'query')
 
         self.display_html(req, query)
Index: trac/versioncontrol/web_ui/browser.py
===================================================================
--- trac/versioncontrol/web_ui/browser.py	(revision 3348)
+++ trac/versioncontrol/web_ui/browser.py	(working copy)
@@ -23,7 +23,7 @@
 from trac import util
 from trac.config import ListOption, Option
 from trac.core import *
-from trac.mimeview import Mimeview, is_binary, get_mimetype
+from trac.mimeview import Mimeview, is_binary
 from trac.perm import IPermissionRequestor
 from trac.util import sorted, embedded_numbers
 from trac.util.datefmt import http_date, format_datetime, pretty_timedelta
@@ -206,22 +206,26 @@
         # MIME type detection
         content = node.get_content()
         chunk = content.read(CHUNK_SIZE)
-        mime_type = node.content_type
-        if not mime_type or mime_type == 'application/octet-stream':
-            mime_type = mimeview.get_mimetype(node.name, chunk) or \
-                        mime_type or 'text/plain'
+        mimetype = node.content_type
+        charset = None
+        if not mimetype or mimetype == 'application/octet-stream':
+            mimetype, charset = mimeview.get_mimetype_charset(
+                node.name, chunk, mimetype) or \
+                (mimetype, None) or ('text/plain', None)
+        full_mimetype = combine_mimetype_charset(mimetype, charset)
 
         # Eventually send the file directly
         format = req.args.get('format')
-        if format in ['raw', 'txt']:
+        if format in ('raw', 'txt'):
+            if format == 'txt':
+                full_mimetype = combine_mimetype_charset('text/plain', charset)
             req.send_response(200)
-            req.send_header('Content-Type',
-                            format == 'txt' and 'text/plain' or mime_type)
+            req.send_header('Content-Type', full_mimetype)
             req.send_header('Content-Length', node.content_length)
             req.send_header('Last-Modified', http_date(node.last_modified))
             req.end_headers()
 
-            while 1:
+            while True:
                 if not chunk:
                     raise RequestDone
                 req.write(chunk)
@@ -248,22 +252,22 @@
             } 
 
             # add ''Plain Text'' alternate link if needed
-            if not is_binary(chunk) and mime_type != 'text/plain':
+            if not is_binary(chunk) and mimetype != 'text/plain':
                 plain_href = req.href.browser(node.path, rev=rev, format='txt')
                 add_link(req, 'alternate', plain_href, 'Plain Text',
                          'text/plain')
 
             # add ''Original Format'' alternate link (always)
             raw_href = req.href.browser(node.path, rev=rev, format='raw')
-            add_link(req, 'alternate', raw_href, 'Original Format', mime_type)
+            add_link(req, 'alternate', raw_href, 'Original Format', mimetype)
 
             self.log.debug("Rendering preview of node %s@%s with mime-type %s"
-                           % (node.name, str(rev), mime_type))
+                           % (node.name, str(rev), mimetype))
 
             del content # the remainder of that content is not needed
 
             req.hdf['file'] = mimeview.preview_to_hdf(
-                req, node.get_content(), node.get_content_length(), mime_type,
+                req, node.get_content(), node.get_content_length(), mimetype,
                 node.created_path, raw_href, annotations=['lineno'])
 
             add_stylesheet(req, 'common/css/code.css')
Index: trac/wiki/web_ui.py
===================================================================
--- trac/wiki/web_ui.py	(revision 3348)
+++ trac/wiki/web_ui.py	(working copy)
@@ -34,7 +34,7 @@
 from trac.wiki.api import IWikiPageManipulator, WikiSystem
 from trac.wiki.model import WikiPage
 from trac.wiki.formatter import wiki_to_html, wiki_to_oneliner
-from trac.mimeview.api import Mimeview, IContentConverter
+from trac.mimeview.api import Mimeview, IContentConverter, Conversion
 
 
 class WikiModule(Component):
@@ -45,11 +45,14 @@
     page_manipulators = ExtensionPoint(IWikiPageManipulator)
 
     # IContentConverter methods
-    def get_supported_conversions(self):
-        yield ('txt', 'Plain Text', 'txt', 'text/x-trac-wiki', 'text/plain', 9)
+    def get_supported_conversions(self, mimetype):
+        if mimetype == 'text/x-trac-wiki':
+            yield Conversion('txt', 'Plain Text', 'txt',
+                             in_type=mimetype, out_type='text/plain',
+                             quality=9)
 
-    def convert_content(self, req, mimetype, content, key):
-        return (content, 'text/plain;charset=utf-8')
+    def convert_content(self, req, conversion, content, filename, url):
+        return (content, 'text/plain; charset=utf-8', 'txt')
 
     # INavigationContributor methods
 
@@ -122,8 +125,8 @@
         else:
             format = req.args.get('format')
             if format:
-                Mimeview(self.env).send_converted(req, 'text/x-trac-wiki',
-                                                  page.text, format, page.name)
+                Mimeview(self.env).send_converted(
+                    req, page.text, 'text/x-trac-wiki', format, page.name)
             self._render_view(req, db, page)
 
         req.hdf['wiki.action'] = action
@@ -379,12 +382,12 @@
             req.hdf['html.norobots'] = 1
 
         # Add registered converters
-        for conversion in Mimeview(self.env).get_supported_conversions(
-                                             'text/x-trac-wiki'):
+        for conversion, converter 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])
+                                            format=conversion.key)
+            add_link(req, 'alternate', conversion_href, conversion.name,
+                     conversion.out_type)
 
         req.hdf['wiki'] = {'exists': page.exists,
                            'version': page.version, 'readonly': page.readonly}
Index: trac/test.py
===================================================================
--- trac/test.py	(revision 3348)
+++ trac/test.py	(working copy)
@@ -130,7 +130,7 @@
         self.config = Configuration(None)
 
         from trac.log import logger_factory
-        self.log = logger_factory('test')
+        self.log = logger_factory('stderr')
 
         from trac.web.href import Href
         self.href = Href('/trac.cgi')
Index: trac/web/chrome.py
===================================================================
--- trac/web/chrome.py	(revision 3348)
+++ trac/web/chrome.py	(working copy)
@@ -17,10 +17,10 @@
 import os
 import re
 
-from trac import mimeview
 from trac.config import *
 from trac.core import *
 from trac.env import IEnvironmentSetupParticipant
+from trac.mimeview import get_mimetype
 from trac.util.markup import html
 from trac.web.api import IRequestHandler, HTTPNotFound
 from trac.web.href import Href
@@ -282,7 +282,7 @@
                     icon = href.chrome(icon)
                 else:
                     icon = href.chrome('common', icon)
-            mimetype = mimeview.get_mimetype(icon)
+            mimetype = get_mimetype(icon)
             add_link(req, 'icon', icon, mimetype=mimetype)
             add_link(req, 'shortcut icon', icon, mimetype=mimetype)
 
