edgewall/trac

View on GitHub
trac/wiki/formatter.py

Summary

Maintainability
F
2 wks
Test Coverage
# -*- coding: utf-8 -*-
#
# Copyright (C) 2003-2023 Edgewall Software
# Copyright (C) 2003-2005 Jonas Borgström <jonas@edgewall.com>
# Copyright (C) 2004-2005 Christopher Lenz <cmlenz@gmx.de>
# Copyright (C) 2005-2007 Christian Boos <cboos@edgewall.org>
# All rights reserved.
#
# This software is licensed as described in the file COPYING, which
# you should have received as part of this distribution. The terms
# are also available at https://trac.edgewall.org/wiki/TracLicense.
#
# This software consists of voluntary contributions made by many
# individuals. For the exact contribution history, see the revision
# history and logs, available at https://trac.edgewall.org/log/.
#
# Author: Jonas Borgström <jonas@edgewall.com>
#         Christopher Lenz <cmlenz@gmx.de>
#         Christian Boos <cboos@edgewall.org>

import io
import re

from trac.core import *
from trac.mimeview import *
from trac.resource import get_relative_resource, get_resource_url
from trac.util import arity, as_int
from trac.util.text import (
    exception_to_unicode, shorten_line, to_unicode, unicode_quote,
    unquote_label
)
from trac.util.html import (
    Element, Fragment, Markup, TracHTMLSanitizer, escape, plaintext, tag,
    to_fragment
)
from trac.util.translation import _, tag_
from trac.wiki.api import WikiSystem, parse_args
from trac.wiki.parser import WikiParser, parse_processor_args

__all__ = ['Formatter', 'MacroError', 'ProcessorError',
           'concat_path_query_fragment', 'extract_link', 'format_to',
           'format_to_html', 'format_to_oneliner',
           'split_url_into_path_query_fragment', 'wiki_to_outline']


def _markup_to_unicode(markup):
    if isinstance(markup, Fragment):
        return Markup(markup)
    else:
        return to_unicode(markup)


def system_message(msg, text=None):
    return tag.div(tag.strong(msg), text and tag.pre(text),
                   class_="system-message")


def split_url_into_path_query_fragment(target):
    """Split a target along `?` and `#` in `(path, query, fragment)`.

    >>> split_url_into_path_query_fragment('http://path?a=1&b=2#frag?ment')
    ('http://path', '?a=1&b=2', '#frag?ment')
    >>> split_url_into_path_query_fragment('http://path#frag?ment')
    ('http://path', '', '#frag?ment')
    >>> split_url_into_path_query_fragment('http://path?a=1&b=2')
    ('http://path', '?a=1&b=2', '')
    >>> split_url_into_path_query_fragment('http://path')
    ('http://path', '', '')
    """
    query = fragment = ''
    idx = target.find('#')
    if idx >= 0:
        target, fragment = target[:idx], target[idx:]
    idx = target.find('?')
    if idx >= 0:
        target, query = target[:idx], target[idx:]
    return target, query, fragment


def concat_path_query_fragment(path, query, fragment=None):
    """Assemble `path`, `query` and `fragment` into a proper URL.

    Can be used to re-assemble an URL decomposed using
    `split_url_into_path_query_fragment` after modification.

    >>> concat_path_query_fragment('/wiki/page', '?version=1')
    '/wiki/page?version=1'
    >>> concat_path_query_fragment('/wiki/page#a', '?version=1', '#b')
    '/wiki/page?version=1#b'
    >>> concat_path_query_fragment('/wiki/page?version=1#a', '?format=txt')
    '/wiki/page?version=1&format=txt#a'
    >>> concat_path_query_fragment('/wiki/page?version=1', '&format=txt')
    '/wiki/page?version=1&format=txt'
    >>> concat_path_query_fragment('/wiki/page?version=1', 'format=txt')
    '/wiki/page?version=1&format=txt'
    >>> concat_path_query_fragment('/wiki/page?version=1#a', '?format=txt', '#')
    '/wiki/page?version=1&format=txt'
    """
    p, q, f = split_url_into_path_query_fragment(path)
    if query:
        q += ('&' if q else '?') + query.lstrip('?&')
    if fragment:
        f = fragment
    return p + q + ('' if f == '#' else f)


class MacroError(TracError):
    """Exception raised on incorrect macro usage.

    The exception is trapped by the wiki formatter and the message is
    rendered in a `pre` tag, wrapped in a div with class `system-message`.

    :since: 1.0.11
    """
    pass

class ProcessorError(TracError):
    """Exception raised on incorrect processor usage.

    The exception is trapped by the wiki formatter and the message is
    rendered in a `pre` tag, wrapped in a div with class `system-message`.

    :since: 0.12
    """
    pass


