Edgewall Software

ChristianBoos: mimeview_conversion.diff

File mimeview_conversion.diff, 50.0 KB (added by cboos, 6 years ago)

Work in progress -- IContentConverter and IHTMLPreviewRenderer merge

  • trac/attachment.py

     
    432432            fd.seek(0) 
    433433             
    434434            binary = is_binary(str_data) 
    435             mime_type = mimeview.get_mimetype(attachment.filename, str_data) 
     435            mimetype = mimeview.get_mimetype(attachment.filename, str_data) 
    436436 
    437437            # Eventually send the file directly 
    438438            format = req.args.get('format') 
     
    442442                    # contain malicious code enabling XSS attacks 
    443443                    req.send_header('Content-Disposition', 'attachment;' + 
    444444                                    'filename=' + attachment.filename) 
    445                 if not mime_type or (self.render_unsafe_content and \ 
     445                if not mimetype or (self.render_unsafe_content and \ 
    446446                                     not binary and format == 'txt'): 
    447                     mime_type = 'text/plain' 
    448                 if 'charset=' not in mime_type: 
    449                     charset = mimeview.get_charset(str_data, mime_type) 
    450                     mime_type = mime_type + '; charset=' + charset 
    451                 req.send_file(attachment.path, mime_type) 
     447                    mimetype = 'text/plain' 
     448                full_mimetype = mimeview.get_mimetype_charset( 
     449                    attachment.filename, str_data, mimetype) 
     450                req.send_file(attachment.path, full_mimetype) 
    452451 
    453452            # add ''Plain Text'' alternate link if needed 
    454453            if self.render_unsafe_content and not binary and \ 
    455                not mime_type.startswith('text/plain'): 
     454               not mimetype.startswith('text/plain'): 
    456455                plaintext_href = attachment.href(req, format='txt') 
    457456                add_link(req, 'alternate', plaintext_href, 'Plain Text', 
    458                          mime_type) 
     457                         mimetype) 
    459458 
    460459            # add ''Original Format'' alternate link (always) 
    461460            raw_href = attachment.href(req, format='raw') 
    462             add_link(req, 'alternate', raw_href, 'Original Format', mime_type) 
     461            add_link(req, 'alternate', raw_href, 'Original Format', mimetype) 
    463462 
    464463            self.log.debug("Rendering preview of file %s with mime-type %s" 
    465                            % (attachment.filename, mime_type)) 
     464                           % (attachment.filename, mimetype)) 
    466465 
    467466            req.hdf['attachment'] = mimeview.preview_to_hdf( 
    468                 req, fd, os.fstat(fd.fileno()).st_size, mime_type, 
     467                req, fd, os.fstat(fd.fileno()).st_size, mimetype, 
    469468                attachment.filename, raw_href, annotations=['lineno']) 
    470469        finally: 
    471470            fd.close() 
  • trac/mimeview/api.py

     
    2727 * taking advantage of existing conventions for the file name 
    2828 * examining the file content and applying various heuristics 
    2929 
    30 The module also knows how to convert the file content from one type 
    31 to another type. 
     30The module also knows about conversions from one data type to another type, 
     31like conversions to text/html (this is no more a special case). 
    3232 
    3333In some cases, only the `url` pointing to the file's content is actually 
    3434needed, that's why we avoid to read the file's content when it's not needed. 
     
    4949 
    5050 
    5151__all__ = ['get_mimetype', 'is_binary', 'detect_unicode', 'Mimeview', 
    52            'content_to_unicode'] 
     52           'content_to_unicode', 
     53           'combine_mimetype_charset', 'split_mimetype_charset'] 
    5354 
    5455 
    5556# Some common MIME types and their associated keywords and/or file extensions 
     
    153154                    return 'application/octet-stream' 
    154155        return mimetype 
    155156 
     157def combine_mimetype_charset(mimetype, charset): 
     158    """Combine the MIME type and charset information in a single string.""" 
     159    if mimetype and charset and not 'charset' in mimetype: 
     160        return '%s; charset=%s' % (mimetype, charset) 
     161    else: 
     162        return mimetype 
     163 
     164def split_mimetype_charset(full_mimetype): 
     165    """Return (mimetype, charset) from the combined information""" 
     166    mimetype = full_mimetype 
     167    charset = None 
     168    idx = full_mimetype.find(';') 
     169    if idx >= 0: 
     170        mimetype = full_mimetype[:idx].strip() 
     171        idx = full_mimetype.find('charset=', idx) 
     172        if idx >= -1: 
     173            charset = full_mimetype[idx+8:].strip() 
     174    return mimetype, charset 
     175 
    156176def is_binary(data): 
    157177    """Detect binary content by checking the first thousand bytes for zeroes. 
    158178 
     
    176196    else: 
    177197        return None 
    178198 
     199# Deprecated (TODO: remove in 0.11) 
     200 
    179201def content_to_unicode(env, content, mimetype): 
    180     """Retrieve an `unicode` object from a `content` to be previewed""" 
    181     mimeview = Mimeview(env) 
    182     if hasattr(content, 'read'): 
    183         content = content.read(mimeview.max_preview_size) 
    184     return mimeview.to_unicode(content, mimetype) 
     202    """Retrieve an `unicode` object from a `content` to be previewed. 
     203    ''Deprecated in 0.10.'' 
     204    """ 
     205    return Mimeview(env).fetch_content(content, mimetype) 
    185206 
    186207 
    187208class IHTMLPreviewRenderer(Interface): 
    188209    """Extension point interface for components that add HTML renderers of 
    189210    specific content types to the `Mimeview` component. 
    190211 
    191     (Deprecated) 
     212    Deprecated in 0.10. Implement `IContentConverter` instead. 
    192213    """ 
    193214 
    194215    # implementing classes should set this property to True if they 
     
    196217    expand_tabs = False 
    197218 
    198219    def get_quality_ratio(mimetype): 
    199         """Return the level of support this renderer provides for the `content` 
    200         of the specified MIME type. The return value must be a number between 
    201         0 and 9, where 0 means no support and 9 means "perfect" support. 
    202         """ 
     220        """Return the level of support this renderer provides""" 
    203221 
    204222    def render(req, mimetype, content, filename=None, url=None): 
    205         """Render an XHTML preview of the raw `content`. 
     223        """Render an XHTML preview of the raw `content`.""" 
    206224 
    207         The `content` might be: 
    208          * a `str` object 
    209          * an `unicode` string 
    210          * any object with a `read` method, returning one of the above 
    211225 
    212         It is assumed that the content will correspond to the given `mimetype`. 
    213  
    214         Besides the `content` value, the same content may eventually 
    215         be available through the `filename` or `url` parameters. 
    216         This is useful for renderers that embed objects, using <object> or 
    217         <img> instead of including the content inline. 
    218          
    219         Can return the generated XHTML text as a single string or as an 
    220         iterable that yields strings. In the latter case, the list will 
    221         be considered to correspond to lines of text in the original content. 
    222         """ 
    223  
    224226class IHTMLPreviewAnnotator(Interface): 
    225227    """Extension point interface for components that can annotate an XHTML 
    226228    representation of file contents with additional information.""" 
     
    238240        annotation data.""" 
    239241 
    240242 
     243class Conversion(object): 
     244    """A data conversion specification. 
     245 
     246    The conversion goes from an `in_type` to an `out_type`. 
     247    A conversion is identified by a `key`, has a `name` and proposes 
     248    an `extension` that can be used for storing the converted data in a file. 
     249 
     250    The `quality` ratio of the conversion is a number in the range 0 to 9, 
     251    where 0 means no support and 9 means "perfect" support. 
     252 
     253    Finally, `expand_tabs` indicates whether a tab expansion should precede 
     254    the conversion attempt. 
     255 
     256    e.g. Conversion(key='latex', name='LaTeX', extension='tex', 
     257                    in_type='text/x-trac-wiki', out_type='text/x-tex', 
     258                    quality=8) 
     259    """ 
     260 
     261    def __init__(self, key, name=None, extension='', 
     262                 in_type=None, out_type='text/html', 
     263                 quality=1, expand_tabs=False): 
     264        self.key = key 
     265        self.name = name or key 
     266        self.extension = extension 
     267        self.in_type = in_type 
     268        self.out_type = out_type 
     269        self.quality = quality 
     270        self.expand_tabs = expand_tabs 
     271 
     272    def __repr__(self): 
     273        return '<Conversion "%s" %s -> %s>' % \ 
     274               (self.key, self.in_type, self.out_type)  
     275 
     276 
    241277class IContentConverter(Interface): 
    242278    """An extension point interface for generic MIME based content 
    243279    conversion.""" 
    244280 
    245     def get_supported_conversions(): 
    246         """Return an iterable of tuples in the form (key, name, extension, 
    247         in_mimetype, out_mimetype, quality) representing the MIME conversions 
    248         supported and 
    249         the quality ratio of the conversion in the range 0 to 9, where 0 means 
    250         no support and 9 means "perfect" support. eg. ('latex', 'LaTeX', 'tex', 
    251         'text/x-trac-wiki', 'text/plain', 8)""" 
     281    def get_supported_conversions(mimetype): 
     282        """Check if conversion of `mimetype` is supported by this converter. 
    252283 
    253     def convert_content(req, mimetype, content, key): 
    254         """Convert the given content from mimetype to the output MIME type 
    255         represented by key. Returns a tuple in the form (content, 
    256         output_mime_type).""" 
     284        Return an iterable of `Conversion` objects if this is the case. 
     285        """ 
    257286 
     287    def convert_content(req, conversion, content, filename, url): 
     288        """Convert the given `content` using the specified `conversion` object. 
    258289 
     290        If not directly available through the `content` value, 
     291        the content may be available through the `filename` or `url` 
     292        arguments. 
     293        This can be useful for converters that can provide links to objects, 
     294        instead of having to inline the content. 
     295 
     296        Return the converted content. 
     297        """ 
     298 
     299 
    259300class Mimeview(Component): 
    260301    """A generic class to prettify data, typically source code.""" 
    261302 
    262     renderers = ExtensionPoint(IHTMLPreviewRenderer) 
     303    renderers = ExtensionPoint(IHTMLPreviewRenderer) # TODO: remove in 0.11 
    263304    annotators = ExtensionPoint(IHTMLPreviewAnnotator) 
    264305    converters = ExtensionPoint(IContentConverter) 
    265306 
     
    282323    def __init__(self): 
    283324        self._mime_map = None 
    284325         
    285     # Public API 
     326    # -- MIME type conversion 
     327     
     328    def get_supported_conversions(self, mimetype, content=None, filename=None): 
     329        """Return a list of possible conversions for the given `content`. 
    286330 
    287     def get_supported_conversions(self, mimetype): 
    288         """Return a list of target MIME types in same form as 
    289         `IContentConverter.get_supported_conversions()`, but with the converter 
    290         component appended. Output is ordered from best to worst quality.""" 
     331        The input `mimetype` is inferred from the `content` and/or the 
     332        `filename`, if not given. 
     333 
     334        Return a list of (conversion,converter), ordered from best 
     335        to worst quality. 
     336        """ 
     337        # Ensure we have a mimetype and only the mimetype, without the charset 
     338        if mimetype: 
     339            mimetype, charset = split_mimetype_charset(mimetype) 
     340        else: 
     341            mimetype = self.get_mimetype(filename, content) or 'text/plain' 
     342 
     343        # Build list of possible conversions, with their associated converters 
    291344        converters = [] 
    292345        for converter in self.converters: 
    293             for k, n, e, im, om, q in converter.get_supported_conversions(): 
    294                 if im == mimetype and q > 0: 
    295                     converters.append((k, n, e, im, om, q, converter)) 
    296         converters = sorted(converters, key=lambda i: i[-1], reverse=True) 
    297         return converters 
     346            print converter 
     347            for conversion in converter.get_supported_conversions(mimetype): 
     348                if conversion.quality > 0: 
     349                    converters.append((conversion, converter)) 
    298350 
    299     def convert_content(self, req, mimetype, content, key, filename=None, 
    300                         url=None): 
    301         """Convert the given content to the target MIME type represented by 
    302         `key`, which can be either a MIME type or a key. Returns a tuple of 
    303         (content, output_mime_type, extension).""" 
     351        # ---- Backward compatibility support for IHTMLPreviewRenderer 
     352        class RendererWrapper(object): 
     353            def __init__(self, renderer): 
     354                self.renderer = renderer 
     355            def convert_content(self, req, conversion, content, 
     356                                filename=None, url=None): 
     357                return self.renderer.render(req, conversion.in_type, 
     358                                            content, filename, url) 
     359        for renderer in self.renderers: 
     360            qr = renderer.get_quality_ratio(mimetype) 
     361            if qr > 0: 
     362                expand_tabs = getattr(renderer, 'expand_tabs', False) 
     363                converters.append( 
     364                    (Conversion(key='', name='', extension=None, 
     365                                in_type=mimetype, out_type='text/html', 
     366                                quality=8, expand_tabs=expand_tabs), 
     367                     RendererWrapper(renderer))) 
     368        # ---- (to be removed in 0.11) 
     369 
     370        return sorted(converters, key=lambda c: c[0].quality, reverse=True) 
     371 
     372    def convert_content(self, req, content, mimetype, selector, 
     373                        filename=None, url=None): 
     374        """Convert the `content` to targeted MIME type specified by 'selector'. 
     375 
     376        The content has the MIME type `mimetype` and the target MIME type 
     377        is determined by `selector`, which can be either directly the 
     378        output MIME type or a key identifying the Conversion. 
     379 
     380        Returns a tuple of (content, output_mime_type, extension). 
     381        """ 
    304382        if not content: 
    305             return ('', 'text/plain;charset=utf-8') 
     383            return ('', 'text/plain; charset=utf-8', '') 
    306384 
    307         # Ensure we have a MIME type for this content 
    308         full_mimetype = mimetype 
    309         if not full_mimetype: 
    310             if hasattr(content, 'read'): 
    311                 content = content.read(self.max_preview_size) 
    312             full_mimetype = self.get_mimetype(filename, content) 
    313         if full_mimetype: 
    314             mimetype = full_mimetype.split(';')[0].strip() # split off charset 
    315         else: 
    316             mimetype = full_mimetype = 'text/plain' # fallback if not binary 
     385        # Ensure we have the mimetype and the charset information 
     386        print `('cc', filename, content, mimetype)` 
     387        full_mimetype = self.get_mimetype_charset(filename, content, mimetype) 
     388        mimetype, charset = split_mimetype_charset(full_mimetype) 
    317389 
    318         # Choose best converter 
    319         candidates = self.get_supported_conversions(mimetype) 
    320         candidates = [c for c in candidates if key in (c[0], c[4])] 
     390        # Filter the converters of `mimetype` that are matching `selector` 
     391        candidates = self.get_supported_conversions(mimetype, content, filename) 
     392        candidates = [c for c in candidates 
     393                      if selector in (c[0].key, c[0].out_type)] 
    321394        if not candidates: 
    322395            raise TracError('No available MIME conversions from %s to %s' % 
    323                             (mimetype, key)) 
     396                            (mimetype, selector)) 
    324397 
     398        tab_expanded = False # we don't want to expand tabs more than once. 
     399 
    325400        # First candidate which converts successfully wins. 
    326         for ck, name, ext, input_mimettype, output_mimetype, quality, \ 
    327                 converter in candidates: 
     401        for conversion, converter in candidates: 
     402            if conversion.expand_tabs and not tab_expanded: 
     403                content = self.fetch_content(content, full_mimetype) 
     404                content = content.expandtabs(self.tab_width) 
     405                tab_expanded = True 
    328406            try: 
    329                 output = converter.convert_content(req, mimetype, content, ck) 
     407                output = converter.convert_content(req, conversion, content, 
     408                                                   filename, url) 
    330409                if not output: 
    331410                    continue 
    332                 return (output[0], output[1], ext) 
     411                return (output[0], output[1], conversion.extension) 
    333412            except Exception, e: 
    334413                self.log.warning('MIME conversion using %s failed (%s)' 
    335414                                 % (converter, e), exc_info=True) 
    336         raise TracError('No available MIME conversions from %s to %s' % 
    337                         (mimetype, key)) 
     415        raise TracError('No MIME conversions from %s to %s succeeded' % 
     416                        (mimetype, selector)) 
    338417 
     418    # -- XHTML rendering and annotations (based on the conversion API) 
     419     
    339420    def get_annotation_types(self): 
    340421        """Generator that returns all available annotation types.""" 
    341422        for annotator in self.annotators: 
     
    343424 
    344425    def render(self, req, mimetype, content, filename=None, url=None, 
    345426               annotations=None): 
    346         """Render an XHTML preview of the given `content`. 
    347  
    348         `content` is the same as an `IHTMLPreviewRenderer.render`'s 
    349         `content` argument. 
    350  
    351         The specified `mimetype` will be used to select the most appropriate 
    352         `IHTMLPreviewRenderer` implementation available for this MIME type. 
    353         If not given, the MIME type will be infered from the filename or the 
    354         content. 
    355  
    356         Return a string containing the XHTML text. 
     427        """Render an XHTML preview of the given `content`, with `annotations`. 
    357428        """ 
    358         if not content: 
    359             return '' 
    360  
    361         # Ensure we have a MIME type for this content 
    362         full_mimetype = mimetype 
    363         if not full_mimetype: 
    364             if hasattr(content, 'read'): 
    365                 content = content.read(self.max_preview_size) 
    366             full_mimetype = self.get_mimetype(filename, content) 
    367         if full_mimetype: 
    368             mimetype = full_mimetype.split(';')[0].strip() # split off charset 
     429        result, output_type, ext = self.convert_content( 
     430            req, content, mimetype, 'text/html', filename, url) 
     431        print `result` 
     432        if isinstance(result, Fragment): 
     433            return result 
     434        elif isinstance(result, basestring): 
     435            return Markup(to_unicode(result)) 
     436        elif annotations: 
     437            return Markup(self._annotate(result, annotations)) 
    369438        else: 
    370             mimetype = full_mimetype = 'text/plain' # fallback if not binary 
     439            buf = StringIO() 
     440            buf.write('<div class="code"><pre>') 
     441            for line in result: 
     442                buf.write(line + '\n') 
     443            buf.write('</pre></div>') 
     444            return Markup(buf.getvalue()) 
    371445 
    372         # Determine candidate `IHTMLPreviewRenderer`s 
    373         candidates = [] 
    374         for renderer in self.renderers: 
    375             qr = renderer.get_quality_ratio(mimetype) 
    376             if qr > 0: 
    377                 candidates.append((qr, renderer)) 
    378         candidates.sort(lambda x,y: cmp(y[0], x[0])) 
    379  
    380         # First candidate which renders successfully wins. 
    381         # Also, we don't want to expand tabs more than once. 
    382         expanded_content = None 
    383         for qr, renderer in candidates: 
    384             try: 
    385                 self.log.debug('Trying to render HTML preview using %s' 
    386                                % renderer.__class__.__name__) 
    387                 # check if we need to perform a tab expansion 
    388                 rendered_content = content 
    389                 if getattr(renderer, 'expand_tabs', False): 
    390                     if expanded_content is None: 
    391                         content = content_to_unicode(self.env, content, 
    392                                                      full_mimetype) 
    393                         expanded_content = content.expandtabs(self.tab_width) 
    394                     rendered_content = expanded_content 
    395                 result = renderer.render(req, full_mimetype, rendered_content, 
    396                                          filename, url) 
    397                 if not result: 
    398                     continue 
    399                 elif isinstance(result, Fragment): 
    400                     return result 
    401                 elif isinstance(result, basestring): 
    402                     return Markup(to_unicode(result)) 
    403                 elif annotations: 
    404                     return Markup(self._annotate(result, annotations)) 
    405                 else: 
    406                     buf = StringIO() 
    407                     buf.write('<div class="code"><pre>') 
    408                     for line in result: 
    409                         buf.write(line + '\n') 
    410                     buf.write('</pre></div>') 
    411                     return Markup(buf.getvalue()) 
    412             except Exception, e: 
    413                 self.log.warning('HTML preview using %s failed (%s)' 
    414                                  % (renderer, e), exc_info=True) 
    415  
    416446    def _annotate(self, lines, annotations): 
    417447        buf = StringIO() 
    418448        buf.write('<table class="code"><thead><tr>') 
     
    447477        buf.write('</tbody></table>') 
    448478        return buf.getvalue() 
    449479 
    450     def get_max_preview_size(self): 
    451         """Deprecated: use `max_preview_size` attribute directly.""" 
    452         return self.max_preview_size 
    453  
     480    # -- MIME type and charset detection 
     481     
    454482    def get_charset(self, content='', mimetype=None): 
    455483        """Infer the character encoding from the `content` or the `mimetype`. 
    456484 
     
    459487        The charset will be determined using this order: 
    460488         * from the charset information present in the `mimetype` argument 
    461489         * auto-detection of the charset from the `content` 
    462          * the configured `default_charset`  
     490         * the configured `default_charset` 
    463491        """ 
    464492        if mimetype: 
    465             ctpos = mimetype.find('charset=') 
    466             if ctpos >= 0: 
    467                 return mimetype[ctpos + 8:].strip() 
     493            mimetype, charset = split_mimetype_charset(mimetype) 
     494            if charset: 
     495                return charset 
    468496        if isinstance(content, str): 
    469497            utf = detect_unicode(content) 
    470498            if utf is not None: 
    471499                return utf 
     500        # TODO: ICharsetDetector 
    472501        return self.default_charset 
    473502 
    474503    def get_mimetype(self, filename, content=None): 
    475504        """Infer the MIME type from the `filename` or the `content`. 
    476505 
    477         `content` is either a `str` or an `unicode` object. 
     506        `content` is either a `str` or an `unicode` object, 
     507        or something that can be `read`. 
    478508 
    479         Return the detected MIME type, augmented by the 
    480         charset information (i.e. "<mimetype>; charset=..."), 
    481         or `None` if detection failed. 
     509        Return the detected MIME type or `None` if detection failed. 
    482510        """ 
    483511        # Extend default extension to MIME type mappings with configured ones 
    484512        if not self._mime_map: 
     
    489517                    for keyword in assocations: # Note: [0] kept on purpose 
    490518                        self._mime_map[keyword] = assocations[0] 
    491519 
    492         mimetype = get_mimetype(filename, content, self._mime_map) 
     520        # read the content only if there's no other way to get the mimetype 
     521        if hasattr(content, 'read'): 
     522            # first try to get the mimetype from the filename only 
     523            mimetype = get_mimetype(filename, None, self._mime_map) 
     524            if mimetype: 
     525                return mimetype 
     526            content = self.fetch_content(content, mimetype) 
     527        return get_mimetype(filename, content, self._mime_map) 
     528 
     529    def get_mimetype_charset(self, filename, content=None, mimetype=None): 
     530        """Retrieve combined mimetype and charset information. 
     531 
     532        If `mimetype` is given, we check if it provides the needed information, 
     533        otherwise we try to detect the mimetype and/or the charset. 
     534        """ 
     535        print `('gmc', filename, content, mimetype)` 
    493536        charset = None 
     537        if not mimetype: 
     538            mimetype = self.get_mimetype(filename, content) 
     539        print `('gmc2', mimetype)` 
    494540        if mimetype: 
     541            if 'charset=' in mimetype: 
     542                return mimetype 
    495543            charset = self.get_charset(content, mimetype) 
    496         if mimetype and charset and not 'charset' in mimetype: 
    497             mimetype += '; charset=' + charset 
    498         return mimetype 
     544        return combine_mimetype_charset(mimetype, charset) 
    499545 
    500     def to_utf8(self, content, mimetype=None): 
    501         """Convert an encoded `content` to utf-8. 
    502  
    503         ''Deprecated in 0.10. You should use `unicode` strings only.'' 
    504         """ 
    505         return to_utf8(content, self.get_charset(content, mimetype)) 
    506  
     546    # -- Charset conversion 
     547     
    507548    def to_unicode(self, content, mimetype=None, charset=None): 
    508549        """Convert `content` (an encoded `str` object) to an `unicode` object. 
    509550 
     
    514555            charset = self.get_charset(content, mimetype) 
    515556        return to_unicode(content, charset) 
    516557 
     558    def fetch_content(self, content, mimetype): 
     559        if hasattr(content, 'read'): 
     560            content = content.read(self.max_preview_size) 
     561        return self.to_unicode(content, mimetype) 
     562 
     563    # -- Deprecated API (TODO: remove in 0.11) 
     564 
     565    def get_max_preview_size(self): 
     566        """Deprecated: use `max_preview_size` attribute directly.""" 
     567        return self.max_preview_size 
     568 
     569    def to_utf8(self, content, mimetype=None): 
     570        """Convert an encoded `content` to utf-8. 
     571 
     572        ''Deprecated in 0.10. You should use `unicode` strings only.'' 
     573        """ 
     574        return to_utf8(content, self.get_charset(content, mimetype)) 
     575 
     576    # -- Utilities 
     577 
    517578    def configured_modes_mapping(self, renderer): 
    518         """Return a MIME type to `(mode,quality)` mapping for given `option`""" 
     579        """Utility for configurable custom converters 
     580 
     581        Return a MIME type to `(mode,quality)` mapping for given `option`, 
     582        assuming a format of comma-separated <mimetype>:<mode>:<quality> 
     583        associations. 
     584 
     585        See EnscriptConverter and SilverCityConverter. 
     586        """ 
    519587        types, option = {}, '%s_modes' % renderer 
    520588        for mapping in self.config['mimeviewer'].getlist(option): 
    521589            if not mapping: 
     
    543611                                           url, annotations), 
    544612                    'raw_href': url} 
    545613 
    546     def send_converted(self, req, in_type, content, selector, filename='file'): 
     614    def send_converted(self, req, content, mimetype, selector, 
     615                       filename='file'): 
    547616        """Helper method for converting `content` and sending it directly. 
    548617 
    549         `selector` can be either a key or a MIME Type.""" 
     618        `mimetype` is the type of the content. 
     619        `selector` can be either a key or the expected output MIME Type. 
     620        """ 
    550621        from trac.web import RequestDone 
    551         content, output_type, ext = self.convert_content(req, in_type, 
    552                                                          content, selector) 
     622        content, output_type, ext = self.convert_content( 
     623            req, content, mimetype, selector, filename) 
    553624        req.send_response(200) 
    554625        req.send_header('Content-Type', output_type) 
    555         req.send_header('Content-Disposition', 'filename=%s.%s' % (filename, 
    556                                                                   ext)) 
     626        req.send_header('Content-Disposition', 'filename=%s.%s' %  
     627                        (filename, ext)) 
    557628        req.end_headers() 
    558629        req.write(content) 
    559630        raise RequestDone         
    560631         
    561632 
     633# utility for Mimeview._annotate 
    562634def _html_splitlines(lines): 
    563635    """Tracks open and close tags in lines of HTML text and yields lines that 
    564636    have no tags spanning more than one line.""" 
     
    608680                                                            number) 
    609681 
    610682 
    611 # -- Default renderers 
     683# -- Default HTML converters (previously ''renderers'') 
    612684 
    613685class PlainTextRenderer(Component): 
    614     """HTML preview renderer for plain text, and fallback for any kind of text 
    615     for which no more specific renderer is available. 
     686    """Convert text to HTML-escaped text. 
     687 
     688    Will be used as a fallback for any kind of text 
     689    for which no more specific HTML converter is available. 
    616690    """ 
    617     implements(IHTMLPreviewRenderer) 
     691     
     692    implements(IContentConverter) 
    618693 
    619     expand_tabs = True 
    620  
     694    # FIXME: make this configurable/reusable somehow (#2672) 
    621695    TREAT_AS_BINARY = [ 
    622696        'application/pdf', 
    623697        'application/postscript', 
    624698        'application/rtf' 
    625699    ] 
    626700 
    627     def get_quality_ratio(self, mimetype): 
     701    def get_supported_conversions(self, mimetype): 
    628702        if mimetype in self.TREAT_AS_BINARY: 
    629             return 0 
    630         return 1 
    631  
    632     def render(self, req, mimetype, content, filename=None, url=None): 
     703            return 
     704        yield Conversion(key='plain', name='Plain Text', extension='txt', 
     705                         in_type=mimetype, out_type='text/html', 
     706                         quality=mimetype=='text/plain' and 8 or 1, 
     707                         expand_tabs=True) 
     708         
     709    def convert_content(self, req, conversion, content, 
     710                        filename=None, url=None): 
    633711        if is_binary(content): 
    634712            self.env.log.debug("Binary data; no preview available") 
    635             return 
     713        else: 
     714            self.env.log.debug("Using default plain text mimeviewer") 
     715            content = content_to_unicode(self.env, content, mimetype) 
     716             
     717            buf = StringIO() 
     718            for line in content.splitlines(): 
     719                buf.write(escape(line)) 
     720            return Markup(buf.getvalue()) 
    636721 
    637         self.env.log.debug("Using default plain text mimeviewer") 
    638         content = content_to_unicode(self.env, content, mimetype) 
    639         for line in content.splitlines(): 
    640             yield escape(line) 
    641722 
    642  
    643723class ImageRenderer(Component): 
    644724    """Inline image display. Here we don't need the `content` at all.""" 
    645     implements(IHTMLPreviewRenderer) 
     725     
     726    implements(IContentConverter) 
    646727 
    647     def get_quality_ratio(self, mimetype): 
     728    def get_supported_conversions(self, mimetype): 
    648729        if mimetype.startswith('image/'): 
    649             return 8 
    650         return 0 
     730            yield Conversion(key='image', name='Image', extension=None, 
     731                             in_type=mimetype, out_type='text/html', 
     732                             quality=8) 
    651733 
    652     def render(self, req, mimetype, content, filename=None, url=None): 
     734    def convert_content(self, req, conversion, content, 
     735                        filename=None, url=None): 
    653736        if url: 
    654             return html.DIV(html.IMG(src=url,alt=filename), 
    655                             class_="image-file") 
     737            return (html.DIV(html.IMG(src=url, alt=filename), 
     738                             class_="image-file"), 
     739                    'text/html', conversion.extension) 
    656740 
    657741 
    658742class WikiTextRenderer(Component): 
    659743    """Render files containing Trac's own Wiki formatting markup.""" 
    660     implements(IHTMLPreviewRenderer) 
    661744 
    662     def get_quality_ratio(self, mimetype): 
     745    implements(IContentConverter) 
     746 
     747    def get_supported_conversions(self, mimetype): 
    663748        if mimetype in ('text/x-trac-wiki', 'application/x-trac-wiki'): 
    664             return 8 
    665         return 0 
     749            yield Conversion(key='wiki', name='Wiki', extension=None, 
     750                             in_type=mimetype, out_type='text/html', 
     751                             quality=8) 
    666752 
    667     def render(self, req, mimetype, content, filename=None, url=None): 
     753    def convert_content(self, req, conversion, content, 
     754                        filename=None, url=None): 
    668755        from trac.wiki import wiki_to_html 
    669         return wiki_to_html(content_to_unicode(self.env, content, mimetype), 
    670                             self.env, req) 
     756        return (wiki_to_html(content_to_unicode(self.env, content, 
     757                                                conversion.in_type), 
     758                             self.env, req), 
     759                'text/html', 'txt') 
     760 
  • trac/ticket/web_ui.py

     
    3333from trac.web import IRequestHandler 
    3434from trac.web.chrome import add_link, add_stylesheet, INavigationContributor 
    3535from trac.wiki import wiki_to_html, wiki_to_oneliner 
    36 from trac.mimeview.api import Mimeview, IContentConverter 
     36from trac.mimeview.api import Mimeview, IContentConverter, Conversion 
    3737 
    3838 
    3939class TicketModuleBase(Component): 
     
    207207 
    208208    # IContentConverter methods 
    209209 
    210     def get_supported_conversions(self): 
    211         yield ('csv', 'Comma-delimited Text', 'csv', 
    212                'trac.ticket.Ticket', 'text/csv', 8) 
    213         yield ('tab', 'Tab-delimited Text', 'tsv', 
    214                'trac.ticket.Ticket', 'text/tab-separated-values', 8) 
    215         yield ('rss', 'RSS Feed', 'xml', 
    216                'trac.ticket.Ticket', 'application/rss+xml', 8) 
     210    def get_supported_conversions(self, content_type): 
     211        if content_type == 'trac.ticket.Ticket': 
     212            yield Conversion('csv', 'Comma-delimited Text', 'csv', 
     213                             in_type=content_type, 
     214                             out_type='text/csv', quality=8) 
     215            yield Conversion('tab', 'Tab-delimited Text', 'tsv', 
     216                             in_type=content_type, 
     217                             out_type='text/tab-separated-values', 
     218                             quality=8) 
     219            yield Conversion('rss', 'RSS Feed', 'xml', 
     220                             in_type=content_type, 
     221                             out_type='application/rss+xml', 
     222                             quality=8) 
    217223 
    218     def convert_content(self, req, mimetype, ticket, key): 
     224    def convert_content(self, req, conversion, ticket, filename, url): 
     225        key = conversion.key 
    219226        if key == 'csv': 
    220227            return self.export_csv(ticket, mimetype='text/csv') 
    221228        elif key == 'tab': 
     
    282289        mime = Mimeview(self.env) 
    283290        format = req.args.get('format') 
    284291        if format: 
    285             mime.send_converted(req, 'trac.ticket.Ticket', ticket, format, 
     292            mime.send_converted(req, ticket, 'trac.ticket.Ticket', format, 
    286293                                'ticket_%d' % ticket.id) 
    287294 
    288295        # If the ticket is being shown in the context of a query, add 
     
    306313        add_stylesheet(req, 'common/css/ticket.css') 
    307314 
    308315        # Add registered converters 
    309         for conversion in mime.get_supported_conversions('trac.ticket.Ticket'): 
    310             conversion_href = req.href.ticket(ticket.id, format=conversion[0]) 
    311             add_link(req, 'alternate', conversion_href, conversion[1], 
    312                      conversion[3]) 
     316        for conversion, converter in \ 
     317                mime.get_supported_conversions('trac.ticket.Ticket'): 
     318            conversion_href = req.href.ticket(ticket.id, format=conversion.key) 
     319            add_link(req, 'alternate', conversion_href, conversion.name, 
     320                     conversion.out_type) 
    313321 
    314322        return 'ticket.cs', None 
    315323 
     
    420428        return (content.getvalue(), '%s;charset=utf-8' % mimetype) 
    421429         
    422430    def export_rss(self, req, ticket): 
     431        print 'export_rss' 
    423432        db = self.env.get_db_cnx() 
    424433        changelog = ticket.get_changelog(db=db) 
    425434        curr_author = None 
     
    437446            changes[-1]['title'] = title 
    438447 
    439448        for date, author, field, old, new in changelog: 
     449            print 'changelog' 
    440450            if date != curr_date or author != curr_author: 
    441451                update_title() 
    442452                change_summary = {} 
     
    467477                                                'new': new} 
    468478        update_title() 
    469479        req.hdf['ticket.changes'] = changes 
     480        print 'about to be done' 
    470481        return (req.hdf.render('ticket_rss.cs'), 'application/rss+xml') 
    471482 
    472483 
  • trac/ticket/tests/conversion.py

     
    22from trac.util import sorted 
    33from trac.ticket.model import Ticket 
    44from trac.ticket.web_ui import TicketModule 
    5 from trac.mimeview.api import Mimeview 
     5from trac.mimeview.api import Mimeview, Conversion 
    66from trac.web.clearsilver import HDFWrapper 
     7from trac.web.href import Href 
    78 
    89import unittest 
    910 
     
    1516        self.ticket_module = TicketModule(self.env) 
    1617        self.mimeview = Mimeview(self.env) 
    1718        self.req = Mock(hdf=HDFWrapper(['./templates']), 
    18                         base_path='/trac.cgi', path_info='') 
     19                        base_path='/trac.cgi', path_info='', 
     20                        href=Href('/trac.cgi'), 
     21                        abs_href=Href('http://example.org/trac.cgi')) 
    1922 
    2023    def _create_a_ticket(self): 
    2124        # 1. Creating ticket 
     
    2730        return ticket 
    2831 
    2932    def test_conversions(self): 
    30         conversions = self.mimeview.get_supported_conversions( 
    31             'trac.ticket.Ticket') 
    32         expected = sorted([('csv', 'Comma-delimited Text', 'csv', 
    33                            'trac.ticket.Ticket', 'text/csv', 8, 
    34                            self.ticket_module), 
    35                           ('tab', 'Tab-delimited Text', 'tsv', 
    36                            'trac.ticket.Ticket', 'text/tab-separated-values', 8, 
    37                            self.ticket_module), 
    38                            ('rss', 'RSS Feed', 'xml', 
    39                             'trac.ticket.Ticket', 'application/rss+xml', 8, 
     33        conversions = self.mimeview\ 
     34                      .get_supported_conversions('trac.ticket.Ticket') 
     35        expected = sorted([(Conversion('csv', 'Comma-delimited Text', 'csv', 
     36                                       'trac.ticket.Ticket', 'text/csv', 8), 
     37                            self.ticket_module), 
     38                           (Conversion('tab', 'Tab-delimited Text', 'tsv', 
     39                                       'trac.ticket.Ticket', 
     40                                       'text/tab-separated-values', 8), 
     41                            self.ticket_module), 
     42                           (Conversion('rss', 'RSS Feed', 'xml', 
     43                                       'trac.ticket.Ticket', 
     44                                       'application/rss+xml', 8), 
    4045                            self.ticket_module)], 
    4146                          key=lambda i: i[-1], reverse=True) 
    42         self.assertEqual(expected, conversions) 
     47        for expected, actual in zip(expected, conversions): 
     48            self.assertEqual(expected[1], actual[1]) 
     49            for attr in ('key', 'name', 'extension', 'in_type', 'out_type', 
     50                         'quality', 'expand_tabs'): 
     51                self.assertEqual(getattr(expected[0], attr), 
     52                                 getattr(actual[0], attr)) 
    4353 
    4454    def test_csv_conversion(self): 
    4555        ticket = self._create_a_ticket() 
    46         csv = self.mimeview.convert_content(self.req, 'trac.ticket.Ticket', 
    47                                             ticket, 'csv') 
     56        csv = self.mimeview.convert_content(self.req, 
     57                                            ticket, 'trac.ticket.Ticket', 
     58                                            'csv') 
    4859        self.assertEqual((u'id,summary,reporter,owner,description,keywords,cc' 
    4960                          '\r\nNone,Foo,santa,,Bar,,\r\n', 
    5061                          'text/csv;charset=utf-8', 'csv'), csv) 
     
    5263 
    5364    def test_tab_conversion(self): 
    5465        ticket = self._create_a_ticket() 
    55         csv = self.mimeview.convert_content(self.req, 'trac.ticket.Ticket', 
    56                                             ticket, 'tab') 
     66        csv = self.mimeview.convert_content(self.req, 
     67                                            ticket, 'trac.ticket.Ticket', 
     68                                            'tab') 
    5769        self.assertEqual((u'id\tsummary\treporter\towner\tdescription\tkeywords' 
    5870                          '\tcc\r\nNone\tFoo\tsanta\t\tBar\t\t\r\n', 
    5971                          'text/tab-separated-values;charset=utf-8', 'tsv'), 
     
    6375        ticket = self._create_a_ticket() 
    6476        ticket.insert() 
    6577        content, mimetype, ext = self.mimeview.convert_content( 
    66             self.req, 'trac.ticket.Ticket', ticket, 'rss') 
     78            self.req, ticket, 'trac.ticket.Ticket', 'rss') 
    6779        self.assertEqual(('<?xml version="1.0"?>\n<!-- RSS generated by Trac v ' 
    6880                          'on  -->\n<rss version="2.0">\n <channel>\n   ' 
    6981                          '<title>Ticket </title>\n  <link></link>\n  ' 
  • trac/ticket/query.py

     
    349349               IContentConverter) 
    350350 
    351351    # IContentConverter methods 
    352     def get_supported_conversions(self): 
    353         yield ('rss', 'RSS Feed', 'xml', 
    354                'trac.ticket.Query', 'application/rss+xml', 8) 
    355         yield ('csv', 'Comma-delimited Text', 'csv', 
    356                'trac.ticket.Query', 'text/csv', 8) 
    357         yield ('tab', 'Tab-delimited Text', 'tsv', 
    358                'trac.ticket.Query', 'text/tab-separated-values', 8) 
     352    def get_supported_conversions(self, content_type): 
     353        if content_type == 'trac.ticket.Query': 
     354            yield Conversion('rss', 'RSS Feed', 'xml', 
     355                             in_type=content_type, 
     356                             out_type='application/rss+xml', quality=8) 
     357            yield Conversion('csv', 'Comma-delimited Text', 'csv', 
     358                             in_type=content_type, 
     359                             out_type='text/csv', quality=8) 
     360            yield Conversion('tab', 'Tab-delimited Text', 'tsv', 
     361                             in_type=content_type, 
     362                             out_type='text/tab-separated-values', 
     363                             quality=8) 
    359364 
    360     def convert_content(self, req, mimetype, query, key): 
     365    def convert_content(self, req, conversion, query, filename=None, url=None): 
     366        key = conversion.key 
    361367        if key == 'rss': 
    362368            return self.export_rss(req, query) 
    363369        elif key == 'csv': 
     
    412418            req.redirect(query.get_href()) 
    413419 
    414420        # Add registered converters 
    415         for conversion in Mimeview(self.env).get_supported_conversions( 
    416                                              'trac.ticket.Query'): 
    417             add_link(req, 'alternate', query.get_href(format=conversion[0]), 
    418                       conversion[1], conversion[3]) 
     421        for conversion, converter in \ 
     422                Mimeview(self.env).get_supported_conversions('trac.ticket.Query'): 
     423            add_link(req, 'alternate', query.get_href(format=conversion.key), 
     424                     conversion.name, conversion.out_type) 
    419425 
    420426        constraints = {} 
    421427        for k, v in query.constraints.items(): 
     
    434440 
    435441        format = req.args.get('format') 
    436442        if format: 
    437             Mimeview(self.env).send_converted(req, 'trac.ticket.Query', query, 
     443            Mimeview(self.env).send_converted(req, query, 'trac.ticket.Query', 
    438444                                              format, 'query') 
    439445 
    440446        self.display_html(req, query) 
  • trac/versioncontrol/web_ui/browser.py

     
    2323from trac import util 
    2424from trac.config import ListOption, Option 
    2525from trac.core import * 
    26 from trac.mimeview import Mimeview, is_binary, get_mimetype 
     26from trac.mimeview import Mimeview, is_binary 
    2727from trac.perm import IPermissionRequestor 
    2828from trac.util import sorted, embedded_numbers 
    2929from trac.util.datefmt import http_date, format_datetime, pretty_timedelta 
     
    206206        # MIME type detection 
    207207        content = node.get_content() 
    208208        chunk = content.read(CHUNK_SIZE) 
    209         mime_type = node.content_type 
    210         if not mime_type or mime_type == 'application/octet-stream': 
    211             mime_type = mimeview.get_mimetype(node.name, chunk) or \ 
    212                         mime_type or 'text/plain' 
     209        mimetype = node.content_type 
     210        charset = None 
     211        if not mimetype or mimetype == 'application/octet-stream': 
     212            mimetype, charset = mimeview.get_mimetype_charset( 
     213                node.name, chunk, mimetype) or \ 
     214                (mimetype, None) or ('text/plain', None) 
     215        full_mimetype = combine_mimetype_charset(mimetype, charset) 
    213216 
    214217        # Eventually send the file directly 
    215218        format = req.args.get('format') 
    216         if format in ['raw', 'txt']: 
     219        if format in ('raw', 'txt'): 
     220            if format == 'txt': 
     221                full_mimetype = combine_mimetype_charset('text/plain', charset) 
    217222            req.send_response(200) 
    218             req.send_header('Content-Type', 
    219                             format == 'txt' and 'text/plain' or mime_type) 
     223            req.send_header('Content-Type', full_mimetype) 
    220224            req.send_header('Content-Length', node.content_length) 
    221225            req.send_header('Last-Modified', http_date(node.last_modified)) 
    222226            req.end_headers() 
    223227 
    224             while 1: 
     228            while True: 
    225229                if not chunk: 
    226230                    raise RequestDone 
    227231                req.write(chunk) 
     
    248252            }  
    249253 
    250254            # add ''Plain Text'' alternate link if needed 
    251             if not is_binary(chunk) and mime_type != 'text/plain': 
     255            if not is_binary(chunk) and mimetype != 'text/plain': 
    252256                plain_href = req.href.browser(node.path, rev=rev, format='txt') 
    253257                add_link(req, 'alternate', plain_href, 'Plain Text', 
    254258                         'text/plain') 
    255259 
    256260            # add ''Original Format'' alternate link (always) 
    257261            raw_href = req.href.browser(node.path, rev=rev, format='raw') 
    258             add_link(req, 'alternate', raw_href, 'Original Format', mime_type) 
     262            add_link(req, 'alternate', raw_href, 'Original Format', mimetype) 
    259263 
    260264            self.log.debug("Rendering preview of node %s@%s with mime-type %s" 
    261                            % (node.name, str(rev), mime_type)) 
     265                           % (node.name, str(rev), mimetype)) 
    262266 
    263267            del content # the remainder of that content is not needed 
    264268 
    265269            req.hdf['file'] = mimeview.preview_to_hdf( 
    266                 req, node.get_content(), node.get_content_length(), mime_type, 
     270                req, node.get_content(), node.get_content_length(), mimetype, 
    267271                node.created_path, raw_href, annotations=['lineno']) 
    268272 
    269273            add_stylesheet(req, 'common/css/code.css') 
  • trac/wiki/web_ui.py

     
    3434from trac.wiki.api import IWikiPageManipulator, WikiSystem 
    3535from trac.wiki.model import WikiPage 
    3636from trac.wiki.formatter import wiki_to_html, wiki_to_oneliner 
    37 from trac.mimeview.api import Mimeview, IContentConverter 
     37from trac.mimeview.api import Mimeview, IContentConverter, Conversion 
    3838 
    3939 
    4040class WikiModule(Component): 
     
    4545    page_manipulators = ExtensionPoint(IWikiPageManipulator) 
    4646 
    4747    # IContentConverter methods 
    48     def get_supported_conversions(self): 
    49         yield ('txt', 'Plain Text', 'txt', 'text/x-trac-wiki', 'text/plain', 9) 
     48    def get_supported_conversions(self, mimetype): 
     49        if mimetype == 'text/x-trac-wiki': 
     50            yield Conversion('txt', 'Plain Text', 'txt', 
     51                             in_type=mimetype, out_type='text/plain', 
     52                             quality=9) 
    5053 
    51     def convert_content(self, req, mimetype, content, key): 
    52         return (content, 'text/plain;charset=utf-8') 
     54    def convert_content(self, req, conversion, content, filename, url): 
     55        return (content, 'text/plain; charset=utf-8', 'txt') 
    5356 
    5457    # INavigationContributor methods 
    5558 
     
    122125        else: 
    123126            format = req.args.get('format') 
    124127            if format: 
    125                 Mimeview(self.env).send_converted(req, 'text/x-trac-wiki', 
    126                                                   page.text, format, page.name) 
     128                Mimeview(self.env).send_converted( 
     129                    req, page.text, 'text/x-trac-wiki', format, page.name) 
    127130            self._render_view(req, db, page) 
    128131 
    129132        req.hdf['wiki.action'] = action 
     
    379382            req.hdf['html.norobots'] = 1 
    380383 
    381384        # Add registered converters 
    382         for conversion in Mimeview(self.env).get_supported_conversions( 
    383                                              'text/x-trac-wiki'): 
     385        for conversion, converter in \ 
     386                Mimeview(self.env).get_supported_conversions('text/x-trac-wiki'): 
    384387            conversion_href = req.href.wiki(page.name, version=version, 
    385                                             format=conversion[0]) 
    386             add_link(req, 'alternate', conversion_href, conversion[1], 
    387                      conversion[3]) 
     388                                            format=conversion.key) 
     389            add_link(req, 'alternate', conversion_href, conversion.name, 
     390                     conversion.out_type) 
    388391 
    389392        req.hdf['wiki'] = {'exists': page.exists, 
    390393                           'version': page.version, 'readonly': page.readonly} 
  • trac/test.py

     
    130130        self.config = Configuration(None) 
    131131 
    132132        from trac.log import logger_factory 
    133         self.log = logger_factory('test') 
     133        self.log = logger_factory('stderr') 
    134134 
    135135        from trac.web.href import Href 
    136136        self.href = Href('/trac.cgi') 
  • trac/web/chrome.py

     
    1717import os 
    1818import re 
    1919 
    20 from trac import mimeview 
    2120from trac.config import * 
    2221from trac.core import * 
    2322from trac.env import IEnvironmentSetupParticipant 
     23from trac.mimeview import get_mimetype 
    2424from trac.util.markup import html 
    2525from trac.web.api import IRequestHandler, HTTPNotFound 
    2626from trac.web.href import Href 
     
    282282                    icon = href.chrome(icon) 
    283283                else: 
    284284                    icon = href.chrome('common', icon) 
    285             mimetype = mimeview.get_mimetype(icon) 
     285            mimetype = get_mimetype(icon) 
    286286            add_link(req, 'icon', icon, mimetype=mimetype) 
    287287            add_link(req, 'shortcut icon', icon, mimetype=mimetype) 
    288288