Edgewall Software

Ticket #3332: mimeview_refactoring-typerepr-r3507.diff

File mimeview_refactoring-typerepr-r3507.diff, 97.2 KB (added by cboos, 6 years ago)

Further progress on the refactoring. Now covers the Ticket module. For this, I had to introduce a base class for the MimeType?: it's the very general TypeRepr?, which is also subclassed by TypeWrapper?. This enable to use the IContentConverter machinery for converting plain old Python objects to other contents. Still need some more polishing, of course.

  • trac/attachment.py

     
    2626from trac.config import BoolOption, IntOption 
    2727from trac.core import * 
    2828from trac.env import IEnvironmentSetupParticipant 
    29 from trac.mimeview import * 
     29from trac.mimeview.api import Mimeview, MimeType, FileMimeContent 
    3030from trac.util import get_reporter_id, create_unique_file 
    3131from trac.util.datefmt import format_datetime, pretty_timedelta 
    3232from trac.util.markup import Markup, html 
     
    124124    def parent_href(self, req): 
    125125        return req.href(self.parent_type, self.parent_id) 
    126126 
    127     def _get_title(self): 
     127    def _get_title(self): # TODO: should extend to other `parent_type`s 
    128128        return '%s%s: %s' % (self.parent_type == 'ticket' and '#' or '', 
    129129                             self.parent_id, self.filename) 
    130130    title = property(_get_title) 
     
    227227 
    228228    select = classmethod(select) 
    229229 
    230     def open(self): 
     230    def open(self): # deprecate? 
    231231        self.env.log.debug('Trying to open attachment at %s', self.path) 
    232232        try: 
    233233            fd = open(self.path, 'rb') 
     
    505505                                 'author': get_reporter_id(req)} 
    506506 
    507507    def _render_view(self, req, attachment): 
     508        # FIXME: perm_map should extend to other `parent_type`s 
    508509        perm_map = {'ticket': 'TICKET_VIEW', 'wiki': 'WIKI_VIEW'} 
    509510        req.perm.assert_permission(perm_map[attachment.parent_type]) 
    510511 
     
    522523        if req.perm.has_permission(perm_map[attachment.parent_type]): 
    523524            req.hdf['attachment.can_delete'] = 1 
    524525 
    525         fd = attachment.open() 
    526         try: 
    527             mimeview = Mimeview(self.env) 
     526        format = req.args.get('format') 
     527        raw_href = attachment.href(req, format='raw') 
    528528 
    529             # MIME type detection 
    530             str_data = fd.read(1000) 
    531             fd.seek(0) 
    532              
    533             binary = is_binary(str_data) 
    534             mime_type = mimeview.get_mimetype(attachment.filename, str_data) 
     529        mimecontent = FileMimeContent(self.env, attachment.path, raw_href, 
     530                                      'Attachment') 
     531        can_render_as_txt = self.render_unsafe_content and \ 
     532                             not mimecontent.is_binary 
    535533 
    536             # Eventually send the file directly 
    537             format = req.args.get('format') 
    538             if format in ('raw', 'txt'): 
    539                 if not self.render_unsafe_content and not binary: 
    540                     # Force browser to download HTML/SVG/etc pages that may 
    541                     # contain malicious code enabling XSS attacks 
    542                     req.send_header('Content-Disposition', 'attachment;' + 
    543                                     'filename=' + attachment.filename) 
    544                 if not mime_type or (self.render_unsafe_content and \ 
    545                                      not binary and format == 'txt'): 
    546                     mime_type = 'text/plain' 
    547                 if 'charset=' not in mime_type: 
    548                     charset = mimeview.get_charset(str_data, mime_type) 
    549                     mime_type = mime_type + '; charset=' + charset 
    550                 req.send_file(attachment.path, mime_type) 
     534        # Eventually send the file directly 
     535        if format in ('raw', 'txt'): 
     536            if not self.render_unsafe_content and not mimecontent.is_binary: 
     537                # Force browser to download HTML/SVG/etc pages that may 
     538                # contain malicious code enabling XSS attacks 
     539                req.send_header('Content-Disposition', 
     540                                'attachment;filename=%s' % attachment.filename) 
     541            if not mimecontent.type.is_known or \ 
     542                   (format == 'txt' and can_render_as_txt): 
     543                # Force the content to be displayed as text 
     544                type = MimeType('text/plain', mimecontent.encoding) 
     545            else: 
     546                type = mimecontent.type 
     547            req.send_file(attachment.path, type.mimetype_charset) 
    551548 
    552             # add ''Plain Text'' alternate link if needed 
    553             if self.render_unsafe_content and not binary and \ 
    554                mime_type and not mime_type.startswith('text/plain'): 
    555                 plaintext_href = attachment.href(req, format='txt') 
    556                 add_link(req, 'alternate', plaintext_href, 'Plain Text', 
    557                          mime_type) 
     549        mimetype = mimecontent.type.mimetype 
    558550 
    559             # add ''Original Format'' alternate link (always) 
    560             raw_href = attachment.href(req, format='raw') 
    561             add_link(req, 'alternate', raw_href, 'Original Format', mime_type) 
     551        # add ''Plain Text'' alternate link if needed 
     552        if can_render_as_txt and mimecontent.type.is_known and \ 
     553               mimetype != 'text/plain': 
     554            add_link(req, 'alternate', attachment.href(req, format='txt'), 
     555                     'Plain Text', 'text/plain') 
    562556 
    563             self.log.debug("Rendering preview of file %s with mime-type %s" 
    564                            % (attachment.filename, mime_type)) 
     557        # add ''Original Format'' alternate link (always) 
     558        add_link(req, 'alternate', raw_href, 'Original Format', mimetype) 
    565559 
    566             req.hdf['attachment'] = mimeview.preview_to_hdf( 
    567                 req, fd, os.fstat(fd.fileno()).st_size, mime_type, 
    568                 attachment.filename, raw_href, annotations=['lineno']) 
    569         finally: 
    570             fd.close() 
     560        self.log.debug("Rendering preview of file %s with mime-type %s" % \ 
     561                       (attachment.filename, mimetype)) 
    571562 
     563        req.hdf['attachment'] = Mimeview(self.env).preview_to_hdf( 
     564            req, mimecontent, annotations=['lineno']) 
     565 
    572566    def _render_list(self, req, p_type, p_id): 
    573567        self._parent_to_hdf(req, p_type, p_id) 
    574568        req.hdf['attachment'] = { 
  • trac/mimeview/rst.py

     
    2828import re 
    2929 
    3030from trac.core import * 
    31 from trac.mimeview.api import IHTMLPreviewRenderer, content_to_unicode 
     31from trac.mimeview.api import IHTMLPreviewRenderer 
    3232from trac.web.href import Href 
    3333from trac.wiki.formatter import WikiProcessor 
    3434from trac.wiki import WikiSystem 
     
    223223 
    224224        _inliner = rst.states.Inliner() 
    225225        _parser = rst.Parser(inliner=_inliner) 
    226         content = content_to_unicode(self.env, content, mimetype) 
     226        content = unicode(content) 
    227227        content = content.encode('utf-8') 
    228228        html = publish_string(content, writer_name='html', parser=_parser, 
    229229                              settings_overrides={'halt_level': 6}) 
  • trac/mimeview/api.py

     
    1919#         Christian Boos <cboos@neuf.fr> 
    2020 
    2121""" 
    22 The `trac.mimeview` module centralize the intelligence related to 
    23 file metadata, principally concerning the `type` (MIME type) of the content 
    24 and, if relevant, concerning the text encoding (charset) used by the content. 
     22The `trac.mimeview` module centralizes the intelligence related to file 
     23metadata, principally concerning the `type` (MIME type) and the text 
     24encoding (charset) used by the content, if the latter one is relevant. 
    2525 
    2626There are primarily two approaches for getting the MIME type of a given file: 
    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 
    33 In some cases, only the `url` pointing to the file's content is actually 
    34 needed, that's why we avoid to read the file's content when it's not needed. 
    35  
    36 The actual `content` to be converted might be a `unicode` object, 
    37 but it can also be the raw byte string (`str`) object, or simply 
    38 an object that can be `read()`. 
     33In order to keep the API simple, we deal with a few classes, 
     34each encapsulating a part of the knowledge related to content. 
     35 * the `MimeType`, for storing the mime type string, the charset, 
     36   but also eventually the name and the commonly used file extension 
     37   for that type 
     38 * the `MimeContent` and `FileMimeContent`, which provide a flexible 
     39   API for handling string and file content, respectively. 
     40   Those classes inherit from the abstract `MimeContentBase`, which 
     41   can be used to make new wrapper classes for any kind of content. 
     42 * the `Conversion` class, which is use to specify conversion from 
     43   one `MimeType` to another 
    3944""" 
    4045 
     46import os 
    4147import re 
    4248from StringIO import StringIO 
    4349 
    4450from trac.config import IntOption, ListOption, Option 
    4551from trac.core import * 
    4652from trac.util import sorted 
    47 from trac.util.text import to_utf8, to_unicode 
     53from trac.util.text import to_utf8, to_unicode, IDENTITY_CHARSET 
    4854from trac.util.markup import escape, Markup, Fragment, html 
    4955 
    5056 
    51 __all__ = ['get_mimetype', 'is_binary', 'detect_unicode', 'Mimeview', 
    52            'content_to_unicode'] 
     57__all__ = ['get_mimetype', 'is_binary', 'detect_unicode', 
     58           'Mimeview', 'Conversion', 'MimeContentBase', 'MimeType',  
     59           'MimeContent', 'FileMimeContent', 
     60           'TypeWrapper', 'ObjectContent', 'IContentConverter', 
     61           'TEXT_PLAIN', 'TEXT_HTML', 
     62           'APPLICATION_RSS_XML', 'APPLICATION_OCTET_STREAM'] 
    5363 
    5464 
    5565# Some common MIME types and their associated keywords and/or file extensions 
    5666 
     67APPLICATION_OCTET_STREAM_STR = 'application/octet-stream' 
     68 
    5769KNOWN_MIME_TYPES = { 
    5870    'application/pdf':        ['pdf'], 
    5971    'application/postscript': ['ps'], 
     
    114126    for e in exts: 
    115127        MIME_MAP[e] = t 
    116128 
    117 # Simple builtin autodetection from the content using a regexp 
    118 MODE_RE = re.compile( 
    119     r"#!(?:[/\w.-_]+/)?(\w+)|"               # look for shebang 
    120     r"-\*-\s*(?:mode:\s*)?([\w+-]+)\s*-\*-|" # look for Emacs' -*- mode -*- 
    121     r"vim:.*?syntax=(\w+)"                   # look for VIM's syntax=<n> 
    122     ) 
    123129 
    124 def get_mimetype(filename, content=None, mime_map=MIME_MAP): 
    125     """Guess the most probable MIME type of a file with the given name. 
     130# -- a few functions for dealing with MIME types / binary / text content 
     131#    in a simple way (get_mimetype, is_binary, detect_unicode) 
    126132 
     133def get_mimetype_from_filename(filename, mime_map=MIME_MAP): 
     134    """Guess the most probable MIME type of file with the given `filename`. 
     135 
    127136    `filename` is either a filename (the lookup will then use the suffix) 
    128137    or some arbitrary keyword. 
    129      
    130     `content` is either a `str` or an `unicode` string. 
     138    `mime_map` maps keywords to MIME types. 
     139 
     140    Return the MIME type as a string, or `None` if not detected. 
    131141    """ 
    132142    suffix = filename.split('.')[-1] 
    133143    if suffix in mime_map: 
     
    141151            mimetype = mimetypes.guess_type(filename)[0] 
    142152        except: 
    143153            pass 
    144         if not mimetype and content: 
    145             match = re.search(MODE_RE, content[:1000]) 
    146             if match: 
    147                 mode = match.group(1) or match.group(3) or \ 
    148                     match.group(2).lower() 
    149                 if mode in mime_map: 
    150                     # 3) mimetype from the content, using the `MODE_RE` 
    151                     return mime_map[mode] 
    152             else: 
    153                 if is_binary(content): 
    154                     # 4) mimetype from the content, using`is_binary` 
    155                     return 'application/octet-stream' 
    156154        return mimetype 
    157155 
     156# Simple builtin autodetection from the content using a regexp 
     157MODE_RE = re.compile( 
     158    r"#!(?:[/\w.-_]+/)?(\w+)|"               # look for shebang 
     159    r"-\*-\s*(?:mode:\s*)?([\w+-]+)\s*-\*-|" # look for Emacs' -*- mode -*- 
     160    r"vim:.*?syntax=(\w+)"                   # look for VIM's syntax=<n> 
     161    ) 
     162 
     163def get_mimetype_from_content(content): 
     164    """Guess the most probable MIME type of file with the given `filename`. 
     165 
     166    `content` is either a `str` or an `unicode` string  
     167 
     168    Return the MIME type as a string, or `None` if not detected. 
     169    """ 
     170    match = re.search(MODE_RE, content[:1000]) 
     171    if match: 
     172        mode = match.group(1) or match.group(3) or \ 
     173            match.group(2).lower() 
     174        if mode in mime_map: 
     175            # 3) mimetype from the content, using the `MODE_RE` 
     176            return mime_map[mode] 
     177    else: 
     178        if is_binary(content): 
     179            # 4) mimetype from the content, using `is_binary` 
     180            return APPLICATION_OCTET_STREAM_STR 
     181 
     182def get_mimetype(filename, content=None, mime_map=MIME_MAP): 
     183    """Auto-detect MIME type either from the `filename` or from the `content`. 
     184    """ 
     185    mimetype = get_mimetype_from_filename(filename, mime_map) 
     186    if not mimetype and content: 
     187        mimetype = get_mimetype_from_content(content) 
     188    return mimetype 
     189 
    158190def is_binary(data): 
    159191    """Detect binary content by checking the first thousand bytes for zeroes. 
    160192 
     
    165197    return '\0' in data[:1000] 
    166198 
    167199def detect_unicode(data): 
    168     """Detect different unicode charsets by looking for BOMs (Byte Order Marks). 
     200    """Detect different unicode charsets by looking for Byte Order Marks. 
    169201 
    170202    Operate obviously only on `str` objects. 
    171203    """ 
     
    178210    else: 
    179211        return None 
    180212 
    181 def content_to_unicode(env, content, mimetype): 
    182     """Retrieve an `unicode` object from a `content` to be previewed""" 
    183     mimeview = Mimeview(env) 
    184     if hasattr(content, 'read'): 
    185         content = content.read(mimeview.max_preview_size) 
    186     return mimeview.to_unicode(content, mimetype) 
    187213 
     214# -- Classes for mimetype, content and conversion 
    188215 
     216class TypeRepr(object): 
     217    """Represent some for of typing.""" 
     218    def match(self, other, regexp=False): 
     219        raise NotImplementedError 
     220 
     221    mimetype = property(lambda x: x._mimetype or APPLICATION_OCTET_STREAM_STR, 
     222                        doc="MIME Type string (without charset information)") 
     223    charset = property(lambda x: x._charset, 
     224                       doc="Eventual charset information") 
     225    is_binary = property(lambda x: x._is_binary()) 
     226 
     227class TypeWrapper(TypeRepr): 
     228    """Typing using Python types.""" 
     229 
     230    def __init__(self, obj): 
     231        self.class_ = isinstance(obj, type) and obj or obj.__class__ 
     232        self._mimetype = self._charset = None 
     233 
     234    def __repr__(self): 
     235        return self.class_.__name__ 
     236 
     237    def _is_binary(self): 
     238        return True 
     239 
     240    def match(self, other, regexp=False): 
     241        other_class = isinstance(other, type) and other or \ 
     242                      isinstance(other, TypeWrapper) and other.class_ or \ 
     243                      other.__class__ 
     244        return other_class == self.class_ 
     245 
     246class MimeType(TypeRepr): 
     247    """Typing of arbitrary content using MIME types. 
     248 
     249    If the MIME type correspond to text content, the object can also 
     250    store a `charset` information. 
     251 
     252    A MIME type has a `name` and has an `extension` 
     253    that can be used for storing the converted data in a file. 
     254 
     255    All the properties of this class are read-only. 
     256    """ 
     257     
     258    def __init__(self, mimetype, charset=None, name=None, extension=None): 
     259        """The `mimetype` string can eventually embed the `charset`.""" 
     260        self._mimetype = mimetype 
     261        # determine charset 
     262        self._charset = charset 
     263        if not self._charset and self._mimetype: 
     264            sep_idx = mimetype.find(';') 
     265            if sep_idx >= 0: 
     266                self._mimetype = mimetype[:sep_idx].strip() 
     267                charset_idx = mimetype.find('charset=', sep_idx) 
     268                if charset_idx >= 0: 
     269                    self._charset = mimetype[charset_idx+8:].strip() 
     270        self._extension = extension 
     271        self._name = name 
     272 
     273    def __repr__(self): 
     274        return 'MIME type: ' + self.mimetype_charset 
     275 
     276    def _get_extension(self): 
     277        if not self._extension: 
     278            self._extension = KNOWN_MIME_TYPES.get(self.mimetype) 
     279            if not self._extension: 
     280                detail = self.mimetype.split('/', 1)[1] 
     281                if detail.startswith('x-'): 
     282                    self._extension = detail[2:] 
     283        return self._extension 
     284 
     285    def _get_mimetype_charset(self): 
     286        """Combine the MIME type and charset information in a single string. 
     287        """ 
     288        if self._mimetype and self._charset: 
     289            return '%s; charset=%s' % (self.mimetype, self.charset) 
     290        else: 
     291            return self.mimetype 
     292 
     293    def _is_binary(self): 
     294        return self._mimetype == APPLICATION_OCTET_STREAM_STR 
     295 
     296    name = property(lambda x: x._name or x._extension) 
     297    extension = property(lambda x: x._get_extension()) 
     298    mimetype_charset = property(lambda x: x._get_mimetype_charset()) 
     299 
     300    def match(self, other, regexp=False): 
     301        """Compare MIME type string only. 
     302 
     303        If `regexp` is set, `self.mimetype` is used as a regexp. 
     304        """ 
     305        if not isinstance(other, MimeType): 
     306            return False 
     307        if regexp: 
     308            return re.match(self.mimetype, other.mimetype) 
     309        else: 
     310            return self.mimetype == other.mimetype 
     311 
     312 
     313TEXT_PLAIN = MimeType('text/plain', 'utf-8', 'Plain Text', 'txt') 
     314TEXT_HTML = MimeType('text/html', 'utf-8', 'HTML', 'html') 
     315 
     316APPLICATION_RSS_XML = MimeType('application/rss+xml', 'utf-8', 
     317                               'RSS Feed', 'xml') 
     318APPLICATION_OCTET_STREAM = MimeType(APPLICATION_OCTET_STREAM_STR, 
     319                                    IDENTITY_CHARSET, 
     320                                    'Undefined (binary)', 'bin')  
     321 
     322 
     323class MimeContentBase(object): 
     324    """An abstract MIME content, with an associated MimeType. 
     325 
     326    Such an object has means to auto-detect both the MIME Type and 
     327    the `encoding` of its content. 
     328 
     329    That `encoding` is more reliable than the `type.charset` information. 
     330    There are additional consistency checks that are performed, and it 
     331    can be `None` if the content is an `unicode` object. 
     332 
     333    The content itself can be accessed in various ways: through 
     334    the iterator protocol, the len() and unicode() operators... 
     335    """ 
     336 
     337    def __init__(self, env, mimetype=None, filename=None, url=None): 
     338        """ 
     339        `mimetype` can be specified as a `MimeType` object, 
     340        or as string, which will then be a hint about the content. 
     341 
     342        If the mimetype is not specified or equal to 
     343        "application/octet-stream", then it will be auto-detected when needed. 
     344 
     345        In case auto-detection fails, APPLICATION_OCTET_STREAM will be the 
     346        corresponding MIME type. 
     347 
     348        The `filename` is simply a suggested basename for that content. 
     349 
     350        The `url` is a link for retrieving the raw content directly 
     351        from the server. This can be useful for converters that can 
     352        provide links to objects, instead of having to expand the 
     353        content inline. 
     354        """ 
     355        self.env = env 
     356        if isinstance(mimetype, basestring): 
     357            mimetype = MimeType(mimetype) 
     358        self._type = mimetype 
     359        self._filename = filename 
     360        self._url = url 
     361        self._binary = None 
     362        self._encoding = False 
     363 
     364    def __repr__(self): 
     365        return '<%s %s "%s">' % (self.__class__.__name__, self._type, 
     366                                 self._filename or self._url) 
     367 
     368    def __unicode__(self): 
     369        """Return the `unicode` object corresponding to the content.""" 
     370        return to_unicode(self.content, self.encoding) 
     371        # Note: this does the right thing if the content is already `unicode` 
     372 
     373    def encode(self, charset): 
     374        """Return a `str`, corresponding to the `charset` encoded content.""" 
     375        if self.encoding == charset: 
     376            return self.content 
     377        else: 
     378            return unicode(self).encode(charset) 
     379 
     380    def _is_binary(self): 
     381        """An heuristic for guessing whether the content is binary or not. 
     382 
     383        This will eventually fetch an `excerpt` of the content. 
     384        """ 
     385        if self._binary is None: 
     386            self._binary = self.type.is_binary 
     387            print `self.type`, self._binary 
     388            if self._binary is None: 
     389                self._binary = is_binary(self.excerpt) or \ 
     390                               (self.type.mimetype in \ 
     391                                Mimeview(self.env).treat_as_binary) 
     392        return self._binary 
     393 
     394    def _get_type(self): 
     395        """Get or determine the MimeType corresponding to this content. 
     396 
     397        An `excerpt` of the content will be examined if needed. 
     398        """ 
     399        if self._type is None: # not set 
     400            mimetype = None 
     401            if self.filename: 
     402                mimemap = Mimeview(self.env).mimemap 
     403                mimetype = get_mimetype_from_filename(self.filename, mimemap) 
     404            if not mimetype: 
     405                mimetype = get_mimetype_from_content(self.excerpt) 
     406            if not mimetype: 
     407                pass # TODO 0.11: go through IMimeTypeDetectors 
     408            self._type = MimeType(mimetype) 
     409        return self._type 
     410 
     411    def _set_type(self, type): 
     412        """Simply replace the existing `type` by the given `MimeType` object. 
     413 
     414        If `None` is given, this will force auto-detection the next time 
     415        `type` will be accessed. 
     416 
     417        Can be used for in-place conversion (e.g. ''any'' to text/plain). 
     418        """ 
     419        self._type = type 
     420 
     421    def _get_encoding(self): 
     422        """Get or determine the current encoding of that `content`. 
     423 
     424        The encoding will be determined using this order: 
     425         * from the charset information present in the mimetype information 
     426         * auto-detection of the charset from the `content` 
     427         * if nothing else worked, use the configured `default_charset` 
     428 
     429        If the `content` happens to be a genuine `unicode` object, then 
     430        this returns `None`. 
     431        If the `content` is binary, then the encoding will be the identity 
     432        charset (ISO Latin 1). 
     433        """ 
     434        if self._encoding is False: 
     435            charset = self.type.charset 
     436            if charset: 
     437                self._encoding = charset 
     438            elif isinstance(self.excerpt, str): 
     439                utf_encoding = detect_unicode(self.excerpt) 
     440                if utf_encoding is not None: 
     441                    self._encoding = utf_encoding 
     442                elif self.is_binary: 
     443                    self._encoding = IDENTITY_CHARSET 
     444            elif isinstance(self.excerpt, unicode): 
     445                self._encoding = None 
     446            if self._encoding is False: 
     447                pass # TODO 0.11: go through ICharsetDetectors here 
     448            if self._encoding is False: 
     449                self._encoding = Mimeview(self.env).default_charset 
     450        return self._encoding 
     451     
     452    def _get_content(self): 
     453        """Retrieve all the content. 
     454 
     455        Default implementation based on iterator. If the iterator itself 
     456        is implemented based on the content... reimplement this one! 
     457        """ 
     458        return "".join(self.__iter__()) 
     459 
     460    def read(self): # TODO: remove in 0.11 
     461        return self.content # (compatibility with IHTMLPreviewRenderer) 
     462 
     463    # Methods that need to be reimplemented by subclasses: 
     464 
     465    def __iter__(self): 
     466        """Iterate on chunks of raw content.""" 
     467        raise NotImplementedError 
     468 
     469    def __len__(self): 
     470        """Length of the raw content, in bytes.""" 
     471        raise NotImplementedError 
     472 
     473    def _get_excerpt(self, len=1000): 
     474        """Extracts the first `len` characters from the content.""" 
     475        raise NotImplementedError 
     476             
     477    type = property(fget=lambda x: x._get_type(), 
     478                    fset=lambda x, y: x._set_type(y)) 
     479    is_binary = property(lambda x: x._is_binary()) 
     480    encoding = property(lambda x: x._get_encoding()) 
     481    excerpt = property(lambda x: x._get_excerpt()) 
     482    content = property(lambda x: x._get_content()) 
     483    filename = property(lambda x: x._filename) 
     484    url = property(lambda x: x._url) 
     485 
     486 
     487class MimeContent(MimeContentBase): 
     488    """MIME-typed content wrapper for a basestring.""" 
     489 
     490    def __init__(self, env, content, mimetype, filename='file', url=None): 
     491        MimeContentBase.__init__(self, env, mimetype, filename, url) 
     492        self._content = content 
     493 
     494    # Reimplemented methods 
     495 
     496    def _get_content(self): 
     497        """Retrieve the wrapped content. 
     498 
     499        Note: therefore this *might* be an `unicode` object. 
     500        Remember that in this case, `encoding` will be `None`. 
     501        """ 
     502        return self._content 
     503 
     504    def __iter__(self): 
     505        """Iterate on chunks of content. 
     506 
     507        If the content `is_binary` property is `False`, those chunks will 
     508        be lines, with the line endings kept. 
     509        """ 
     510        if self.is_binary: 
     511            buf = StringIO(self.content) 
     512            chunk = buf.read(1000) 
     513            while chunk: 
     514                yield chunk 
     515                chunk = buf.read(1000) 
     516        else: 
     517            for line in self.content.splitlines(True): 
     518                yield line 
     519 
     520    def __len__(self): 
     521        """Length of the content, in characters.""" 
     522        return len(self.content) 
     523 
     524    def _get_excerpt(self, len=1000): 
     525        """Extracts the first `len` characters from the content.""" 
     526        return self._content[:len] 
     527 
     528 
     529class FileMimeContent(MimeContentBase): 
     530    """MIME-typed content wrapper for a file.""" 
     531 
     532    def __init__(self, env, path, url=None, kind='File', mimetype=None): 
     533        self._fd = None 
     534        self._path = path 
     535        self._kind = kind 
     536        self._excerpt = None 
     537        MimeContentBase.__init__(self, env, mimetype, os.path.basename(path), 
     538                                 url) 
     539    def __del__(self): 
     540        if self._fd: 
     541            self._fd.close() 
     542 
     543    def _ensure_open(self): 
     544        if not self._fd: 
     545            try: 
     546                self._fd = open(self._path) 
     547            except IOError: 
     548                raise TracError('%s "%s" not found' % (self._kind, 
     549                                                       self._filename)) 
     550    # Reimplemented methods 
     551     
     552    def __iter__(self): 
     553        """Iterate on chunks of raw content.""" 
     554        chunk = self.excerpt 
     555        while chunk: 
     556            yield chunk 
     557            chunk = self._fd.read(1000) 
     558 
     559    def __len__(self): 
     560        """Length of the raw content, in bytes.""" 
     561        if self._fd: 
     562            stat = os.fstat(self._fd.fileno()) 
     563        else: 
     564            stat = os.stat(self._path) 
     565        return stat.st_size 
     566 
     567    def _get_excerpt(self, len=1000): 
     568        """Extracts the `len` first bytes from the content.""" 
     569        if self._excerpt is None: 
     570            self._ensure_open() 
     571            self._excerpt = self._fd.read(1000) 
     572        return self._excerpt 
     573 
     574class ObjectContent(MimeContentBase): 
     575    """Wraps a Python object into a MimeContentBase. 
     576 
     577    Only supports the bare minimum of the MimeContentBase methods. 
     578    """ 
     579 
     580    def __init__(self, env, obj, filename="obj"): 
     581        self._obj = obj 
     582        MimeContentBase.__init__(self, env, TypeWrapper(obj), 
     583                                 filename=filename) 
     584 
     585    def _get_content(self): 
     586        """Retrieve the wrapped content.""" 
     587        return self._obj 
     588 
     589         
     590class NoConversion(TracError): 
     591    def __init__(self, msg, from_, output, key): 
     592        TracError.__init__(self, '%s, from %s to %s' % 
     593                           (msg, repr(from_), output and repr(output) or key)) 
     594 
     595class Conversion(object): 
     596    """A specification for performing a data conversion. 
     597 
     598    Each conversion is identified by a `key` and targets an output `mimetype`. 
     599 
     600    A conversion also specifies a `quality` ranking, which is a number 
     601    in the range 0 to 9, where 0 means no support and 9 means "perfect" 
     602    support (try to keep 9 available for user defined conversions, 
     603    though nothing will prevent them from using 10 or 100...) 
     604 
     605    Finally, `expand_tabs` indicates whether a tab expansion should precede 
     606    the conversion attempt. 
     607 
     608    e.g. Conversion(key='latex', quality=8, mimetype=MimeType('text/x-tex')) 
     609    """ 
     610 
     611    def __init__(self, key, quality=1, mimetype=TEXT_HTML, expand_tabs=False): 
     612        self.key = key 
     613        self.quality = quality 
     614        self.mimetype = mimetype 
     615        self.expand_tabs = expand_tabs 
     616 
     617    def __repr__(self): 
     618        return '<%s conversion to %s [qr=%s, et=%s]>' % \ 
     619               (self.key, self.mimetype, self.quality, self.expand_tabs) 
     620 
     621 
     622# -- Deprecated (TODO: remove in 0.11) 
     623 
    189624class IHTMLPreviewRenderer(Interface): 
    190625    """Extension point interface for components that add HTML renderers of 
    191626    specific content types to the `Mimeview` component. 
    192627 
    193     (Deprecated) 
     628    Deprecated in 0.10. Implement `IContentConverter` instead. 
    194629    """ 
    195630 
    196631    # implementing classes should set this property to True if they 
     
    198633    expand_tabs = False 
    199634 
    200635    def get_quality_ratio(mimetype): 
    201         """Return the level of support this renderer provides for the `content` 
    202         of the specified MIME type. The return value must be a number between 
    203         0 and 9, where 0 means no support and 9 means "perfect" support. 
    204         """ 
     636        """Return the level of support this renderer provides""" 
    205637 
    206638    def render(req, mimetype, content, filename=None, url=None): 
    207         """Render an XHTML preview of the raw `content`. 
     639        """Render an XHTML preview of the raw `content`.""" 
    208640 
    209         The `content` might be: 
    210          * a `str` object 
    211          * an `unicode` string 
    212          * any object with a `read` method, returning one of the above 
    213641 
    214         It is assumed that the content will correspond to the given `mimetype`. 
     642# -- Interfaces for the extension points 
    215643 
    216         Besides the `content` value, the same content may eventually 
    217         be available through the `filename` or `url` parameters. 
    218         This is useful for renderers that embed objects, using <object> or 
    219         <img> instead of including the content inline. 
    220          
    221         Can return the generated XHTML text as a single string or as an 
    222         iterable that yields strings. In the latter case, the list will 
    223         be considered to correspond to lines of text in the original content. 
     644class IContentConverter(Interface): 
     645    """An extension point interface for generic content conversion.""" 
     646 
     647    def get_supported_conversions(mimetype):  
     648        """Check if conversion of `mimetype` is supported by this converter. 
     649 
     650        Return an iterable of `Conversion` objects for which this is 
     651        the case. 
    224652        """ 
    225653 
     654    def convert_content(context, conversion, content):  
     655        """Convert the given `content` using the specified `conversion`. 
     656 
     657        The conversion takes place in the given formatting `context`. 
     658        A `context` provides at least a `req` property. 
     659         
     660        Return the converted content as a new `MimeContent` object. 
     661        """  
     662 
    226663class IHTMLPreviewAnnotator(Interface): 
    227664    """Extension point interface for components that can annotate an XHTML 
    228665    representation of file contents with additional information.""" 
     
    240677        annotation data.""" 
    241678 
    242679 
    243 class IContentConverter(Interface): 
    244     """An extension point interface for generic MIME based content 
    245     conversion.""" 
     680# -- The main Mimeview component 
    246681 
    247     def get_supported_conversions(): 
    248         """Return an iterable of tuples in the form (key, name, extension, 
    249         in_mimetype, out_mimetype, quality) representing the MIME conversions 
    250         supported and 
    251         the quality ratio of the conversion in the range 0 to 9, where 0 means 
    252         no support and 9 means "perfect" support. eg. ('latex', 'LaTeX', 'tex', 
    253         'text/x-trac-wiki', 'text/plain', 8)""" 
     682class ToplevelContext(object): 
     683    """A simple wrapper for the Request object. 
    254684 
    255     def convert_content(req, mimetype, content, key): 
    256         """Convert the given content from mimetype to the output MIME type 
    257         represented by key. Returns a tuple in the form (content, 
    258         output_mime_type) or None if conversion is not possible.""" 
     685    Use this when no other context information is available. 
     686    """ 
     687    def __init__(self, req): 
     688        self.req = req 
    259689 
    260  
    261690class Mimeview(Component): 
    262691    """A generic class to prettify data, typically source code.""" 
    263692 
    264     renderers = ExtensionPoint(IHTMLPreviewRenderer) 
     693    renderers = ExtensionPoint(IHTMLPreviewRenderer) # TODO: remove in 0.11 
    265694    annotators = ExtensionPoint(IHTMLPreviewAnnotator) 
    266695    converters = ExtensionPoint(IContentConverter) 
    267696 
     
    275704        """Maximum file size for HTML preview. (''since 0.9'').""") 
    276705 
    277706    mime_map = ListOption('mimeviewer', 'mime_map', 
    278         'text/x-dylan:dylan,text/x-idl:ice,text/x-ada:ads:adb', 
     707        'text/x-dylan:dylan,text/x-idl:ice,text/x-ada:ads:adb', doc= 
    279708        """List of additional MIME types and keyword mappings. 
    280         Mappings are comma-separated, and for each MIME type, 
    281         there's a colon (":") separated list of associated keywords 
    282         or file extensions. (''since 0.10'').""") 
    283709 
     710        Mappings are comma-separated. Each mapping starts with the mimetype, 
     711        followed by a colon (":") and the (colon separated) list of associated 
     712        keywords or file extensions. (''since 0.10'').""") 
     713 
     714    treat_as_binary = ListOption('mimeviewer', 'treat_as_binary', 
     715        'application/pdf,application/postscript,application/rtf', doc= 
     716        """List of MIME types that should always be treated as binary content. 
     717 
     718        Accounts for the fact that our binary detection heuristic can't 
     719        always work for some kind of binary data. (''since 0.10'').""") 
     720 
    284721    def __init__(self): 
    285722        self._mime_map = None 
    286          
    287     # Public API 
    288723 
    289     def get_supported_conversions(self, mimetype): 
    290         """Return a list of target MIME types in same form as 
    291         `IContentConverter.get_supported_conversions()`, but with the converter 
    292         component appended. Output is ordered from best to worst quality.""" 
     724    def _get_mimemap(self): 
     725        """Extend default extension to MIME type mappings""" 
     726        if not self._mime_map: 
     727            self._mime_map = {} 
     728            self._mime_map.update(MIME_MAP) 
     729            for mapping in self.config['mimeviewer'].getlist('mime_map'): 
     730                if ':' in mapping: 
     731                    assocations = mapping.split(':') 
     732                    mimetype = assocations[0] 
     733                    for keyword in assocations: # mimetype->mimetype on purpose 
     734                        self._mime_map[keyword] = mimetype 
     735        return self._mime_map 
     736 
     737    mimemap = property(_get_mimemap) 
     738 
     739    def lookup(self, keyword, charset=None): 
     740        """Lookup for given `keyword`, among known MIME Types. 
     741 
     742        Return a `MimeType` object if found, `None` otherwise. 
     743        """ 
     744        mimetype = self.mimemap.get(keyword, None) 
     745        if mimetype: 
     746            return MimeType(mimetype, charset, extension=keyword) 
     747 
     748    # -- MIME type conversion 
     749     
     750    def get_conversions(self, input): 
     751        """Return a list of possible conversions for the `input` MimeType. 
     752 
     753        The returned list contains pair of `(conversion, converter)` objects, 
     754        ordered from best to worst quality. 
     755        """ 
     756        # Build list of possible conversions 
    293757        converters = [] 
    294758        for converter in self.converters: 
    295             for k, n, e, im, om, q in converter.get_supported_conversions(): 
    296                 if im == mimetype and q > 0: 
    297                     converters.append((k, n, e, im, om, q, converter)) 
    298         converters = sorted(converters, key=lambda i: i[-1], reverse=True) 
    299         return converters 
     759            for conversion in converter.get_supported_conversions(input): 
     760                if conversion.quality > 0: 
     761                    converters.append((conversion, converter)) 
    300762 
    301     def convert_content(self, req, mimetype, content, key, filename=None, 
    302                         url=None): 
    303         """Convert the given content to the target MIME type represented by 
    304         `key`, which can be either a MIME type or a key. Returns a tuple of 
    305         (content, output_mime_type, extension).""" 
    306         if not content: 
    307             return ('', 'text/plain;charset=utf-8') 
     763        # ---- Backward compatibility support for IHTMLPreviewRenderer 
     764        class IHTMLPreviewRendererWrapper(object): 
     765            def __init__(self, renderer): 
     766                self.renderer = renderer 
     767            def __repr__(self): 
     768                return repr(self.renderer) 
     769            def convert_content(self, context, conversion, mimecontent): 
     770                return self.renderer.render( 
     771                    context.req, mimecontent.type.mimetype, 
     772                    mimecontent, # which is read()able  
     773                    mimecontent.filename, mimecontent.url) 
     774        for renderer in self.renderers: 
     775            qr = renderer.get_quality_ratio(input.mimetype) 
     776            if qr > 0: 
     777                expand_tabs = getattr(renderer, 'expand_tabs', False) 
     778                converters.append( 
     779                    (Conversion(key='', quality=qr, mimetype=TEXT_HTML, 
     780                                expand_tabs=expand_tabs), 
     781                     IHTMLPreviewRendererWrapper(renderer))) 
     782        # ---- (to be removed in 0.11) 
    308783 
    309         # Ensure we have a MIME type for this content 
    310         full_mimetype = mimetype 
    311         if not full_mimetype: 
    312             if hasattr(content, 'read'): 
    313                 content = content.read(self.max_preview_size) 
    314             full_mimetype = self.get_mimetype(filename, content) 
    315         if full_mimetype: 
    316             mimetype = full_mimetype.split(';')[0].strip() # split off charset 
     784        return sorted(converters, key=lambda c: c[0].quality, reverse=True) 
     785 
     786    def convert(self, context, selector, mimecontent): 
     787        """Convert the `mimecontent` to another MIME type. 
     788 
     789        The conversion to be done is determined by `selector`, 
     790        which can be either directly the desired output MIME type or 
     791        a key identifying the `Conversion` object. 
     792 
     793        Returns a new `MimeContent`. 
     794        """ 
     795        result, mimetype = self._convert(context, selector, mimecontent) 
     796 
     797        if isinstance(result, MimeContentBase): 
     798            return result 
     799        else:         
     800            return MimeContent(self.env, result, mimetype, 
     801                               filename=mimecontent.filename) 
     802 
     803    def _convert(self, context, selector, mimecontent): 
     804        """Convert the `mimecontent` to another MIME type. 
     805 
     806        Doesn't necessarily return a new `MimeContent`: can be a basestring, 
     807        a Fragment, an iterable... 
     808         
     809        """ 
     810        # See whether we've got a type selector 
     811        if isinstance(selector, TypeRepr): 
     812            selected_output = selector 
     813            selected_key = None 
    317814        else: 
    318             mimetype = full_mimetype = 'text/plain' # fallback if not binary 
     815            selected_output = self.lookup(selector) 
     816            if selected_output: 
     817                selected_key = None 
     818            else: 
     819                selected_key = selector 
    319820 
    320         # Choose best converter 
    321         candidates = list(self.get_supported_conversions(mimetype)) 
    322         candidates = [c for c in candidates if key in (c[0], c[4])] 
     821        # Get all available conversions for the input and filter those which 
     822        # are matching either the `selected_output` or the `selected_key` 
     823        candidates = [] 
     824        for cc_pair in self.get_conversions(mimecontent.type): 
     825            conversion = cc_pair[0] 
     826            if selected_key == conversion.key or \ 
     827               conversion.mimetype.match(selected_output): ### TODO: conversion.type 
     828                candidates.append(cc_pair) 
    323829        if not candidates: 
    324             raise TracError('No available MIME conversions from %s to %s' % 
    325                             (mimetype, key)) 
     830            raise NoConversion('No available MIME conversions', 
     831                               mimecontent.type, selected_output, selected_key) 
    326832 
    327         # First successful conversion wins 
    328         for ck, name, ext, input_mimettype, output_mimetype, quality, \ 
    329                 converter in candidates: 
    330             output = converter.convert_content(req, mimetype, content, ck) 
    331             if not output: 
    332                 continue 
    333             return (output[0], output[1], ext) 
    334         raise TracError('No available MIME conversions from %s to %s' % 
    335                         (mimetype, key)) 
     833        tab_expanded = None # we don't want to expand tabs more than once. 
    336834 
     835        # First candidate which converts successfully wins. 
     836        for conversion, converter in candidates: 
     837            self.log.debug(u'Attempting conversion of %s using %s and %s' % 
     838                           (mimecontent, conversion, converter)) 
     839            if conversion.expand_tabs and not tab_expanded: 
     840                tab_expanded = unicode(mimecontent).expandtabs(self.tab_width) 
     841                mimecontent = MimeContent(self.env, tab_expanded, 
     842                                          mimecontent.type) 
     843                self.log.debug('tab expansion performed.') 
     844            try: 
     845                res = converter.convert_content(context, conversion, 
     846                                                mimecontent) 
     847                if res: 
     848                    return res, conversion.mimetype 
     849            except Exception, e: 
     850                self.log.warning('MIME conversion using %s failed (%s)' 
     851                                 % (converter, e), exc_info=True) 
     852        raise NoConversion('No MIME conversions succeeded', 
     853                           mimecontent.type, selected_output, selected_key) 
     854 
     855    # -- XHTML rendering and annotations (based on the conversion API) 
     856     
    337857    def get_annotation_types(self): 
    338858        """Generator that returns all available annotation types.""" 
    339859        for annotator in self.annotators: 
    340860            yield annotator.get_annotation_type() 
    341861 
    342     def render(self, req, mimetype, content, filename=None, url=None, 
    343                annotations=None): 
    344         """Render an XHTML preview of the given `content`. 
     862    def render(self, req, mimecontent, annotations=None): 
     863        """Render an XHTML preview of the given `mimecontent`. 
    345864 
    346         `content` is the same as an `IHTMLPreviewRenderer.render`'s 
    347         `content` argument. 
    348  
    349         The specified `mimetype` will be used to select the most appropriate 
    350         `IHTMLPreviewRenderer` implementation available for this MIME type. 
    351         If not given, the MIME type will be infered from the filename or the 
    352         content. 
    353  
    354         Return a string containing the XHTML text. 
     865        Some `annotations` might be requested as well. 
    355866        """ 
    356         if not content: 
    357             return '' 
     867        result, _ = self._convert(ToplevelContext(req), 'text/html', 
     868                                  mimecontent) 
     869        if isinstance(result, Fragment): 
     870            return result             # might be processed further 
     871        elif isinstance(result, basestring): 
     872            self.log.warning('HTML rendering: got %s' % 
     873                             result.__class__.__name__) 
     874            return Markup(to_unicode(result)) # needed for compatibility  
    358875 
    359         # Ensure we have a MIME type for this content 
    360         full_mimetype = mimetype 
    361         if not full_mimetype: 
    362             if hasattr(content, 'read'): 
    363                 content = content.read(self.max_preview_size) 
    364             full_mimetype = self.get_mimetype(filename, content) 
    365         if full_mimetype: 
    366             mimetype = full_mimetype.split(';')[0].strip() # split off charset 
    367         else: 
    368             mimetype = full_mimetype = 'text/plain' # fallback if not binary 
     876        # otherwise, it's an iterable yielding lines 
     877        if annotations: 
     878            return Markup(self._annotate(result, annotations)) 
    369879 
    370         # Determine candidate `IHTMLPreviewRenderer`s 
    371         candidates = [] 
    372         for renderer in self.renderers: 
    373             qr = renderer.get_quality_ratio(mimetype) 
    374             if qr > 0: 
    375                 candidates.append((qr, renderer)) 
    376         candidates.sort(lambda x,y: cmp(y[0], x[0])) 
     880        return html.DIV(html.PRE(Markup(''.join(result))), class_="code") 
    377881 
    378         # First candidate which renders successfully wins. 
    379         # Also, we don't want to expand tabs more than once. 
    380         expanded_content = None 
    381         for qr, renderer in candidates: 
    382             try: 
    383                 self.log.debug('Trying to render HTML preview using %s' 
    384                                % renderer.__class__.__name__) 
    385                 # check if we need to perform a tab expansion 
    386                 rendered_content = content 
    387                 if getattr(renderer, 'expand_tabs', False): 
    388                     if expanded_content is None: 
    389                         content = content_to_unicode(self.env, content, 
    390                                                      full_mimetype) 
    391                         expanded_content = content.expandtabs(self.tab_width) 
    392                     rendered_content = expanded_content 
    393                 result = renderer.render(req, full_mimetype, rendered_content, 
    394                                          filename, url) 
    395                 if not result: 
    396                     continue 
    397                 elif isinstance(result, Fragment): 
    398                     return result 
    399                 elif isinstance(result, basestring): 
    400                     return Markup(to_unicode(result)) 
    401                 elif annotations: 
    402                     return Markup(self._annotate(result, annotations)) 
    403                 else: 
    404                     buf = StringIO() 
    405                     buf.write('<div class="code"><pre>') 
    406                     for line in result: 
    407                         buf.write(line + '\n') 
    408                     buf.write('</pre></div>') 
    409                     return Markup(buf.getvalue()) 
    410             except Exception, e: 
    411                 self.log.warning('HTML preview using %s failed (%s)' 
    412                                  % (renderer, e), exc_info=True) 
    413  
    414882    def _annotate(self, lines, annotations): 
     883        """Add requested `annotations` to the lines' content.""" 
    415884        buf = StringIO() 
    416885        buf.write('<table class="code"><thead><tr>') 
    417886        annotators = [] 
     
    445914        buf.write('</tbody></table>') 
    446915        return buf.getvalue() 
    447916 
     917    # -- Deprecated API (TODO: remove in 0.11) 
     918 
    448919    def get_max_preview_size(self): 
    449920        """Deprecated: use `max_preview_size` attribute directly.""" 
    450921        return self.max_preview_size 
    451922 
    452     def get_charset(self, content='', mimetype=None): 
    453         """Infer the character encoding from the `content` or the `mimetype`. 
    454  
    455         `content` is either a `str` or an `unicode` object. 
    456          
    457         The charset will be determined using this order: 
    458          * from the charset information present in the `mimetype` argument 
    459          * auto-detection of the charset from the `content` 
    460          * the configured `default_charset`  
    461         """ 
    462         if mimetype: 
    463             ctpos = mimetype.find('charset=') 
    464             if ctpos >= 0: 
    465                 return mimetype[ctpos + 8:].strip() 
    466         if isinstance(content, str): 
    467             utf = detect_unicode(content) 
    468             if utf is not None: 
    469                 return utf 
    470         return self.default_charset 
    471  
    472     def get_mimetype(self, filename, content=None): 
    473         """Infer the MIME type from the `filename` or the `content`. 
    474  
    475         `content` is either a `str` or an `unicode` object. 
    476  
    477         Return the detected MIME type, augmented by the 
    478         charset information (i.e. "<mimetype>; charset=..."), 
    479         or `None` if detection failed. 
    480         """ 
    481         # Extend default extension to MIME type mappings with configured ones 
    482         if not self._mime_map: 
    483             self._mime_map = MIME_MAP 
    484             for mapping in self.config['mimeviewer'].getlist('mime_map'): 
    485                 if ':' in mapping: 
    486                     assocations = mapping.split(':') 
    487                     for keyword in assocations: # Note: [0] kept on purpose 
    488                         self._mime_map[keyword] = assocations[0] 
    489  
    490         mimetype = get_mimetype(filename, content, self._mime_map) 
    491         charset = None 
    492         if mimetype: 
    493             charset = self.get_charset(content, mimetype) 
    494         if mimetype and charset and not 'charset' in mimetype: 
    495             mimetype += '; charset=' + charset 
    496         return mimetype 
    497  
    498923    def to_utf8(self, content, mimetype=None): 
    499924        """Convert an encoded `content` to utf-8. 
    500925 
    501926        ''Deprecated in 0.10. You should use `unicode` strings only.'' 
    502927        """ 
    503         return to_utf8(content, self.get_charset(content, mimetype)) 
     928        return to_utf8(content) 
    504929 
    505     def to_unicode(self, content, mimetype=None, charset=None): 
    506         """Convert `content` (an encoded `str` object) to an `unicode` object. 
     930    # -- Utilities 
    507931 
    508         This calls `trac.util.to_unicode` with the `charset` provided, 
    509         or the one obtained by `Mimeview.get_charset()`. 
    510         """ 
    511         if not charset: 
    512             charset = self.get_charset(content, mimetype) 
    513         return to_unicode(content, charset) 
     932    def is_binary(self, typerepr): 
     933        """Checks whether a given `TypeRepr` is binary or not.""" 
     934        return typerepr.is_binary or typerepr.mimetype in self.treat_as_binary 
    514935 
    515936    def configured_modes_mapping(self, renderer): 
    516         """Return a MIME type to `(mode,quality)` mapping for given `option`""" 
     937        """Utility for configurable custom converters 
     938 
     939        Return a MIME type to `(mode,quality)` mapping for given `option`, 
     940        assuming a format of comma-separated <mimetype>:<mode>:<quality> 
     941        associations. 
     942 
     943        See EnscriptConverter and SilverCityConverter. 
     944        """ 
    517945        types, option = {}, '%s_modes' % renderer 
    518946        for mapping in self.config['mimeviewer'].getlist(option): 
    519947            if not mapping: 
     
    526954                                 "option." % (mapping, option)) 
    527955        return types 
    528956     
    529     def preview_to_hdf(self, req, content, length, mimetype, filename, 
    530                        url=None, annotations=None): 
    531         """Prepares a rendered preview of the given `content`. 
    532  
    533         Note: `content` will usually be an object with a `read` method. 
    534         """         
    535         if length >= self.max_preview_size: 
     957    def preview_to_hdf(self, req, mimecontent, annotations=None): 
     958        """Prepares a rendered preview of the given `mimecontent`."""         
     959        if len(mimecontent) >= self.max_preview_size: 
    536960            return {'max_file_size_reached': True, 
    537961                    'max_file_size': self.max_preview_size, 
    538                     'raw_href': url} 
     962                    'raw_href': mimecontent.url} 
    539963        else: 
    540             return {'preview': self.render(req, mimetype, content, filename, 
    541                                            url, annotations), 
    542                     'raw_href': url} 
     964            try: 
     965                preview = self.render(req, mimecontent, annotations) 
     966            except NoConversion, e: 
     967                preview = None 
     968            return {'preview': preview, 
     969                    'raw_href': mimecontent.url} 
    543970 
    544     def send_converted(self, req, in_type, content, selector, filename='file'): 
    545         """Helper method for converting `content` and sending it directly. 
     971    def send_converted(self, req, selector, mimecontent): 
     972        """Helper method for converting `mimecontent` and sending it directly. 
    546973 
    547         `selector` can be either a key or a MIME Type.""" 
     974        `selector` can be either a key or the expected output MIME Type. 
     975        """ 
    548976        from trac.web import RequestDone 
    549         content, output_type, ext = self.convert_content(req, in_type, 
    550                                                          content, selector) 
     977        result = self.convert(ToplevelContext(req), selector, mimecontent) 
    551978        req.send_response(200) 
    552         req.send_header('Content-Type', output_type) 
    553         req.send_header('Content-Disposition', 'filename=%s.%s' % (filename, 
    554                                                                    ext)) 
     979        req.send_header('Content-Type', result.type.mimetype_charset) 
     980        req.send_header('Content-Disposition', 'filename=%s.%s' % 
     981                        (result.filename, result.type.extension)) 
    555982        req.end_headers() 
    556         req.write(content) 
    557         raise RequestDone         
    558          
     983        req.write(result.encode('utf-8')) 
     984        raise RequestDone 
    559985 
     986        
     987 
     988# utility for Mimeview._annotate 
    560989def _html_splitlines(lines): 
    561990    """Tracks open and close tags in lines of HTML text and yields lines that 
    562991    have no tags spanning more than one line.""" 
     
    6041033    def annotate_line(self, number, content): 
    6051034        return '<th id="L%s"><a href="#L%s">%s</a></th>' % (number, number, 
    6061035                                                            number) 
     1036#        return html.TH(html.A(number, href="#L%s" % number), id=number) 
    6071037 
    6081038 
    609 # -- Default renderers 
     1039# -- Default TEXT_HTML converters (previously ''IHTMLPreviewRenderer'') 
    6101040 
    6111041class PlainTextRenderer(Component): 
    612     """HTML preview renderer for plain text, and fallback for any kind of text 
    613     for which no more specific renderer is available. 
     1042    """Convert text to HTML-escaped text. 
     1043 
     1044    Will be used as a fallback for any kind of text 
     1045    for which no more specific HTML converter is available. 
    6141046    """ 
    615     implements(IHTMLPreviewRenderer) 
     1047    implements(IContentConverter) 
    6161048 
    617     expand_tabs = True 
    618  
    619     TREAT_AS_BINARY = [ 
    620         'application/pdf', 
    621         'application/postscript', 
    622         'application/rtf' 
    623     ] 
    624  
    625     def get_quality_ratio(self, mimetype): 
    626         if mimetype in self.TREAT_AS_BINARY: 
    627             return 0 
    628         return 1 
    629  
    630     def render(self, req, mimetype, content, filename=None, url=None): 
    631         if is_binary(content): 
     1049    def get_supported_conversions(self, input): 
     1050        if Mimeview(self.env).is_binary(input): 
     1051            return 
     1052        yield Conversion(key='default', 
     1053                         quality=TEXT_PLAIN.match(input) and 8 or 1, 
     1054                         mimetype=TEXT_HTML, expand_tabs=True) 
     1055         
     1056    def convert_content(self, context, conversion, mimecontent): 
     1057        if mimecontent.is_binary: 
    6321058            self.env.log.debug("Binary data; no preview available") 
    633             return 
     1059        else: 
     1060            if not TEXT_PLAIN.match(mimecontent.type): 
     1061                self.env.log.debug("Fallback to plain text renderer.") 
     1062            mimecontent.type = MimeType('text/plain', mimecontent.encoding) 
     1063            return mimecontent 
    6341064 
    635         self.env.log.debug("Using default plain text mimeviewer") 
    636         content = content_to_unicode(self.env, content, mimetype) 
    637         for line in content.splitlines(): 
    638             yield escape(line) 
    6391065 
    640  
    6411066class ImageRenderer(Component): 
    642     """Inline image display. Here we don't need the `content` at all.""" 
    643     implements(IHTMLPreviewRenderer) 
     1067    """Inline image display. 
    6441068 
    645     def get_quality_ratio(self, mimetype): 
    646         if mimetype.startswith('image/'): 
    647             return 8 
    648         return 0 
     1069    This renderer doesn't need the actual data at all, only the url. 
     1070    """ 
     1071    implements(IContentConverter) 
    6491072 
    650     def render(self, req, mimetype, content, filename=None, url=None): 
    651         if url: 
    652             return html.DIV(html.IMG(src=url,alt=filename), 
     1073    def get_supported_conversions(self, input): 
     1074        if MimeType('^image/').match(input, regexp=True): 
     1075            yield Conversion(key='image', quality=8, mimetype=TEXT_HTML) 
     1076 
     1077    def convert_content(self, context, conversion, mimecontent): 
     1078        if mimecontent.url: 
     1079            return html.DIV(html.IMG(src=mimecontent.url, 
     1080                                     alt=mimecontent.filename), 
    6531081                            class_="image-file") 
    6541082 
    6551083 
    6561084class WikiTextRenderer(Component): 
    6571085    """Render files containing Trac's own Wiki formatting markup.""" 
    658     implements(IHTMLPreviewRenderer) 
    6591086 
    660     def get_quality_ratio(self, mimetype): 
    661         if mimetype in ('text/x-trac-wiki', 'application/x-trac-wiki'): 
    662             return 8 
    663         return 0 
     1087    implements(IContentConverter) 
    6641088 
    665     def render(self, req, mimetype, content, filename=None, url=None): 
     1089    def get_supported_conversions(self, input): 
     1090        from trac.wiki import TEXT_X_TRAC_WIKI, APPLICATION_X_TRAC_WIKI 
     1091        if TEXT_X_TRAC_WIKI.match(input) or \ 
     1092               APPLICATION_X_TRAC_WIKI.match(input): 
     1093            yield Conversion(key='wiki', quality=8, mimetype=TEXT_HTML) 
     1094 
     1095    def convert_content(self, context, conversion, mimecontent): 
    6661096        from trac.wiki import wiki_to_html 
    667         return wiki_to_html(content_to_unicode(self.env, content, mimetype), 
    668                             self.env, req) 
     1097        return MimeContent(self.env, wiki_to_html(unicode(mimecontent), 
     1098                                                  self.env, context.req), 
     1099                           TEXT_HTML) 
  • trac/mimeview/silvercity.py

     
    2424 
    2525from trac.core import * 
    2626from trac.config import ListOption 
    27 from trac.mimeview.api import IHTMLPreviewRenderer, Mimeview 
     27from trac.mimeview.api import IContentConverter, Mimeview, MimeContent, \ 
     28                              Conversion, TEXT_HTML 
    2829 
    2930__all__ = ['SilverCityRenderer'] 
    3031 
     
    5758CRLF_RE = re.compile('\r$', re.MULTILINE) 
    5859 
    5960 
    60 class SilverCityRenderer(Component): 
     61class SilverCityConverter(Component): 
    6162    """Syntax highlighting based on SilverCity.""" 
    6263 
    63     implements(IHTMLPreviewRenderer) 
     64    implements(IContentConverter) 
    6465 
    65     enscript_modes = ListOption('mimeviewer', 'silvercity_modes', 
    66         '', 
     66    silvercity_modes = ListOption('mimeviewer', 'silvercity_modes', doc= 
    6767        """List of additional MIME types known by SilverCity. 
     68         
    6869        For each, a tuple `mimetype:mode:quality` has to be 
    6970        specified, where `mimetype` is the MIME type, 
    7071        `mode` is the corresponding SilverCity mode to be used 
     
    7980    def __init__(self): 
    8081        self._types = None 
    8182 
    82     def get_quality_ratio(self, mimetype): 
     83    def get_supported_conversions(self, input): 
    8384        # Extend default MIME type to mode mappings with configured ones 
    8485        if not self._types: 
    8586            self._types = {} 
    8687            self._types.update(types) 
    8788            self._types.update( 
    8889                Mimeview(self.env).configured_modes_mapping('silvercity')) 
    89         return self._types.get(mimetype, (None, 0))[1] 
     90        quality_ratio = self._types.get(input.mimetype, (None, 0))[1] 
     91        if quality_ratio: 
     92            yield Conversion('silvercity', quality_ratio, TEXT_HTML) 
    9093 
    91     def render(self, req, mimetype, content, filename=None, rev=None): 
     94    def convert_content(self, context, conversion, mimecontent): 
    9295        import SilverCity 
    9396        try: 
    94             mimetype = mimetype.split(';', 1)[0] 
    95             typelang = self._types[mimetype] 
     97            typelang = self._types[mimecontent.type.mimetype] 
    9698            lang = typelang[0] 
    9799            module = getattr(SilverCity, lang) 
    98100            generator = getattr(module, lang + "HTMLGenerator") 
     
    108110            raise Exception, err 
    109111 
    110112        # SilverCity does not like unicode strings 
    111         content = content.encode('utf-8') 
     113        content = mimecontent.encode('utf-8') 
    112114         
    113115        # SilverCity generates extra empty line against some types of 
    114116        # the line such as comment or #include with CRLF. So we 
     
    122124        span_default_re = re.compile(r'<span class="\w+_default">(.*?)</span>', 
    123125                                     re.DOTALL) 
    124126        html = span_default_re.sub(r'\1', br_re.sub('', buf.getvalue())) 
    125          
    126         # Convert the output back to a unicode string 
    127         html = html.decode('utf-8') 
    128127 
    129128        # SilverCity generates _way_ too many non-breaking spaces... 
    130129        # We don't need them anyway, so replace them by normal spaces 
    131         return html.replace('&nbsp;', ' ').splitlines() 
     130        return MimeContent(self.env, html.replace('&nbsp;', ' '), TEXT_HTML) 
  • trac/mimeview/patch.py

     
    1616#         Ludvig Strigeus 
    1717 
    1818from trac.core import * 
    19 from trac.mimeview.api import content_to_unicode, IHTMLPreviewRenderer, Mimeview 
     19from trac.mimeview.api import IHTMLPreviewRenderer, Mimeview 
    2020from trac.util.markup import escape, Markup 
    2121from trac.web.chrome import add_stylesheet 
    2222 
     
    6565    def render(self, req, mimetype, content, filename=None, rev=None): 
    6666        from trac.web.clearsilver import HDFWrapper 
    6767 
    68         content = content_to_unicode(self.env, content, mimetype) 
     68        content = unicode(content) 
    6969        d = self._diff_to_hdf(content.splitlines(), 
    7070                              Mimeview(self.env).tab_width) 
    7171        if not d: 
  • trac/mimeview/enscript.py

     
    135135        cmdline += ' --color -h -q --language=html -p - -E%s' % mode 
    136136        self.env.log.debug("Enscript command line: %s" % cmdline) 
    137137 
     138        content = unicode(content) 
    138139        np = NaivePopen(cmdline, content.encode('utf-8'), capturestderr=1) 
    139140        if np.errorlevel or np.err: 
    140141            err = 'Running (%s) failed: %s, %s.' % (cmdline, np.errorlevel, 
  • trac/mimeview/php.py

     
    2020 
    2121from trac.core import * 
    2222from trac.config import Option 
    23 from trac.mimeview.api import IHTMLPreviewRenderer, content_to_unicode 
     23from trac.mimeview.api import IHTMLPreviewRenderer 
    2424from trac.util import NaivePopen 
    2525from trac.util.markup import Deuglifier 
    2626 
     
    7979        cmdline += ' -sn' 
    8080        self.env.log.debug("PHP command line: %s" % cmdline) 
    8181         
    82         content = content_to_unicode(self.env, content, mimetype) 
     82        content = unicode(content) 
    8383        content = content.encode('utf-8') 
    8484        np = NaivePopen(cmdline, content, capturestderr=1) 
    8585        if np.errorlevel or np.err: 
  • trac/ticket/web_ui.py

     
    2323from trac.config import BoolOption, Option 
    2424from trac.core import * 
    2525from trac.env import IEnvironmentSetupParticipant 
     26from trac.mimeview.api import * 
    2627from trac.ticket import Milestone, Ticket, TicketSystem, ITicketManipulator 
    2728from trac.ticket.notification import TicketNotifyEmail 
    2829from trac.Timeline import ITimelineEventProvider 
     
    3334from trac.web import IRequestHandler 
    3435from trac.web.chrome import add_link, add_stylesheet, INavigationContributor 
    3536from trac.wiki import wiki_to_html, wiki_to_oneliner 
    36 from trac.mimeview.api import Mimeview, IContentConverter 
    3737 
    3838 
    3939class InvalidTicket(TracError): 
    4040    """Exception raised when a ticket fails validation.""" 
    4141 
    4242 
     43TEXT_CSV = MimeType('text/csv', 'utf-8', 
     44                    'Comma-delimited Text', 'csv') 
     45TEXT_TSV = MimeType('text/tab-separated-values', 'utf-8', 
     46                    'Tab-delimited Text', 'tsv') 
     47 
     48 
    4349class TicketModuleBase(Component): 
    4450    # FIXME: temporary place-holder for unified ticket validation until 
    4551    #        ticket controller unification is merged 
     
    211217 
    212218    # IContentConverter methods 
    213219 
    214     def get_supported_conversions(self): 
    215         yield ('csv', 'Comma-delimited Text', 'csv', 
    216                'trac.ticket.Ticket', 'text/csv', 8) 
    217         yield ('tab', 'Tab-delimited Text', 'tsv', 
    218                'trac.ticket.Ticket', 'text/tab-separated-values', 8) 
    219         yield ('rss', 'RSS Feed', 'xml', 
    220                'trac.ticket.Ticket', 'application/rss+xml', 8) 
     220    def get_supported_conversions(self, input): 
     221        if input.match(Ticket): 
     222            yield Conversion('csv', 8, TEXT_CSV) 
     223            yield Conversion('tab', 8, TEXT_TSV) 
     224            yield Conversion('rss', 8, APPLICATION_RSS_XML) 
    221225 
    222     def convert_content(self, req, mimetype, ticket, key): 
     226    def convert_content(self, context, conversion, objcontent): 
     227        key = conversion.key 
    223228        if key == 'csv': 
    224             return self.export_csv(ticket, mimetype='text/csv') 
     229            return self.export_csv(objcontent, TEXT_CSV) 
    225230        elif key == 'tab': 
    226             return self.export_csv(ticket, sep='\t', 
    227                                    mimetype='text/tab-separated-values') 
     231            return self.export_csv(objcontent, TEXT_TSV, sep='\t') 
    228232        elif key == 'rss': 
    229             return self.export_rss(req, ticket) 
     233            return self.export_rss(context.req, objcontent) 
    230234 
    231235    # INavigationContributor methods 
    232236 
     
    281285        self._insert_ticket_data(req, db, ticket, 
    282286                                 get_reporter_id(req, 'author')) 
    283287 
    284         mime = Mimeview(self.env) 
    285288        format = req.args.get('format') 
    286289        if format: 
    287             mime.send_converted(req, 'trac.ticket.Ticket', ticket, format, 
    288                                 'ticket_%d' % ticket.id) 
     290            Mimeview(self.env).send_converted( 
     291                req, format, ObjectContent(self.env, ticket, 
     292                                           filename='ticket_%d' % ticket.id)) 
    289293 
    290294        # If the ticket is being shown in the context of a query, add 
    291295        # links to help navigate in the query result set 
     
    308312        add_stylesheet(req, 'common/css/ticket.css') 
    309313 
    310314        # Add registered converters 
    311         for conversion in mime.get_supported_conversions('trac.ticket.Ticket'): 
    312             conversion_href = req.href.ticket(ticket.id, format=conversion[0]) 
    313             add_link(req, 'alternate', conversion_href, conversion[1], 
    314                      conversion[3]) 
     315        for conv, _ in Mimeview(self.env).get_conversions(TypeWrapper(Ticket)): 
     316            conversion_href = req.href.ticket(ticket.id, format=conv.key) 
     317            add_link(req, 'alternate', conversion_href, 
     318                     conv.mimetype.name, conv.mimetype.mimetype_charset) 
    315319 
    316320        return 'ticket.cs', None 
    317321 
     
    429433 
    430434    # Internal methods 
    431435 
    432     def export_csv(self, ticket, sep=',', mimetype='text/plain'): 
     436    def export_csv(self, objcontent, mimetype, sep=','): 
     437        ticket = objcontent.content 
    433438        content = StringIO() 
    434439        content.write(sep.join(['id'] + [f['name'] for f in ticket.fields]) 
    435440                      + CRLF) 
     
    438443                                 .replace(sep, '_').replace('\\', '\\\\') 
    439444                                 .replace('\n', '\\n').replace('\r', '\\r') 
    440445                                 for f in ticket.fields]) + CRLF) 
    441         return (content.getvalue(), '%s;charset=utf-8' % mimetype) 
     446        return MimeContent(self.env, content.getvalue(), mimetype, 
     447                           filename=objcontent.filename) 
    442448         
    443     def export_rss(self, req, ticket): 
     449    def export_rss(self, req, objcontent): 
     450        ticket = objcontent.content 
    444451        db = self.env.get_db_cnx() 
    445452        changes = [] 
    446453        change_summary = {} 
     
    471478            change['title'] = '; '.join(['%s %s' % (', '.join(v), k) for k, v \ 
    472479                                         in change_summary.iteritems()]) 
    473480        req.hdf['ticket.changes'] = changes 
    474         return (req.hdf.render('ticket_rss.cs'), 'application/rss+xml') 
     481        return MimeContent(self.env, req.hdf.render('ticket_rss.cs'), 
     482                           APPLICATION_RSS_XML, filename=objcontent.filename) 
    475483 
    476  
    477484    def _do_save(self, req, db, ticket): 
    478485        if req.perm.has_permission('TICKET_CHGPROP'): 
    479486            # TICKET_CHGPROP gives permission to edit the ticket 
  • 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 
    77from trac.web.href import Href 
    88 
     
    1717        self.mimeview = Mimeview(self.env) 
    1818        self.req = Mock(hdf=HDFWrapper(['./templates']), 
    1919                        base_path='/trac.cgi', path_info='', 
    20                         href=Href('/trac.cgi')) 
     20                        href=Href('/trac.cgi'), 
     21                        abs_href=Href('http://example.org/trac.cgi')) 
    2122 
    2223    def _create_a_ticket(self): 
    2324        # 1. Creating ticket 
     
    2930        return ticket 
    3031 
    3132    def test_conversions(self): 
    32         conversions = self.mimeview.get_supported_conversions( 
    33             'trac.ticket.Ticket') 
    34         expected = sorted([('csv', 'Comma-delimited Text', 'csv', 
    35                            'trac.ticket.Ticket', 'text/csv', 8, 
    36                            self.ticket_module), 
    37                           ('tab', 'Tab-delimited Text', 'tsv', 
    38                            'trac.ticket.Ticket', 'text/tab-separated-values', 8, 
    39                            self.ticket_module), 
    40                            ('rss', 'RSS Feed', 'xml', 
    41                             '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), 
    4245                            self.ticket_module)], 
    4346                          key=lambda i: i[-1], reverse=True) 
    44         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)) 
    4553 
    4654    def test_csv_conversion(self): 
    4755        ticket = self._create_a_ticket() 
    48         csv = self.mimeview.convert_content(self.req, 'trac.ticket.Ticket', 
    49                                             ticket, 'csv') 
     56        csv = self.mimeview.convert_content(self.req, 
     57                                            ticket, 'trac.ticket.Ticket', 
     58                                            'csv') 
    5059        self.assertEqual((u'id,summary,reporter,owner,description,keywords,cc' 
    5160                          '\r\nNone,Foo,santa,,Bar,,\r\n', 
    5261                          'text/csv;charset=utf-8', 'csv'), csv) 
     
    5463 
    5564    def test_tab_conversion(self): 
    5665        ticket = self._create_a_ticket() 
    57         csv = self.mimeview.convert_content(self.req, 'trac.ticket.Ticket', 
    58                                             ticket, 'tab') 
     66        csv = self.mimeview.convert_content(self.req, 
     67                                            ticket, 'trac.ticket.Ticket', 
     68                                            'tab') 
    5969        self.assertEqual((u'id\tsummary\treporter\towner\tdescription\tkeywords' 
    6070                          '\tcc\r\nNone\tFoo\tsanta\t\tBar\t\t\r\n', 
    6171                          'text/tab-separated-values;charset=utf-8', 'tsv'), 
     
    6474    def test_rss_conversion(self): 
    6575        ticket = self._create_a_ticket() 
    6676        content, mimetype, ext = self.mimeview.convert_content( 
    67             self.req, 'trac.ticket.Ticket', ticket, 'rss') 
     77            self.req, ticket, 'trac.ticket.Ticket', 'rss') 
    6878        self.assertEqual(('<?xml version="1.0"?>\n<!-- RSS generated by Trac v ' 
    6979                          'on  -->\n<rss version="2.0">\n <channel>\n   ' 
    7080                          '<title>Ticket </title>\n  <link></link>\n  ' 
  • trac/versioncontrol/web_ui/util.py

     
    1616# Author: Jonas Borgström <jonas@edgewall.com> 
    1717#         Christian Boos <cboos@neuf.fr> 
    1818 
     19import posixpath 
    1920import re 
    2021import urllib 
    2122 
    2223from trac.core import TracError 
     24from trac.mimeview.api import MimeContentBase 
    2325from trac.util.datefmt import format_datetime, pretty_timedelta 
    2426from trac.util.text import shorten_line 
    2527from trac.util.markup import escape, html, Markup 
     
    2729from trac.wiki import wiki_to_html, wiki_to_oneliner 
    2830 
    2931__all__ = ['get_changes', 'get_path_links', 'get_path_rev_line', 
    30            'get_existing_node', 'render_node_property'] 
     32           'get_existing_node', 'render_node_property', 
     33           'NodeMimeContent'] 
    3134 
     35 
     36# MimeContent wrapper for repository files 
     37# 
     38# -- This could be part of trac.versioncontrol.api itself, 
     39#    if introducing a dependency on trac.mimeview.api is 
     40#    considered to be OK. 
     41 
     42CHUNK_SIZE = 4096 
     43 
     44class NodeMimeContent(MimeContentBase): 
     45    """Encapsulation of the content of a file in the repository.""" 
     46             
     47    def __init__(self, env, node, url=None): 
     48        self.node = node 
     49        MimeContentBase.__init__(self, env, node.content_type, 
     50                                 posixpath.basename(node.path), url) 
     51        self._excerpt = None 
     52 
     53    def __iter__(self): 
     54        """Get chunks of the byte content""" 
     55        chunk = self.excerpt 
     56        self._excerpt = None 
     57        while True: 
     58            if not chunk: 
     59                return 
     60            yield chunk 
     61            chunk = self._content.read(CHUNK_SIZE) 
     62 
     63    def __len__(self): 
     64        return self.node.get_content_length() 
     65     
     66    def _get_excerpt(self): 
     67        if not self._excerpt: 
     68            self._content = self.node.get_content() 
     69            self._excerpt = self._content.read(CHUNK_SIZE) 
     70        return self._excerpt 
     71             
     72 
    3273def get_changes(env, repos, revs, full=None, req=None, format=None): 
    3374    db = env.get_db_cnx() 
    3475    changes = {} 
  • trac/versioncontrol/web_ui/changeset.py

     
    2626from trac import util 
    2727from trac.config import BoolOption, IntOption 
    2828from trac.core import * 
    29 from trac.mimeview import Mimeview, is_binary 
    3029from trac.perm import IPermissionRequestor 
    3130from trac.Search import ISearchSource, search_to_sql, shorten_result 
    3231from trac.Timeline import ITimelineEventProvider 
     
    3635from trac.versioncontrol import Changeset, Node 
    3736from trac.versioncontrol.diff import get_diff_options, hdf_diff, unified_diff 
    3837from trac.versioncontrol.svn_authz import SubversionAuthorizer 
    39 from trac.versioncontrol.web_ui.util import render_node_property 
     38from trac.versioncontrol.web_ui.util import * 
    4039from trac.web import IRequestHandler 
    4140from trac.web.chrome import INavigationContributor, add_link, add_stylesheet 
    4241from trac.wiki import wiki_to_html, wiki_to_oneliner, IWikiSyntaxProvider, \ 
     
    439438            The list is empty when no differences between comparable files 
    440439            are detected, but the return value is None for non-comparable files. 
    441440            """ 
    442             old_content = old_node.get_content().read() 
    443             if is_binary(old_content): 
     441            old_content = NodeMimeContent(self.env, old_node) 
     442            if old_content.is_binary: 
    444443                return None 
    445444 
    446             new_content = new_node.get_content().read() 
    447             if is_binary(new_content): 
     445            new_content = NodeMimeContent(self.env, new_content) 
     446            if new_content.is_binary: 
    448447                return None 
    449448 
    450             mview = Mimeview(self.env) 
    451             old_content = mview.to_unicode(old_content, old_node.content_type) 
    452             new_content = mview.to_unicode(new_content, new_node.content_type) 
     449            old_content = unicode(old_content) 
     450            new_content = unicode(new_content) 
    453451 
    454452            if old_content != new_content: 
    455453                context = 3 
     
    526524                        'filename=%s.diff' % filename) 
    527525        req.end_headers() 
    528526 
    529         mimeview = Mimeview(self.env) 
    530527        for old_node, new_node, kind, change in repos.get_changes(**diff): 
    531528            # TODO: Property changes 
    532529 
     
    536533 
    537534            new_content = old_content = '' 
    538535            new_node_info = old_node_info = ('','') 
    539             mimeview = Mimeview(self.env) 
    540536 
    541537            if old_node: 
    542                 old_content = old_node.get_content().read() 
    543                 if is_binary(old_content): 
     538                old_content = NodeMimeContent(self.env, old_node) 
     539                if old_content.is_binary: 
    544540                    continue 
    545541                old_node_info = (old_node.path, old_node.rev) 
    546                 old_content = mimeview.to_unicode(old_content, 
    547                                                   old_node.content_type) 
     542                old_content = unicode(old_content) 
    548543            if new_node: 
    549                 new_content = new_node.get_content().read() 
    550                 if is_binary(new_content): 
     544                new_content = NodeMimeContent(self.env, new_node) 
     545                if new_content.is_binary: 
    551546                    continue 
    552547                new_node_info = (new_node.path, new_node.rev) 
    553548                new_path = new_node.path 
    554                 new_content = mimeview.to_unicode(new_content, 
    555                                                   new_node.content_type) 
     549                new_content = unicode(new_content) 
    556550            else: 
    557551                old_node_path = repos.normalize_path(old_node.path) 
    558552                diff_old_path = repos.normalize_path(diff.old_path) 
  • 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, MimeType 
    2727from trac.perm import IPermissionRequestor 
    2828from trac.util import sorted, embedded_numbers 
    2929from trac.util.datefmt import http_date, format_datetime, pretty_timedelta 
     
    3636from trac.versioncontrol.web_ui.util import * 
    3737 
    3838 
    39 CHUNK_SIZE = 4096 
    40  
    41  
    4239class BrowserModule(Component): 
    4340 
    4441    implements(INavigationContributor, IPermissionRequestor, IRequestHandler, 
     
    199196    def _render_file(self, req, repos, node, rev=None): 
    200197        req.perm.assert_permission('FILE_VIEW') 
    201198 
    202         mimeview = Mimeview(self.env) 
     199        format = req.args.get('format') 
    203200 
    204         # MIME type detection 
    205         content = node.get_content() 
    206         chunk = content.read(CHUNK_SIZE) 
    207         mime_type = node.content_type 
    208         if not mime_type or mime_type == 'application/octet-stream': 
    209             mime_type = mimeview.get_mimetype(node.name, chunk) or \ 
    210                         mime_type or 'text/plain' 
     201        raw_href = req.href.browser(node.path, rev=rev, format='raw') 
    211202 
     203        mimecontent = NodeMimeContent(self.env, node, raw_href) 
     204 
    212205        # Eventually send the file directly 
    213         format = req.args.get('format') 
    214         if format in ['raw', 'txt']: 
     206        if format in ('raw', 'txt'): 
     207            if format == 'txt': 
     208                type = MimeType('text/plain', mimecontent.encoding) 
     209            else: 
     210                type = mimecontent.type 
    215211            req.send_response(200) 
    216             req.send_header('Content-Type', 
    217                             format == 'txt' and 'text/plain' or mime_type) 
    218             req.send_header('Content-Length', node.content_length) 
     212            req.send_header('Content-Type', type.mimetype_charset) 
     213            req.send_header('Content-Length', len(mimecontent)) 
    219214            req.send_header('Last-Modified', http_date(node.last_modified)) 
    220215            req.end_headers() 
    221216 
    222             while 1: 
    223                 if not chunk: 
    224                     raise RequestDone 
     217            for chunk in mimecontent: 
    225218                req.write(chunk) 
    226                 chunk = content.read(CHUNK_SIZE) 
     219            raise RequestDone 
    227220        else: 
    228221            # The changeset corresponding to the last change on `node`  
    229222            # is more interesting than the `rev` changeset. 
    230             changeset = repos.get_changeset(node.rev) 
     223            changeset = repos.get_changeset(node.created_rev) 
    231224 
    232225            message = changeset.message or '--' 
    233226            if self.config['changeset'].getbool('wiki_format_messages'): 
     
    238231 
    239232            req.hdf['file'] = { 
    240233                'rev': node.rev, 
    241                 'changeset_href': req.href.changeset(node.rev), 
     234                'changeset_href': req.href.changeset(node.created_rev), 
    242235                'date': format_datetime(changeset.date), 
    243236                'age': pretty_timedelta(changeset.date), 
    244237                'size': pretty_size(node.content_length), 
     
    246239                'message': message 
    247240            }  
    248241 
     242            mimetype = mimecontent.type.mimetype 
     243             
    249244            # add ''Plain Text'' alternate link if needed 
    250             if not is_binary(chunk) and mime_type != 'text/plain': 
     245            if not mimecontent.is_binary and mimetype != 'text/plain': 
    251246                plain_href = req.href.browser(node.path, rev=rev, format='txt') 
    252247                add_link(req, 'alternate', plain_href, 'Plain Text', 
    253248                         'text/plain') 
    254249 
    255250            # add ''Original Format'' alternate link (always) 
    256             raw_href = req.href.browser(node.path, rev=rev, format='raw') 
    257             add_link(req, 'alternate', raw_href, 'Original Format', mime_type) 
     251            add_link(req, 'alternate', raw_href, 'Original Format', mimetype) 
    258252 
    259253            self.log.debug("Rendering preview of node %s@%s with mime-type %s" 
    260                            % (node.name, str(rev), mime_type)) 
     254                           % (node.name, str(rev), mimetype)) 
    261255 
    262             del content # the remainder of that content is not needed 
     256            req.hdf['file'] = Mimeview(self.env).preview_to_hdf( 
     257                req, mimecontent, annotations=['lineno']) 
    263258 
    264             req.hdf['file'] = mimeview.preview_to_hdf( 
    265                 req, node.get_content(), node.get_content_length(), mime_type, 
    266                 node.created_path, raw_href, annotations=['lineno']) 
    267  
    268259            add_stylesheet(req, 'common/css/code.css') 
    269260 
    270261    # IWikiSyntaxProvider methods 
  • trac/wiki/web_ui.py

     
    3434from trac.web import HTTPNotFound, IRequestHandler 
    3535from trac.wiki.api import IWikiPageManipulator, WikiSystem 
    3636from trac.wiki.model import WikiPage 
    37 from trac.wiki.formatter import wiki_to_html, wiki_to_oneliner 
    38 from trac.mimeview.api import Mimeview, IContentConverter 
     37from trac.wiki.formatter import wiki_to_html, wiki_to_oneliner, \ 
     38                                TEXT_X_TRAC_WIKI, APPLICATION_X_TRAC_WIKI 
     39from trac.mimeview.api import Mimeview, IContentConverter, Conversion, \ 
     40                              MimeContent, TEXT_PLAIN 
    3941 
    4042 
    4143class InvalidWikiPage(TracError): 
     
    5052    page_manipulators = ExtensionPoint(IWikiPageManipulator) 
    5153 
    5254    # IContentConverter methods 
    53     def get_supported_conversions(self): 
    54         yield ('txt', 'Plain Text', 'txt', 'text/x-trac-wiki', 'text/plain', 9) 
    5555 
    56     def convert_content(self, req, mimetype, content, key): 
    57         return (content, 'text/plain;charset=utf-8') 
     56    def get_supported_conversions(self, input): 
     57        if TEXT_X_TRAC_WIKI.match(input) or \ 
     58               APPLICATION_X_TRAC_WIKI.match(input): 
     59            yield Conversion('txt', quality=8, mimetype=TEXT_PLAIN) 
    5860 
     61    def convert_content(self, context, conversion, content): 
     62        return content # identity transform 
     63 
    5964    # INavigationContributor methods 
    6065 
    6166    def get_active_navigation_item(self, req): 
     
    128133        else: 
    129134            format = req.args.get('format') 
    130135            if format: 
    131                 Mimeview(self.env).send_converted(req, 'text/x-trac-wiki', 
    132                                                   page.text, format, page.name) 
     136                wiki_content = MimeContent(self.env, page.text, 
     137                                           TEXT_X_TRAC_WIKI, 
     138                                           filename=page.name) 
     139                Mimeview(self.env).send_converted(req, format, wiki_content) 
    133140            self._render_view(req, db, page) 
    134141 
    135142        req.hdf['wiki.action'] = action 
     
    429436            req.hdf['html.norobots'] = 1 
    430437 
    431438        # Add registered converters 
    432         for conversion in Mimeview(self.env).get_supported_conversions( 
    433                                              'text/x-trac-wiki'): 
     439        for conv, _ in Mimeview(self.env).get_conversions(TEXT_X_TRAC_WIKI): 
     440            if conv.key in ('wiki', 'default'): 
     441                continue 
    434442            conversion_href = req.href.wiki(page.name, version=version, 
    435                                             format=conversion[0]) 
    436             add_link(req, 'alternate', conversion_href, conversion[1], 
    437                      conversion[3]) 
     443                                            format=conv.key) 
     444            add_link(req, 'alternate', conversion_href, 
     445                     conv.mimetype.name, conv.mimetype.mimetype_charset) 
    438446 
    439447        latest_page = WikiPage(self.env, page.name) 
    440448        req.hdf['wiki'] = {'exists': page.exists, 
  • trac/wiki/formatter.py

     
    3131from trac.util.markup import escape, Markup, Element, html 
    3232 
    3333__all__ = ['wiki_to_html', 'wiki_to_oneliner', 'wiki_to_outline', 
    34            'wiki_to_link', 'Formatter' ] 
     34           'wiki_to_link', 'Formatter', 
     35           'TEXT_X_TRAC_WIKI', 'APPLICATION_X_TRAC_WIKI'] 
    3536 
    3637 
     38TEXT_X_TRAC_WIKI = MimeType('text/x-trac-wiki', 
     39                            name='Trac Wiki Text', extension='txt') 
     40 
     41APPLICATION_X_TRAC_WIKI = MimeType('application/x-trac-wiki', 
     42                                   name='Trac Wiki Text', extension='txt') 
     43 
     44 
    3745def system_message(msg, text=None): 
    3846    return html.DIV(html.STRONG(msg), text and html.PRE(text), 
    3947                    class_="system-message") 
     
    4351 
    4452    _code_block_re = re.compile('^<div(?:\s+class="([^"]+)")?>(.*)</div>$') 
    4553 
    46     def __init__(self, env, name): 
    47         # TODO: transmit `formatter` argument 
    48         self.env = env 
     54    def __init__(self, formatter, name): 
     55        self.formatter = formatter 
     56        self.env = formatter.env 
    4957        self.name = name 
    5058        self.error = None 
    5159        self.macro_provider = None 
     60        self.mimetype = None 
    5261 
    5362        builtin_processors = {'html': self._html_processor, 
    5463                              'default': self._default_processor, 
     
    6574                        break 
    6675        if not self.processor: 
    6776            # Find a matching mimeview renderer 
    68             from trac.mimeview.api import Mimeview 
    69             mimetype = Mimeview(self.env).get_mimetype(self.name) 
    70             if mimetype: 
    71                 self.name = mimetype 
     77            mimetype = Mimeview(self.env).lookup(self.name) 
     78            if not APPLICATION_OCTET_STREAM.match(mimetype): 
     79                self.mimetype = mimetype 
    7280                self.processor = self._mimeview_processor 
    7381            else: 
    7482                self.processor = self._default_processor 
     
    94102    # generic processors 
    95103 
    96104    def _macro_processor(self, req, text): 
    97         # TODO: macro should take a `formatter` argument 
     105        # TODO: macro should take a `formatter` argument (0.11) 
    98106        self.env.log.debug('Executing Wiki macro %s by provider %s' 
    99107                           % (self.name, self.macro_provider)) 
    100108        return self.macro_provider.render_macro(req, self.name, text) 
    101109 
    102110    def _mimeview_processor(self, req, text): 
    103         # TODO: transmit context from `formatter` 
    104         return Mimeview(self.env).render(req, self.name, text) 
     111        class FormatterContext(object): 
     112            def __init__(self, req): 
     113                self.req = req 
     114        blockcontent = MimeContent(self.env, text, self.mimetype) 
     115        return Mimeview(self.env).convert(self.formatter, 'text/html', 
     116                                          blockcontent) 
     117                                          
    105118 
    106119    def process(self, req, text, in_paragraph=False): 
    107120        if self.error: 
     
    429442            return '<br />' 
    430443        args = fullmatch.group('macroargs') 
    431444        try: 
    432             macro = WikiProcessor(self.env, name) 
     445            macro = WikiProcessor(self, name) 
    433446            return macro.process(self.req, args, True) 
    434447        except Exception, e: 
    435448            self.env.log.error('Macro %s(%s) failed' % (name, args), 
     
    714727            else: 
    715728                self.code_text += line + os.linesep 
    716729                if not self.code_processor: 
    717                     self.code_processor = WikiProcessor(self.env, 'default') 
     730                    self.code_processor = WikiProcessor(self, 'default') 
    718731        elif line.strip() == Formatter.ENDBLOCK: 
    719732            self.in_code_block -= 1 
    720733            if self.in_code_block == 0 and self.code_processor: 
     
    728741            match = Formatter._processor_re.search(line) 
    729742            if match: 
    730743                name = match.group(1) 
    731                 self.code_processor = WikiProcessor(self.env, name) 
     744                self.code_processor = WikiProcessor(self, name) 
    732745            else: 
    733746                self.code_text += line + os.linesep  
    734                 self.code_processor = WikiProcessor(self.env, 'default') 
     747                self.code_processor = WikiProcessor(self, 'default') 
    735748        else: 
    736749            self.code_text += line + os.linesep 
    737750 
  • 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 
     
    288288                    icon = href.chrome(icon) 
    289289                else: 
    290290                    icon = href.chrome('common', icon) 
    291             mimetype = mimeview.get_mimetype(icon) 
     291            mimetype = get_mimetype(icon) 
    292292            add_link(req, 'icon', icon, mimetype=mimetype) 
    293293            add_link(req, 'shortcut icon', icon, mimetype=mimetype) 
    294294 
  • trac/util/text.py

     
    2525 
    2626 
    2727CRLF = '\r\n' 
     28IDENTITY_CHARSET = 'iso-8859-1' # not iso-8859-15 
    2829 
    2930# -- Unicode 
    3031 
     
    4041    If the `lossy` argument is `True`, which is the default, then 
    4142    we use the 'replace' mode: 
    4243 
    43     If the `lossy` argument is `False`, we fallback to the 'iso-8859-15' 
    44     charset in case of an error (encoding a `str` using 'iso-8859-15' 
     44    If the `lossy` argument is `False`, we fallback to the 'iso-8859-1' 
     45    charset in case of an error (encoding a `str` using 'iso-8859-1' 
    4546    will always work, as there's one Unicode character for each byte of 
    4647    the input). 
    4748    """ 
     
    6566            except UnicodeError: 
    6667                return unicode(text, locale.getpreferredencoding(), errors) 
    6768    except UnicodeError: 
    68         return unicode(text, 'iso-8859-15') 
     69        return unicode(text, IDENTITY_CHARSET) 
    6970 
    7071def unicode_quote(value): 
    7172    """A unicode aware version of urllib.quote""" 
     
    8586    return urlencode([(k, isinstance(v, unicode) and v.encode('utf-8') or v) 
    8687                      for k, v in params]) 
    8788 
    88 def to_utf8(text, charset='iso-8859-15'): 
     89def to_utf8(text, charset=IDENTITY_CHARSET): 
    8990    """Convert a string to UTF-8, assuming the encoding is either UTF-8, ISO 
    9091    Latin-1, or as specified by the optional `charset` parameter. 
    9192 
     
    101102            u = unicode(text, charset) 
    102103        except UnicodeError: 
    103104            # This should always work 
    104             u = unicode(text, 'iso-8859-15') 
     105            u = unicode(text, IDENTITY_CHARSET) 
    105106        return u.encode('utf-8') 
    106107 
    107108 
  • templates/browser.cs

     
    111111  </table><?cs 
    112112 /if ?><?cs 
    113113  
    114  if:!browser.is_dir ?> 
    115   <div id="preview"><?cs 
    116    if:file.preview ?><?cs 
    117     var:file.preview ?><?cs 
    118    elif:file.max_file_size_reached ?> 
    119     <strong>HTML preview not available</strong>, since the file size exceeds 
    120     <?cs var:file.max_file_size ?> bytes. Try <a href="<?cs 
    121     var:file.raw_href ?>">downloading</a> the file instead.<?cs 
    122    else ?><strong>HTML preview not available</strong>. To view, <a href="<?cs 
    123     var:file.raw_href ?>">download</a> the file.<?cs 
    124    /if ?> 
    125   </div><?cs 
     114 if:!browser.is_dir ?><?cs 
     115  call:html_preview(file) ?><?cs 
    126116 /if ?> 
    127117 
    128118 <div id="help"> 
  • templates/macros.cs

     
    194194 
    195195def:plural(base, count) ?><?cs 
    196196 var:base ?><?cs if:count != 1 ?>s<?cs /if ?><?cs 
     197/def ?><?cs 
     198 
     199def:html_preview(base) ?> 
     200 <div id="preview"><?cs 
     201  if:base.preview ?> 
     202   <?cs var:base.preview ?><?cs 
     203  elif:base.max_file_size_reached ?> 
     204   <strong>HTML preview not available</strong>, since the file size exceeds 
     205   <?cs var:base.max_file_size  ?> bytes. You may <a href="<?cs 
     206     var:base.raw_href ?>">download the file</a> instead.<?cs 
     207  else ?> 
     208   <strong>HTML preview not available</strong>. To view the file, 
     209   <a href="<?cs var:base.raw_href ?>">download the file</a>.<?cs 
     210  /if ?> 
     211 </div><?cs 
    197212/def ?> 
  • templates/attachment.cs

     
    6666   </th></tr><tr> 
    6767   <td class="message"><?cs var:attachment.description ?></td> 
    6868  </tr> 
    69  </tbody></table> 
    70  <div id="preview"><?cs 
    71   if:attachment.preview ?> 
    72    <?cs var:attachment.preview ?><?cs 
    73   elif:attachment.max_file_size_reached ?> 
    74    <strong>HTML preview not available</strong>, since the file size exceeds 
    75    <?cs var:attachment.max_file_size  ?> bytes. You may <a href="<?cs 
    76      var:attachment.raw_href ?>">download the file</a> instead.<?cs 
    77   else ?> 
    78    <strong>HTML preview not available</strong>. To view the file, 
    79    <a href="<?cs var:attachment.raw_href ?>">download the file</a>.<?cs 
    80   /if ?> 
    81  </div> 
     69 </tbody></table><?cs 
     70 call:html_preview(attachment) ?> 
    8271 <?cs if:attachment.can_delete ?><div class="buttons"> 
    8372  <form method="get" action=""><div id="delete"> 
    8473   <input type="hidden" name="action" value="delete" />