class WikiProcessor(object):

    _code_block_re = re.compile(r'^<div(?:\s+class="([^"]+)")?>(.*)</div>$')
    _block_elem_re = re.compile(r'^\s*<(?:div|table)(?:\s+[^>]+)?>',
                                re.I | re.M)

    def __init__(self, formatter, name, args=None):
        """Find the processor by name

        :param formatter: the formatter embedding a call for this processor
        :param name: the name of the processor
        :param args: extra parameters for the processor

        (''since 0.11'')
        """
        self.formatter = formatter
        self.env = formatter.env
        self.name = name
        self.args = args
        self.error = None
        self.macro_provider = None

        # FIXME: move these tables outside of __init__
        builtin_processors = {'html': self._html_processor,
                              'htmlcomment': self._htmlcomment_processor,
                              'default': self._default_processor,
                              'comment': self._comment_processor,
                              'div': self._div_processor,
                              'rtl': self._rtl_processor,
                              'span': self._span_processor,
                              'Span': self._span_processor,
                              'td': self._td_processor,
                              'th': self._th_processor,
                              'tr': self._tr_processor,
                              'table': self._table_processor,
                              }

        self.inline_check = {'html': self._html_is_inline,
                             'htmlcomment': True, 'comment': True,
                             'span': True, 'Span': True,
                             }.get(name)

        self._sanitizer = TracHTMLSanitizer(
            safe_schemes=formatter.wiki.safe_schemes,
            safe_origins=formatter.wiki.safe_origins)

        self.processor = builtin_processors.get(name)
        if not self.processor:
            # Find a matching wiki macro
            for macro_provider in WikiSystem(self.env).macro_providers:
                for macro_name in macro_provider.get_macros() or []:
                    if self.name == macro_name:
                        if hasattr(macro_provider, 'expand_macro'):
                            self.processor = self._macro_processor
                        else:
                            raise TracError(
                                tag_("Pre-0.11 macros with the %(method)s "
                                     "method are no longer supported.",
                                     method=tag.code("render_macro")))
                        self.macro_provider = macro_provider
                        self.inline_check = getattr(macro_provider, 'is_inline',
                                                    False)
                        break
        if not self.processor:
            # Find a matching mimeview renderer
            mimeview = Mimeview(formatter.env)
            for renderer in mimeview.renderers:
                if renderer.get_quality_ratio(self.name) > 1:
                    self.processor = self._mimeview_processor
                    break
            if not self.processor:
                mimetype = mimeview.get_mimetype(self.name)
                if mimetype:
                    self.name = mimetype
                    self.processor = self._mimeview_processor
        if not self.processor:
            self.processor = self._default_processor
            self.error = _("No macro or processor named '%(name)s' found",
                           name=name)

    # inline checks

    def _html_is_inline(self, text):
        if text:
            tag = text[1:].lstrip()
            idx = tag.find(' ')
            if idx > -1:
                tag = tag[:idx]
            return tag.lower() in ('a', 'span', 'bdo', 'img',
                                   'big', 'small', 'font',
                                   'tt', 'i', 'b', 'u', 's', 'strike',
                                   'em', 'strong', 'dfn', 'code', 'q',
                                   'samp', 'kbd', 'var', 'cite', 'abbr',
                                   'acronym', 'sub', 'sup')
    # builtin processors

    def _comment_processor(self, text):
        return ''

    def _default_processor(self, text):
        if self.args and 'lineno' in self.args:
            self.name = \
                Mimeview(self.formatter.env).get_mimetype('text/plain')
            return self._mimeview_processor(text)
        else:
            return tag.pre(text, class_="wiki")

    def _html_processor(self, text):
        if WikiSystem(self.env).render_unsafe_content:
            return Markup(text)
        return self._sanitizer.sanitize(text)

    def _htmlcomment_processor(self, text):
        if "--" in text:
            return system_message(_('Error: Forbidden character sequence '
                                    '"--" in htmlcomment wiki code block'))
        return Markup('<!--\n%s-->\n' % text)

    def _elt_processor(self, eltname, format_to, text):
        # Note: as long as _processor_param_re is not re.UNICODE, **args is OK.
        # Also, parse_args is using strict mode when processing [[span(...)]].
        elt = getattr(tag, eltname)(**(self.args or {}))
        if not WikiSystem(self.env).render_unsafe_content:
            sanitized_elt = getattr(tag, eltname)
            sanitized_elt.attrib = self._sanitizer.sanitize_attrs(eltname,
                                                                  elt.attrib)
            elt = sanitized_elt
        elt.append(format_to(self.env, self.formatter.context, text))
        return elt

    def _div_processor(self, text):
        if not self.args:
            self.args = {}
        self.args.setdefault('class', 'wikipage')
        return self._elt_processor('div', format_to_html, text)

    def _rtl_processor(self, text):
        if not self.args:
            self.args = {}
        self.args['class'] = ('rtl ' + self.args.get('class', '')).rstrip()
        return self._elt_processor('div', format_to_html, text)

    def _span_processor(self, text):
        if self.args is None:
            args, self.args = parse_args(text, strict=True)
            text = ', '.join(args)
        return self._elt_processor('span', format_to_oneliner, text)

    def _td_processor(self, text):
        return self._tablecell_processor('td', text)

    def _th_processor(self, text):
        return self._tablecell_processor('th', text)

    def _tr_processor(self, text):
        try:
            elt = self._elt_processor('tr', self._format_row, text)
            self.formatter.open_table()
            return elt
        except ProcessorError as e:
            return system_message(e)

    def _table_processor(self, text):
        if not self.args:
            self.args = {}
        self.args.setdefault('class', 'wiki')
        try:
            return self._elt_processor('table', self._format_table, text)
        except ProcessorError as e:
            return system_message(e)

    def _tablecell_processor(self, eltname, text):
        self.formatter.open_table_row()
        return self._elt_processor(eltname, format_to_html, text)

    _has_multiple_tables_re = re.compile(r"</table>.*?<table",
                                         re.MULTILINE | re.DOTALL)

    _inner_table_re = re.compile(r"""\s*
      <table[^>]*>\s*
        ((?:<tr[^>]*>)?
          (.*?)
        (?:</tr>)?)\s*
      </table>\s*$
      """, re.MULTILINE | re.DOTALL | re.VERBOSE)

    # Note: the need for "parsing" that crude way the formatted content
    #       will go away as soon as we have a WikiDOM to manipulate...

    def _parse_inner_table(self, text):
        if self._has_multiple_tables_re.search(text):
            raise ProcessorError(_("!#%(name)s must contain at most one table",
                                   name=self.name))
        match = self._inner_table_re.match(text)
        if not match:
            raise ProcessorError(_("!#%(name)s must contain at least one table"
                                   " cell (and table cells only)",
                                   name=self.name))
        return Markup(match.group(1 if self.name == 'table' else 2))

    def _format_row(self, env, context, text):
        if text:
            out = io.StringIO()
            Formatter(env, context).format(text, out)
            text = self._parse_inner_table(out.getvalue())
        return text

    def _format_table(self, env, context, text):
        if text:
            out = io.StringIO()
            Formatter(env, context).format(text, out)
            text = self._parse_inner_table(out.getvalue())
        return text

    # generic processors

    def _macro_processor(self, text):
        self.env.log.debug('Executing Wiki macro %s by provider %s',
                           self.name, self.macro_provider)
        if arity(self.macro_provider.expand_macro) == 4:
            return self.macro_provider.expand_macro(self.formatter, self.name,
                                                    text, self.args)
        else:
            return self.macro_provider.expand_macro(self.formatter, self.name,
                                                    text)

    def _mimeview_processor(self, text):
        annotations = []
        context = self.formatter.context.child()
        args = self.args.copy() if self.args else self.args
        if args and 'lineno' in args:
            lineno = as_int(args.pop('lineno'), 1, min=1)
            context.set_hints(lineno=lineno)
            id = str(args.pop('id', '')) or \
                 self.formatter._unique_anchor('a')
            context.set_hints(id=id + '-')
            if 'marks' in args:
                context.set_hints(marks=args.pop('marks'))
            annotations.append('lineno')
        if args:  # Remaining args are assumed to be lexer options
            context.set_hints(lexer_options=args)
        return tag.div(class_='wiki-code')(
            Mimeview(self.env).render(context, self.name, text,
                                      annotations=annotations))
    # TODO: use convert('text/html') instead of render

    def process(self, text, in_paragraph=False):
        if self.error:
            text = system_message(tag_("Error: Failed to load processor "
                                       "%(name)s", name=tag.code(self.name)),
                                  self.error)
        else:
            text = self.processor(text)
        return text or ''

    def is_inline(self, text):
        if callable(self.inline_check):
            return self.inline_check(text)
        else:
            return self.inline_check

    def ensure_inline(self, text, in_paragraph=True):
        content_for_span = None
        interrupt_paragraph = False
        if isinstance(text, Element):
            tagname = text.tag.lower()
            if tagname == 'div':
                class_ = text.attrib.get('class', '')
                if class_ and 'code' in class_:
                    content_for_span = text.children
                else:
                    interrupt_paragraph = True
            elif tagname == 'table':
                interrupt_paragraph = True
        else:
            # FIXME: do something smarter for Streams
            text = _markup_to_unicode(text)
            match = re.match(self._code_block_re, text)
            if match:
                if match.group(1) and 'code' in match.group(1):
                    content_for_span = match.group(2)
                else:
                    interrupt_paragraph = True
            elif re.match(self._block_elem_re, text):
                interrupt_paragraph = True
        if content_for_span:
            text = tag.span(class_='code-block')(*content_for_span)
        elif interrupt_paragraph and in_paragraph:
            text = "</p>%s<p>" % _markup_to_unicode(text)
        return text


class Formatter(object):
    """Base Wiki formatter.

    Parses and formats wiki text, in a given `RenderingContext`.
    """

    flavor = 'default'

    def __init__(self, env, context):
        self.env = env
        self.context = context.child()
        self.context.set_hints(disable_warnings=True)
        self.req = context.req
        self.href = context.href
        self.resource = context.resource
        self.perm = context.perm
        self.wiki = WikiSystem(self.env)
        self.wikiparser = WikiParser(self.env)
        self._anchors = {}
        self._open_tags = []
        self._safe_schemes = None
        if not self.wiki.render_unsafe_content:
            self._safe_schemes = set(self.wiki.safe_schemes)


    def split_link(self, target):
        return split_url_into_path_query_fragment(target)

    # -- Pre- IWikiSyntaxProvider rules (Font styles)

    _indirect_tags = {
        'MM_BOLD': ('<strong>', '</strong>'),
        'WC_BOLD': ('<strong>', '</strong>'),
        'MM_ITALIC': ('<em>', '</em>'),
        'WC_ITALIC': ('<em>', '</em>'),
        'MM_UNDERLINE': ('<span class="underline">', '</span>'),
        'MM_STRIKE': ('<del>', '</del>'),
        'MM_SUBSCRIPT': ('<sub>', '</sub>'),
        'MM_SUPERSCRIPT': ('<sup>', '</sup>'),
    }

    def _get_open_tag(self, tag):
        """Retrieve opening tag for direct or indirect `tag`."""
        if not isinstance(tag, tuple):
            tag = self._indirect_tags[tag]
        return tag[0]

    def _get_close_tag(self, tag):
        """Retrieve closing tag for direct or indirect `tag`."""
        if not isinstance(tag, tuple):
            tag = self._indirect_tags[tag]
        return tag[1]

    def tag_open_p(self, tag):
        """Do we currently have any open tag with `tag` as end-tag?"""
        return tag in self._open_tags

    def pop_tags(self):
        while self._open_tags:
            yield self._open_tags.pop()

    def flush_tags(self):
        for tag in self.pop_tags():
            self.out.write(self._get_close_tag(tag))

    def open_tag(self, tag_open, tag_close=None):
        """Open an inline style tag.

        If `tag_close` is not specified, `tag_open` is an indirect tag (0.12)
        """
        if tag_close:
            self._open_tags.append((tag_open, tag_close))
        else:
            self._open_tags.append(tag_open)
            tag_open = self._get_open_tag(tag_open)
        return tag_open

    def close_tag(self, open_tag, close_tag=None):
        """Open a inline style tag.

        If `close_tag` is not specified, it's an indirect tag (0.12)
        """
        tmp = ''
        for i in range(len(self._open_tags) - 1, -1, -1):
            tag = self._open_tags[i]
            tmp += self._get_close_tag(tag)
            if (open_tag == tag,
                    (open_tag, close_tag) == tag)[bool(close_tag)]:
                del self._open_tags[i]
                for j in range(i, len(self._open_tags)):
                    tmp += self._get_open_tag(self._open_tags[j])
                break
        return tmp

    def _indirect_tag_handler(self, match, tag):
        """Handle binary inline style tags (indirect way, 0.12)"""
        if self._list_stack and not self.in_list_item:
            self.close_list()

        if self.tag_open_p(tag):
            return self.close_tag(tag)
        else:
            return self.open_tag(tag)

    def _bolditalic_formatter(self, match, fullmatch):
        if self._list_stack and not self.in_list_item:
            self.close_list()

        bold_open = self.tag_open_p('MM_BOLD')
        italic_open = self.tag_open_p('MM_ITALIC')
        if bold_open and italic_open:
            bold_idx = self._open_tags.index('MM_BOLD')
            italic_idx = self._open_tags.index('MM_ITALIC')
            if italic_idx < bold_idx:
                close_tags = ('MM_BOLD', 'MM_ITALIC')
            else:
                close_tags = ('MM_ITALIC', 'MM_BOLD')
            open_tags = ()
        elif bold_open:
            close_tags = ('MM_BOLD',)
            open_tags = ('MM_ITALIC',)
        elif italic_open:
            close_tags = ('MM_ITALIC',)
            open_tags = ('MM_BOLD',)
        else:
            close_tags = ()
            open_tags = ('MM_BOLD', 'MM_ITALIC')

        tmp = []
        tmp.extend(self.close_tag(tag) for tag in close_tags)
        tmp.extend(self.open_tag(tag) for tag in open_tags)
        return ''.join(tmp)

    def _bold_formatter(self, match, fullmatch):
        return self._indirect_tag_handler(match, 'MM_BOLD')

    def _bold_wc_formatter(self, match, fullmatch):
        return self._indirect_tag_handler(match, 'WC_BOLD')

    def _italic_formatter(self, match, fullmatch):
        return self._indirect_tag_handler(match, 'MM_ITALIC')

    def _italic_wc_formatter(self, match, fullmatch):
        return self._indirect_tag_handler(match, 'WC_ITALIC')

    def _underline_formatter(self, match, fullmatch):
        return self._indirect_tag_handler(match, 'MM_UNDERLINE')

    def _strike_formatter(self, match, fullmatch):
        return self._indirect_tag_handler(match, 'MM_STRIKE')

    def _subscript_formatter(self, match, fullmatch):
        return self._indirect_tag_handler(match, 'MM_SUBSCRIPT')

    def _superscript_formatter(self, match, fullmatch):
        return self._indirect_tag_handler(match, 'MM_SUPERSCRIPT')

    def _inlinecode_formatter(self, match, fullmatch):
        return tag.code(fullmatch.group('inline'))

    def _inlinecode2_formatter(self, match, fullmatch):
        return tag.code(fullmatch.group('inline2'))

    # pre-0.12 public API (no longer used by Trac itself but kept for plugins)

    def simple_tag_handler(self, match, open_tag, close_tag):
        """Generic handler for simple binary style tags"""
        if self.tag_open_p((open_tag, close_tag)):
            return self.close_tag(open_tag, close_tag)
        else:
            self.open_tag(open_tag, close_tag)
        return open_tag

    # -- Post- IWikiSyntaxProvider rules

    # WikiCreole line breaks

    def _linebreak_wc_formatter(self, match, fullmatch):
        return '<br />'

    # E-mails

    def _email_formatter(self, match, fullmatch):
        from trac.web.chrome import Chrome
        omatch = Chrome(self.env).format_emails(self.context, match)
        if omatch == match: # not obfuscated, make a link
            return self._make_mail_link('mailto:'+match, match)
        else:
            return omatch

    # HTML escape of &, < and >

    def _htmlescape_formatter(self, match, fullmatch):
        return "&amp;" if match == "&" \
                        else "&lt;" if match == "<" else "&gt;"

    # Short form (shref) and long form (lhref) of TracLinks

    def _shrefbr_formatter(self, match, fullmatch):
        ns = fullmatch.group('snsbr')
        target = unquote_label(fullmatch.group('stgtbr'))
        match = match[1:-1]
        return '&lt;%s&gt;' % \
               self._make_link(ns, target, match, match, fullmatch)

    def _shref_formatter(self, match, fullmatch):
        ns = fullmatch.group('sns')
        target = unquote_label(fullmatch.group('stgt'))
        return self._make_link(ns, target, match, match, fullmatch)

    def _lhref_formatter(self, match, fullmatch):
        rel = fullmatch.group('rel')
        ns = fullmatch.group('lns')
        target = unquote_label(fullmatch.group('ltgt'))
        label = fullmatch.group('label')
        return self._make_lhref_link(match, fullmatch, rel, ns, target, label)

    def _make_lhref_link(self, match, fullmatch, rel, ns, target, label):
        if not label: # e.g. `[http://target]` or `[wiki:target]`
            if target:
                if ns and target.startswith('//'):     # for `[http://target]`
                    label = ns + ':' + target   #  use `http://target`
                else:                           # for `wiki:target`
                    label = target.lstrip('/')  #  use only `target`
            else: # e.g. `[search:]`
                label = ns
        else:
            label = unquote_label(label)
        if rel:
            if not label:
                label = self.wiki.make_label_from_target(rel)
            path, query, fragment = self.split_link(rel)
            if path.startswith('//'):
                path = '/' + path.lstrip('/')
            elif path.startswith('/'):
                path = self.href + path
            else:
                resource = get_relative_resource(self.resource, path)
                path = get_resource_url(self.env, resource, self.href)
                if resource.id:
                    target = concat_path_query_fragment(str(resource.id),
                                                        query, fragment)
                    if resource.realm == 'wiki':
                        target = '/' + target   # Avoid wiki page scoping
                    return self._make_link(resource.realm, target, match,
                                           label, fullmatch)
            return tag.a(label,
                         href=concat_path_query_fragment(path, query, fragment))
        else:
            return self._make_link(ns or 'wiki', target or '', match, label,
                                   fullmatch)

    def _make_link(self, ns, target, match, label, fullmatch):
        # first check for an alias defined in trac.ini
        ns = self.env.config['intertrac'].get(ns, ns)
        if ns in self.wikiparser.link_resolvers:
            resolver = self.wikiparser.link_resolvers[ns]
            if arity(resolver) == 5:
                return resolver(self, ns, target, escape(label, False),
                                fullmatch)
            else:
                return resolver(self, ns, target, escape(label, False))
        elif ns == "mailto":
            from trac.web.chrome import Chrome
            chrome = Chrome(self.env)
            if chrome.never_obfuscate_mailto:
                otarget, olabel = target, label
            else:
                otarget = chrome.format_emails(self.context, target)
                olabel = chrome.format_emails(self.context, label)
            if (otarget, olabel) == (target, label):
                return self._make_mail_link('mailto:'+target, label)
            else:
                return olabel or otarget
        elif target.startswith('//'):
            if self._safe_schemes is None or ns in self._safe_schemes:
                return self._make_ext_link(ns + ':' + target, label)
            else:
                return escape(match)
        else:
            return self._make_intertrac_link(ns, target, label) or \
                   self._make_interwiki_link(ns, target, label) or \
                   escape(match)

    def _make_intertrac_link(self, ns, target, label):
        res = self.get_intertrac_url(ns, target)
        if res:
            return self._make_ext_link(res[0], label, res[1])

    def get_intertrac_url(self, ns, target):
        intertrac = self.env.config['intertrac']
        url = intertrac.get(ns + '.url')
        name = _("Trac project %(name)s", name=ns)
        if not url and ns.lower() == 'trac':
            url = 'https://trac.edgewall.org'
            name = _("The Trac Project")
        if url:
            name = intertrac.get(ns + '.title', name)
            url = '%s/intertrac/%s' % (url, unicode_quote(target))
            if target:
                title = _("%(target)s in %(name)s", target=target, name=name)
            else:
                title = name
            return url, title

    def shorthand_intertrac_helper(self, ns, target, label, fullmatch):
        if fullmatch: # short form
            it_group = fullmatch.groupdict().get('it_' + ns)
            if it_group:
                alias = it_group.strip()
                intertrac = self.env.config['intertrac']
                target = '%s:%s' % (ns, target[len(it_group):])
                return self._make_intertrac_link(intertrac.get(alias, alias),
                                                 target, label) or label

    def _make_interwiki_link(self, ns, target, label):
        from trac.wiki.interwiki import InterWikiMap
        interwiki = InterWikiMap(self.env)
        if ns in interwiki:
            url, title = interwiki.url(ns, target)
            if url:
                return self._make_ext_link(url, label, title)

    def _make_ext_link(self, url, text, title=''):
        local_url = self.env.project_url or self.env.abs_href.base
        if not url.startswith(local_url):
            return tag.a(tag.span('\u200b', class_="icon"), text,
                         class_="ext-link", href=url, title=title or None)
        else:
            return tag.a(text, href=url, title=title or None)

    def _make_mail_link(self, url, text, title=''):
        return tag.a(tag.span('\u200b', class_="icon"), text,
                     class_="mail-link", href=url, title=title or None)

    # Anchors

    def _anchor_formatter(self, match, fullmatch):
        anchor = fullmatch.group('anchorname')
        label = fullmatch.group('anchorlabel') or ''
        if label:
            label = format_to_oneliner(self.env, self.context, label)
        return '<span class="wikianchor" id="%s">%s</span>' % (anchor, label)

    def _unique_anchor(self, anchor):
        i = 1
        anchor_base = anchor
        while anchor in self._anchors:
            anchor = anchor_base + str(i)
            i += 1
        self._anchors[anchor] = True
        return anchor

    # WikiMacros or WikiCreole links

    def _macrolink_formatter(self, match, fullmatch):
        # check for a known [[macro]]
        macro_or_link = match[2:-2]
        if macro_or_link.startswith('=#'):
            fullmatch = WikiParser._set_anchor_wc_re.match(macro_or_link)
            if fullmatch:
                return self._anchor_formatter(macro_or_link, fullmatch)
        fullmatch = WikiParser._macro_re.match(macro_or_link)
        if fullmatch:
            name = fullmatch.group('macroname')
            args = fullmatch.group('macroargs')
            macro = False # not a macro
            macrolist = name[-1] == '?'
            if name.lower() == 'br' or name == '?':
                macro = None
            else:
                macro = WikiProcessor(self, (name, name[:-1])[macrolist])
                if macro.error:
                    macro = False
            if macro is not False:
                if macrolist:
                    macro = WikiProcessor(self, 'MacroList')
                return self._macro_formatter(match, fullmatch, macro)
        fullmatch = WikiParser._creolelink_re.match(macro_or_link)
        return self._lhref_formatter(match, fullmatch)

    def _macro_formatter(self, match, fullmatch, macro, only_inline=False):
        name = fullmatch.group('macroname')
        if name and name[-1] == '?': # Macro?() shortcut for MacroList(Macro)
            args = name[:-1] or '*'
        else:
            args = fullmatch.group('macroargs')
        if name.lower() == 'br':
            return self.emit_linebreak(args)
        in_paragraph = not (getattr(self, 'in_list_item', True) or
                            getattr(self, 'in_table', True) or
                            getattr(self, 'in_def_list', True))
        try:
            return macro.ensure_inline(macro.process(args), in_paragraph)
        except MacroError as e:
            return system_message(_("Macro %(name)s(%(args)s) failed",
                                    name=name, args=args), to_fragment(e))
        except Exception as e:
            self.env.log.error("Macro %s(%s) failed for %s:%s", name,
                               args, self.resource,
                               exception_to_unicode(e, traceback=True))
            return system_message(_("Error: Macro %(name)s(%(args)s) failed",
                                    name=name, args=args), to_fragment(e))
    def emit_linebreak(self, args):
        if args:
            sep = ':' if ':' in args else '='
            kv = args.split(sep, 1)
            if kv[0] == 'clear':
                clear = kv[-1] if kv[-1] in ['left', 'right'] else 'both'
                return '<br style="clear: {0}" />'.format(clear)
        return '<br />'

    # Headings

    def _parse_heading(self, match, fullmatch, shorten):
        match = match.strip()

        hdepth = fullmatch.group('hdepth')
        depth = len(hdepth)
        anchor = fullmatch.group('hanchor') or ''
        htext = fullmatch.group('htext').strip()
        if htext.endswith(hdepth):
            htext = htext[:-depth]
        heading = format_to_oneliner(self.env, self.context, htext, False)
        if anchor:
            anchor = anchor[1:]
        else:
            sans_markup = plaintext(heading, keeplinebreaks=False)
            anchor = WikiParser._anchor_re.sub('', sans_markup)
            if not anchor or anchor[0].isdigit() or anchor[0] in '.-':
                # an ID must start with a Name-start character in XHTML
                anchor = 'a' + anchor # keeping 'a' for backward compat
        anchor = self._unique_anchor(anchor)
        if shorten:
            heading = format_to_oneliner(self.env, self.context, htext, True)
        return depth, heading, anchor

    def _heading_formatter(self, match, fullmatch):
        self.close_table()
        self.close_paragraph()
        self.close_indentation()
        self.close_list()
        self.close_def_list()
        depth, heading, anchor = self._parse_heading(match, fullmatch, False)
        self.out.write('<h%d class="section" id="%s">%s</h%d>' %
                       (depth, anchor, heading, depth))

    # Generic indentation (as defined by lists and quotes)

    def _set_tab(self, depth):
        """Append a new tab if needed and truncate tabs deeper than `depth`

        given:       -*-----*--*---*--
        setting:              *
        results in:  -*-----*-*-------
        """
        tabstops = []
        for ts in self._tabstops:
            if ts >= depth:
                break
            tabstops.append(ts)
        tabstops.append(depth)
        self._tabstops = tabstops

    # Lists

    def _list_formatter(self, match, fullmatch):
        ldepth = len(fullmatch.group('ldepth'))
        listid = match[ldepth]
        self.in_list_item = True
        class_ = start = None
        if listid in WikiParser.BULLET_CHARS:
            type_ = 'ul'
        else:
            type_ = 'ol'
            lstart = fullmatch.group('lstart')
            if listid == 'i':
                class_ = 'lowerroman'
            elif listid == 'I':
                class_ = 'upperroman'
            elif listid.isdigit() and lstart != '1':
                start = int(lstart)
            elif listid.islower():
                class_ = 'loweralpha'
                if len(lstart) == 1 and lstart != 'a':
                    start = ord(lstart) - ord('a') + 1
            elif listid.isupper():
                class_ = 'upperalpha'
                if len(lstart) == 1 and lstart != 'A':
                    start = ord(lstart) - ord('A') + 1
        self._set_list_depth(ldepth, type_, class_, start)
        return ''

    def _get_list_depth(self):
        """Return the space offset associated to the deepest opened list."""
        if self._list_stack:
            return self._list_stack[-1][1]
        return -1

    def _set_list_depth(self, depth, new_type=None, lclass=None, start=None):
        def open_list():
            self.close_table()
            self.close_paragraph()
            self.close_indentation() # FIXME: why not lists in quotes?
            self._list_stack.append((new_type, depth))
            self._set_tab(depth)
            class_attr = ' class="%s"' % lclass if lclass else ''
            start_attr = ' start="%s"' % start if start is not None else ''
            self.out.write('<' + new_type + class_attr + start_attr + '><li>')
        def close_item():
            self.flush_tags()
            self.out.write('</li>')
        def close_list(tp):
            self._list_stack.pop()
            close_item()
            self.out.write('</%s>' % tp)

        # depending on the indent/dedent, open or close lists
        if depth > self._get_list_depth():
            open_list()
        else:
            while self._list_stack:
                deepest_type, deepest_offset = self._list_stack[-1]
                if depth >= deepest_offset:
                    break
                close_list(deepest_type)
            if new_type and depth >= 0:
                if self._list_stack:
                    old_type, old_offset = self._list_stack[-1]
                    if new_type and old_type != new_type:
                        close_list(old_type)
                        open_list()
                    else:
                        if old_offset != depth: # adjust last depth
                            self._list_stack[-1] = (old_type, depth)
                        close_item()
                        self.out.write('<li>')
                else:
                    open_list()

    def close_list(self, depth=-1):
        self._set_list_depth(depth)

    # Definition Lists

    def _definition_formatter(self, match, fullmatch):
        if self.in_def_list:
            tmp = '</dd>'
        else:
            self.close_paragraph()
            tmp = '<dl class="wiki">'
        definition = match[:match.find('::')]
        tmp += '<dt>%s</dt><dd>' % format_to_oneliner(self.env, self.context,
                                                      definition)
        self.in_def_list = True
        return tmp

    def close_def_list(self):
        if self.in_def_list:
            self.out.write('</dd></dl>\n')
        self.in_def_list = False

    # Blockquote

    def _indent_formatter(self, match, fullmatch):
        idepth = len(fullmatch.group('idepth'))
        if self._list_stack:
            ltype, ldepth = self._list_stack[-1]
            if idepth < ldepth:
                for _, ldepth in self._list_stack:
                    if idepth > ldepth:
                        self.in_list_item = True
                        self._set_list_depth(idepth)
                        return ''
            elif idepth <= ldepth + (3 if ltype == 'ol' else 2):
                self.in_list_item = True
                return ''
        if not self.in_def_list:
            self._set_quote_depth(idepth)
        return ''

    def close_indentation(self):
        self._set_quote_depth(0)

    def _get_quote_depth(self):
        """Return the space offset associated to the deepest opened quote."""
        return self._quote_stack[-1] if self._quote_stack else 0

    def _set_quote_depth(self, depth, citation=False):
        def open_quote(depth):
            self.close_table()
            self.close_paragraph()
            self.close_list()
            def open_one_quote(d):
                self._quote_stack.append(d)
                self._set_tab(d)
                class_attr = ' class="citation"' if citation else ''
                self.out.write('<blockquote%s>\n' % class_attr)
            if citation:
                for d in range(quote_depth+1, depth+1):
                    open_one_quote(d)
            else:
                open_one_quote(depth)
        def close_quote():
            self.close_table()
            self.close_paragraph()
            self._quote_stack.pop()
            self.out.write('</blockquote>\n')
        quote_depth = self._get_quote_depth()
        if depth > quote_depth:
            self._set_tab(depth)
            tabstops = self._tabstops[::-1]
            while tabstops:
                tab = tabstops.pop()
                if tab > quote_depth:
                    open_quote(tab)
        else:
            while self._quote_stack:
                deepest_offset = self._quote_stack[-1]
                if depth >= deepest_offset:
                    break
                close_quote()
            if not citation and depth > 0:
                if self._quote_stack:
                    old_offset = self._quote_stack[-1]
                    if old_offset != depth: # adjust last depth
                        self._quote_stack[-1] = depth
                else:
                    open_quote(depth)
        if depth > 0:
            self.in_quote = True

    # Table

    def _table_cell_formatter(self, match, fullmatch):
        self.open_table()
        self.open_table_row()
        self.continue_table = 1
        separator = fullmatch.group('table_cell_sep')
        is_last = fullmatch.group('table_cell_last')
        numpipes = len(separator)
        cell = 'td'
        if separator[0] == '=':
            numpipes -= 1
        if separator[-1] == '=':
            numpipes -= 1
            cell = 'th'
        colspan = numpipes // 2
        if is_last is not None:
            if is_last and is_last[-1] == '\\':
                self.continue_table_row = 1
            colspan -= 1
            if not colspan:
                return ''
        attrs = ''
        if colspan > 1:
            attrs = ' colspan="%d"' % int(colspan)
        # alignment: ||left || right||default|| default ||  center  ||
        after_sep = fullmatch.end('table_cell_sep')
        alignleft = after_sep < len(self.line) and self.line[after_sep] != ' '
        # lookahead next || (FIXME: this fails on ` || ` inside the cell)
        next_sep = re.search(r'([^!])=?\|\|', self.line[after_sep:])
        alignright = next_sep and next_sep.group(1) != ' '
        textalign = None
        if alignleft:
            if not alignright:
                textalign = 'left'
        elif alignright:
            textalign = 'right'
        elif next_sep: # check for the extra spaces specifying a center align
            first_extra = after_sep + 1
            last_extra = after_sep + next_sep.start() - 1
            if first_extra < last_extra and \
                   self.line[first_extra] == self.line[last_extra] == ' ':
                textalign = 'center'
        if textalign:
            attrs += ' style="text-align: %s"' % textalign
        td = '<%s%s>' % (cell, attrs)
        if self.in_table_cell:
            close_tags = ''.join(self._get_close_tag(tag)
                                 for tag in self.pop_tags())
            td = '%s</%s>%s' % (close_tags, self.in_table_cell, td)
        self.in_table_cell = cell
        return td

    def _table_row_sep_formatter(self, match, fullmatch):
        self.open_table()
        self.close_table_row(force=True)
        params = fullmatch.group('table_row_params')
        if params:
            tr = WikiProcessor(self, 'tr', self.parse_processor_args(params))
            processed = _markup_to_unicode(tr.process(''))
            params = processed[3:processed.find('>')]
        self.open_table_row(params or '')
        self.continue_table = 1
        self.continue_table_row = 1

    def open_table(self):
        if not self.in_table:
            self.close_paragraph()
            self.close_list()
            self.close_def_list()
            self.in_table = 1
            self.out.write('<table class="wiki">\n')

    def open_table_row(self, params=''):
        if not self.in_table_row:
            self.open_table()
            self.in_table_row = 1
            self.out.write('<tr%s>' % params)

    def close_table_row(self, force=False):
        if self.in_table_row and (not self.continue_table_row or force):
            self.in_table_row = 0
            if self.in_table_cell:
                self.flush_tags()
                self.out.write('</%s>' % self.in_table_cell)
                self.in_table_cell = ''
            self.out.write('</tr>')
        self.continue_table_row = 0

    def close_table(self):
        if self.in_table:
            self.close_table_row(force=True)
            self.out.write('</table>\n')
            self.in_table = 0

    # Paragraphs

    def open_paragraph(self):
        if not self.paragraph_open:
            self.out.write('<p>\n')
            self.paragraph_open = 1

    def close_paragraph(self):
        self.flush_tags()
        if self.paragraph_open:
            self.out.write('</p>\n')
            self.paragraph_open = 0

    # Code blocks

    def parse_processor_args(self, line):
        return parse_processor_args(line)

    def handle_code_block(self, line, startmatch=None):
        if startmatch:
            self.in_code_block += 1
            if self.in_code_block == 1:
                name = startmatch.group(2)
                if name:
                    args = parse_processor_args(line[startmatch.end():])
                    self.code_processor = WikiProcessor(self, name, args)
                else:
                    self.code_processor = None
                self.code_buf = []
                self.code_prefix = line[:line.find(WikiParser.STARTBLOCK)]
            else:
                self.code_buf.append(line)
                if not self.code_processor:
                    self.code_processor = WikiProcessor(self, 'default')
        elif line.strip() == WikiParser.ENDBLOCK:
            self.in_code_block -= 1
            if self.in_code_block == 0 and self.code_processor:
                if self.code_processor.name not in ('th', 'td', 'tr'):
                    self.close_table()
                self.close_paragraph()
                if self.code_buf:
                    if self.code_prefix and all(not l or
                                                l.startswith(self.code_prefix)
                                                for l in self.code_buf):
                        code_indent = len(self.code_prefix)
                        self.code_buf = [l[code_indent:]
                                         for l in self.code_buf]
                    self.code_buf.append('')
                code_text = '\n'.join(self.code_buf)
                processed = self._exec_processor(self.code_processor,
                                                 code_text)
                self.out.write(_markup_to_unicode(processed))
            else:
                self.code_buf.append(line)
        elif not self.code_processor:
            match = WikiParser._processor_re.match(line)
            if match:
                self.code_prefix = match.group(1)
                name = match.group(2)
                args = parse_processor_args(line[match.end():])
                self.code_processor = WikiProcessor(self, name, args)
            else:
                self.code_buf.append(line)
                self.code_processor = WikiProcessor(self, 'default')
        else:
            self.code_buf.append(line)

    def close_code_blocks(self):
        while self.in_code_block > 0:
            self.handle_code_block(WikiParser.ENDBLOCK)

    def _exec_processor(self, processor, text):
        try:
            return processor.process(text)
        except ProcessorError as e:
            return system_message(_("Processor %(name)s failed",
                                    name=processor.name), to_fragment(e))
        except Exception as e:
            self.env.log.error("Processor %s failed for %s:%s",
                               processor.name, self.resource,
                               exception_to_unicode(e, traceback=True))
            return system_message(_("Error: Processor %(name)s failed",
                                    name=processor.name), to_fragment(e))

    # > quotes

    def handle_quote_block(self, line):
        self.close_paragraph()
        depth = line.find('>')
        # Close lists up to current level:
        #
        #  - first level item
        #    - second level item
        #    > citation part of first level item
        #
        #  (depth == 3, _list_stack == [1, 3])
        if not self._quote_buffer and depth < self._get_list_depth():
            self.close_list(depth)
        self._quote_buffer.append(line[depth + 1:])

    def close_quote_block(self, escape_newlines):
        if self._quote_buffer:
            # avoid an extra <blockquote> when there's consistently one space
            # after the '>'
            if all(not line or line[0] in '> ' for line in self._quote_buffer):
                self._quote_buffer = [line[bool(line and line[0] == ' '):]
                                      for line in self._quote_buffer]
            self.out.write('<blockquote class="citation">\n')
            Formatter(self.env, self.context).format(self._quote_buffer,
                                                     self.out, escape_newlines)
            self.out.write('</blockquote>\n')
            self._quote_buffer = []

    # -- Wiki engine

    def handle_match(self, fullmatch):
        for itype, match in fullmatch.groupdict().items():
            if match and itype not in self.wikiparser.helper_patterns:
                # Check for preceding escape character '!'
                if match[0] == '!':
                    return escape(match[1:])
                if itype in self.wikiparser.external_handlers:
                    external_handler = self.wikiparser.external_handlers[itype]
                    return external_handler(self, match, fullmatch)
                else:
                    internal_handler = getattr(self, '_%s_formatter' % itype)
                    return internal_handler(match, fullmatch)

    def replace(self, fullmatch):
        """Replace one match with its corresponding expansion"""
        replacement = self.handle_match(fullmatch)
        if replacement:
            return _markup_to_unicode(replacement)

    _normalize_re = re.compile(r'[\v\f]', re.UNICODE)

    def reset(self, source, out=None):
        if isinstance(source, str):
            source = re.sub(self._normalize_re, ' ', source)
        self.source = source
        class NullOut(object):
            def write(self, data):
                pass
        self.out = out or NullOut()
        self._open_tags = []
        self._list_stack = []
        self._quote_stack = []
        self._tabstops = []
        self._quote_buffer = []

        self.in_code_block = 0
        self.in_table = 0
        self.in_def_list = 0
        self.in_table_row = 0
        self.continue_table = 0
        self.continue_table_row = 0
        self.in_table_cell = ''
        self.paragraph_open = 0
        return source

    def format(self, text, out=None, escape_newlines=False):
        text = self.reset(text, out)
        if isinstance(text, str):
            text = text.splitlines()

        for line in text:
            # Detect start of code block (new block or embedded block)
            block_start_match = None
            if WikiParser.ENDBLOCK not in line:
                block_start_match = WikiParser._startblock_re.match(line)
            # Handle content or end of code block
            if self.in_code_block:
                self.handle_code_block(line, block_start_match)
                continue
            # Handle citation quotes '> ...'
            if line.strip().startswith('>'):
                self.handle_quote_block(line)
                continue
            # Handle end of citation quotes
            self.close_quote_block(escape_newlines)
            # Handle start of a new block
            if block_start_match:
                self.handle_code_block(line, block_start_match)
                continue
            # Handle Horizontal ruler
            if line[0:4] == '----':
                self.close_table()
                self.close_paragraph()
                self.close_indentation()
                self.close_list()
                self.close_def_list()
                self.out.write('<hr />\n')
                continue
            # Handle new paragraph
            if line == '':
                self.close_table()
                self.close_paragraph()
                self.close_indentation()
                self.close_list()
                self.close_def_list()
                continue

            # Tab expansion and clear tabstops if no indent
            line = line.replace('\t', ' '*8)
            if not line.startswith(' '):
                self._tabstops = []

            # Handle end of indentation
            if not line.startswith(' ') and self._quote_stack:
                self.close_indentation()

            self.in_list_item = False
            self.in_quote = False
            # Throw a bunch of regexps on the problem
            self.line = line
            result = re.sub(self.wikiparser.rules, self.replace, line)

            if not self.in_list_item:
                self.close_list()

            if not self.in_quote:
                self.close_indentation()

            if self.in_def_list and not line.startswith(' '):
                self.close_def_list()

            if self.in_table and not self.continue_table:
                self.close_table()
            self.continue_table = 0

            sep = '\n'
            if not(self.in_list_item or self.in_def_list or self.in_table):
                if len(result):
                    self.open_paragraph()
                if escape_newlines and self.paragraph_open and \
                       not result.rstrip().endswith('<br />'):
                    sep = '<br />' + sep
            self.out.write(result + sep)
            self.close_table_row()

        self.close_code_blocks()
        self.close_quote_block(escape_newlines)
        self.close_table()
        self.close_paragraph()
        self.close_indentation()
        self.close_list()
        self.close_def_list()


class OneLinerFormatter(Formatter):
    """
    A special version of the wiki formatter that only implement a
    subset of the wiki formatting functions. This version is useful
    for rendering short wiki-formatted messages on a single line
    """
    flavor = 'oneliner'

    # Override a few formatters to disable some wiki syntax in "oneliner"-mode
    def _list_formatter(self, match, fullmatch):
        return match
    def _indent_formatter(self, match, fullmatch):
        return match
    def _citation_formatter(self, match, fullmatch):
        return escape(match, False)
    def _heading_formatter(self, match, fullmatch):
        return escape(match, False)
    def _definition_formatter(self, match, fullmatch):
        return escape(match, False)
    def _table_cell_formatter(self, match, fullmatch):
        return match
    def _table_row_sep_formatter(self, match, fullmatch):
        return ''

    def _linebreak_wc_formatter(self, match, fullmatch):
        return ' '

    def _macro_formatter(self, match, fullmatch, macro):
        name = fullmatch.group('macroname')
        if name.lower() == 'br':
            return ' '
        args = fullmatch.group('macroargs')
        if macro.is_inline(args):
            return Formatter._macro_formatter(self, match, fullmatch, macro)
        else:
            return '[[%s%s]]' % (name, '(...)' if args else '')

    def format(self, text, out, shorten=False):
        if not text:
            return
        text = self.reset(text, out)

        # Simplify code blocks
        in_code_block = 0
        processor = None
        buf = io.StringIO()
        for line in text.strip().splitlines():
            if WikiParser.ENDBLOCK not in line and \
                   WikiParser._startblock_re.match(line):
                in_code_block += 1
            elif line.strip() == WikiParser.ENDBLOCK:
                if in_code_block:
                    in_code_block -= 1
                    if in_code_block == 0:
                        if processor != 'comment':
                            buf.write(' [...]\n')
                        processor = None
            elif in_code_block:
                if not processor:
                    if line.startswith('#!'):
                        processor = line[2:].strip()
            else:
                buf.write(line + '\n')
        result = buf.getvalue()[:-len('\n')]

        if shorten:
            result = shorten_line(result)

        result = re.sub(self.wikiparser.rules, self.replace, result)
        result = result.replace('[...]', '[\u2026]')
        if result.endswith('...'):
            result = result[:-3] + '\u2026'

        self.out.write(result)
        # Close all open 'one line'-tags
        self.flush_tags()
        # Flush unterminated code blocks
        if in_code_block > 0:
            self.out.write('[\u2026]')


class OutlineFormatter(Formatter):
    """Special formatter that generates an outline of all the headings."""
    flavor = 'outline'

    # Avoid the possible side-effects of rendering WikiProcessors
    def _macro_formatter(self, match, fullmatch, macro):
        name = fullmatch.group('macroname')
        if name.lower() == 'br':
            return ' '
        args = fullmatch.group('macroargs')
        if macro.is_inline(args):
            return Formatter._macro_formatter(self, match, fullmatch, macro)
        return ''

    def handle_code_block(self, line, startmatch=None):
        if WikiParser.ENDBLOCK not in line and \
               WikiParser._startblock_re.match(line):
            self.in_code_block += 1
        elif line.strip() == WikiParser.ENDBLOCK:
            self.in_code_block -= 1

    def format(self, text, out, max_depth=6, min_depth=1, shorten=True):
        self.shorten = shorten
        whitespace_indent = '  '
        self.outline = []
        Formatter.format(self, text)

        if min_depth > max_depth:
            min_depth, max_depth = max_depth, min_depth
        max_depth = min(6, max_depth)
        min_depth = max(1, min_depth)

        curr_depth = min_depth - 1
        out.write('\n')
        for depth, anchor, text in self.outline:
            if depth < min_depth or depth > max_depth:
                continue
            if depth > curr_depth: # Deeper indent
                for i in range(curr_depth, depth):
                    out.write(whitespace_indent * (2*i) + '<ol>\n' +
                              whitespace_indent * (2*i+1) + '<li>\n')
            elif depth < curr_depth: # Shallower indent
                for i in range(curr_depth-1, depth-1, -1):
                    out.write(whitespace_indent * (2*i+1) + '</li>\n' +
                              whitespace_indent * (2*i) + '</ol>\n')
                out.write(whitespace_indent * (2*depth-1) + '</li>\n' +
                          whitespace_indent * (2*depth-1) + '<li>\n')
            else: # Same indent
                out.write( whitespace_indent * (2*depth-1) + '</li>\n' +
                           whitespace_indent * (2*depth-1) + '<li>\n')
            curr_depth = depth
            out.write(whitespace_indent * (2*depth) +
                      '<a href="#%s">%s</a>\n' % (anchor, text))
        # Close out all indentation
        for i in range(curr_depth-1, min_depth-2, -1):
            out.write(whitespace_indent * (2*i+1) + '</li>\n' +
                      whitespace_indent * (2*i) + '</ol>\n')

    def _heading_formatter(self, match, fullmatch):
        depth, heading, anchor = self._parse_heading(match, fullmatch,
                                                     self.shorten)
        heading = re.sub(r'</?a(?: .*?)?>', '', heading) # Strip out link tags
        self.outline.append((depth, anchor, heading))


class LinkFormatter(OutlineFormatter):
    """Special formatter that focuses on TracLinks."""
    flavor = 'link'

    def _heading_formatter(self, match, fullmatch):
        return ''

    def match(self, wikitext):
        """Return the Wiki match found at the beginning of the `wikitext`"""
        wikitext = self.reset(wikitext)
        self.line = wikitext
        match = re.match(self.wikiparser.rules, wikitext)
        if match:
            return self.handle_match(match)


# Pure Wiki Formatter

class HtmlFormatter(object):
    """Format parsed wiki text to HTML"""

    flavor = 'default'

    def __init__(self, env, context, wikidom):
        self.env = env
        self.context = context
        if isinstance(wikidom, str):
            wikidom = WikiParser(env).parse(wikidom)
        self.wikidom = wikidom

    def generate(self, escape_newlines=False):
        """Generate HTML elements.

        newlines in the wikidom will be preserved if `escape_newlines` is set.
        """
        # FIXME: compatibility code only for now
        out = io.StringIO()
        Formatter(self.env, self.context).format(self.wikidom, out,
                                                 escape_newlines)
        return Markup(out.getvalue())


class InlineHtmlFormatter(object):
    """Format parsed wiki text to inline elements HTML.

    Block level content will be discarded or compacted.
    """

    flavor = 'oneliner'

    def __init__(self, env, context, wikidom):
        self.env = env
        self.context = context
        if isinstance(wikidom, str):
            wikidom = WikiParser(env).parse(wikidom)
        self.wikidom = wikidom

    def generate(self, shorten=False):
        """Generate HTML inline elements.

        If `shorten` is set, the generation will stop once enough characters
        have been emitted.
        """
        # FIXME: compatibility code only for now
        out = io.StringIO()
        OneLinerFormatter(self.env, self.context).format(self.wikidom, out,
                                                         shorten)
        return Markup(out.getvalue())


def format_to(env, flavor, context, wikidom, **options):
    if flavor is None:
        flavor = context.get_hint('wiki_flavor', 'html')
    if flavor == 'oneliner':
        return format_to_oneliner(env, context, wikidom, **options)
    else:
        return format_to_html(env, context, wikidom, **options)

def format_to_html(env, context, wikidom, escape_newlines=None):
    if not wikidom:
        return Markup()
    if escape_newlines is None:
        escape_newlines = context.get_hint('preserve_newlines', False)
    return HtmlFormatter(env, context, wikidom).generate(escape_newlines)

def format_to_oneliner(env, context, wikidom, shorten=None):
    if not wikidom:
        return Markup()
    if shorten is None:
        shorten = context.get_hint('shorten_lines', False)
    return InlineHtmlFormatter(env, context, wikidom).generate(shorten)

def extract_link(env, context, wikidom):
    if not wikidom:
        return Markup()
    return LinkFormatter(env, context).match(wikidom)


# pre-0.11 wiki text to Markup compatibility methods

def wiki_to_outline(wikitext, env, db=None,
                    absurls=False, max_depth=None, min_depth=None, req=None):
    """:deprecated: will be removed in 1.0 and replaced by something else"""
    if not wikitext:
        return Markup()
    abs_ref, href = (req or env).abs_href, (req or env).href
    from trac.web.chrome import web_context
    context = web_context(req, absurls=absurls)
    out = io.StringIO()
    OutlineFormatter(env, context).format(wikitext, out, max_depth, min_depth)
    return Markup(out.getvalue())