| 1 | # -*- coding: utf-8 -*-
|
|---|
| 2 | #
|
|---|
| 3 | # Copyright (C) 2003-2023 Edgewall Software
|
|---|
| 4 | # Copyright (C) 2003-2005 Jonas Borgström <jonas@edgewall.com>
|
|---|
| 5 | # Copyright (C) 2004-2005 Christopher Lenz <cmlenz@gmx.de>
|
|---|
| 6 | # Copyright (C) 2005-2007 Christian Boos <cboos@edgewall.org>
|
|---|
| 7 | # All rights reserved.
|
|---|
| 8 | #
|
|---|
| 9 | # This software is licensed as described in the file COPYING, which
|
|---|
| 10 | # you should have received as part of this distribution. The terms
|
|---|
| 11 | # are also available at https://trac.edgewall.org/wiki/TracLicense.
|
|---|
| 12 | #
|
|---|
| 13 | # This software consists of voluntary contributions made by many
|
|---|
| 14 | # individuals. For the exact contribution history, see the revision
|
|---|
| 15 | # history and logs, available at https://trac.edgewall.org/log/.
|
|---|
| 16 | #
|
|---|
| 17 | # Author: Jonas Borgström <jonas@edgewall.com>
|
|---|
| 18 | # Christopher Lenz <cmlenz@gmx.de>
|
|---|
| 19 | # Christian Boos <cboos@edgewall.org>
|
|---|
| 20 |
|
|---|
| 21 | import io
|
|---|
| 22 | import re
|
|---|
| 23 |
|
|---|
| 24 | from trac.core import *
|
|---|
| 25 | from trac.mimeview import *
|
|---|
| 26 | from trac.resource import get_relative_resource, get_resource_url
|
|---|
| 27 | from trac.util import arity, as_int
|
|---|
| 28 | from trac.util.text import (
|
|---|
| 29 | exception_to_unicode, shorten_line, to_unicode, unicode_quote,
|
|---|
| 30 | unquote_label
|
|---|
| 31 | )
|
|---|
| 32 | from trac.util.html import (
|
|---|
| 33 | Element, Fragment, Markup, TracHTMLSanitizer, escape, plaintext, tag,
|
|---|
| 34 | to_fragment
|
|---|
| 35 | )
|
|---|
| 36 | from trac.util.translation import _, tag_
|
|---|
| 37 | from trac.wiki.api import WikiSystem, parse_args
|
|---|
| 38 | from trac.wiki.parser import WikiParser, parse_processor_args
|
|---|
| 39 |
|
|---|
| 40 | __all__ = ['Formatter', 'MacroError', 'ProcessorError',
|
|---|
| 41 | 'concat_path_query_fragment', 'extract_link', 'format_to',
|
|---|
| 42 | 'format_to_html', 'format_to_oneliner',
|
|---|
| 43 | 'split_url_into_path_query_fragment', 'wiki_to_outline']
|
|---|
| 44 |
|
|---|
| 45 |
|
|---|
| 46 | def _markup_to_unicode(markup):
|
|---|
| 47 | if isinstance(markup, Fragment):
|
|---|
| 48 | return Markup(markup)
|
|---|
| 49 | else:
|
|---|
| 50 | return to_unicode(markup)
|
|---|
| 51 |
|
|---|
| 52 |
|
|---|
| 53 | def system_message(msg, text=None):
|
|---|
| 54 | return tag.div(tag.strong(msg), text and tag.pre(text),
|
|---|
| 55 | class_="system-message")
|
|---|
| 56 |
|
|---|
| 57 |
|
|---|
| 58 | def split_url_into_path_query_fragment(target):
|
|---|
| 59 | """Split a target along `?` and `#` in `(path, query, fragment)`.
|
|---|
| 60 |
|
|---|
| 61 | >>> split_url_into_path_query_fragment('http://path?a=1&b=2#frag?ment')
|
|---|
| 62 | ('http://path', '?a=1&b=2', '#frag?ment')
|
|---|
| 63 | >>> split_url_into_path_query_fragment('http://path#frag?ment')
|
|---|
| 64 | ('http://path', '', '#frag?ment')
|
|---|
| 65 | >>> split_url_into_path_query_fragment('http://path?a=1&b=2')
|
|---|
| 66 | ('http://path', '?a=1&b=2', '')
|
|---|
| 67 | >>> split_url_into_path_query_fragment('http://path')
|
|---|
| 68 | ('http://path', '', '')
|
|---|
| 69 | """
|
|---|
| 70 | query = fragment = ''
|
|---|
| 71 | idx = target.find('#')
|
|---|
| 72 | if idx >= 0:
|
|---|
| 73 | target, fragment = target[:idx], target[idx:]
|
|---|
| 74 | idx = target.find('?')
|
|---|
| 75 | if idx >= 0:
|
|---|
| 76 | target, query = target[:idx], target[idx:]
|
|---|
| 77 | return target, query, fragment
|
|---|
| 78 |
|
|---|
| 79 |
|
|---|
| 80 | def concat_path_query_fragment(path, query, fragment=None):
|
|---|
| 81 | """Assemble `path`, `query` and `fragment` into a proper URL.
|
|---|
| 82 |
|
|---|
| 83 | Can be used to re-assemble an URL decomposed using
|
|---|
| 84 | `split_url_into_path_query_fragment` after modification.
|
|---|
| 85 |
|
|---|
| 86 | >>> concat_path_query_fragment('/wiki/page', '?version=1')
|
|---|
| 87 | '/wiki/page?version=1'
|
|---|
| 88 | >>> concat_path_query_fragment('/wiki/page#a', '?version=1', '#b')
|
|---|
| 89 | '/wiki/page?version=1#b'
|
|---|
| 90 | >>> concat_path_query_fragment('/wiki/page?version=1#a', '?format=txt')
|
|---|
| 91 | '/wiki/page?version=1&format=txt#a'
|
|---|
| 92 | >>> concat_path_query_fragment('/wiki/page?version=1', '&format=txt')
|
|---|
| 93 | '/wiki/page?version=1&format=txt'
|
|---|
| 94 | >>> concat_path_query_fragment('/wiki/page?version=1', 'format=txt')
|
|---|
| 95 | '/wiki/page?version=1&format=txt'
|
|---|
| 96 | >>> concat_path_query_fragment('/wiki/page?version=1#a', '?format=txt', '#')
|
|---|
| 97 | '/wiki/page?version=1&format=txt'
|
|---|
| 98 | """
|
|---|
| 99 | p, q, f = split_url_into_path_query_fragment(path)
|
|---|
| 100 | if query:
|
|---|
| 101 | q += ('&' if q else '?') + query.lstrip('?&')
|
|---|
| 102 | if fragment:
|
|---|
| 103 | f = fragment
|
|---|
| 104 | return p + q + ('' if f == '#' else f)
|
|---|
| 105 |
|
|---|
| 106 |
|
|---|
| 107 | class MacroError(TracError):
|
|---|
| 108 | """Exception raised on incorrect macro usage.
|
|---|
| 109 |
|
|---|
| 110 | The exception is trapped by the wiki formatter and the message is
|
|---|
| 111 | rendered in a `pre` tag, wrapped in a div with class `system-message`.
|
|---|
| 112 |
|
|---|
| 113 | :since: 1.0.11
|
|---|
| 114 | """
|
|---|
| 115 | pass
|
|---|
| 116 |
|
|---|
| 117 | class ProcessorError(TracError):
|
|---|
| 118 | """Exception raised on incorrect processor usage.
|
|---|
| 119 |
|
|---|
| 120 | The exception is trapped by the wiki formatter and the message is
|
|---|
| 121 | rendered in a `pre` tag, wrapped in a div with class `system-message`.
|
|---|
| 122 |
|
|---|
| 123 | :since: 0.12
|
|---|
| 124 | """
|
|---|
| 125 | pass
|
|---|
| 126 |
|
|---|
| 127 |
|
|---|
| 128 | class WikiProcessor(object):
|
|---|
| 129 |
|
|---|
| 130 | _code_block_re = re.compile(r'^<div(?:\s+class="([^"]+)")?>(.*)</div>$')
|
|---|
| 131 | _block_elem_re = re.compile(r'^\s*<(?:div|table)(?:\s+[^>]+)?>',
|
|---|
| 132 | re.I | re.M)
|
|---|
| 133 |
|
|---|
| 134 | def __init__(self, formatter, name, args=None):
|
|---|
| 135 | """Find the processor by name
|
|---|
| 136 |
|
|---|
| 137 | :param formatter: the formatter embedding a call for this processor
|
|---|
| 138 | :param name: the name of the processor
|
|---|
| 139 | :param args: extra parameters for the processor
|
|---|
| 140 |
|
|---|
| 141 | (''since 0.11'')
|
|---|
| 142 | """
|
|---|
| 143 | self.formatter = formatter
|
|---|
| 144 | self.env = formatter.env
|
|---|
| 145 | self.name = name
|
|---|
| 146 | self.args = args
|
|---|
| 147 | self.error = None
|
|---|
| 148 | self.macro_provider = None
|
|---|
| 149 |
|
|---|
| 150 | # FIXME: move these tables outside of __init__
|
|---|
| 151 | builtin_processors = {'html': self._html_processor,
|
|---|
| 152 | 'htmlcomment': self._htmlcomment_processor,
|
|---|
| 153 | 'default': self._default_processor,
|
|---|
| 154 | 'comment': self._comment_processor,
|
|---|
| 155 | 'div': self._div_processor,
|
|---|
| 156 | 'rtl': self._rtl_processor,
|
|---|
| 157 | 'span': self._span_processor,
|
|---|
| 158 | 'Span': self._span_processor,
|
|---|
| 159 | 'td': self._td_processor,
|
|---|
| 160 | 'th': self._th_processor,
|
|---|
| 161 | 'tr': self._tr_processor,
|
|---|
| 162 | 'table': self._table_processor,
|
|---|
| 163 | }
|
|---|
| 164 |
|
|---|
| 165 | self.inline_check = {'html': self._html_is_inline,
|
|---|
| 166 | 'htmlcomment': True, 'comment': True,
|
|---|
| 167 | 'span': True, 'Span': True,
|
|---|
| 168 | }.get(name)
|
|---|
| 169 |
|
|---|
| 170 | self._sanitizer = TracHTMLSanitizer(
|
|---|
| 171 | safe_schemes=formatter.wiki.safe_schemes,
|
|---|
| 172 | safe_origins=formatter.wiki.safe_origins)
|
|---|
| 173 |
|
|---|
| 174 | self.processor = builtin_processors.get(name)
|
|---|
| 175 | if not self.processor:
|
|---|
| 176 | # Find a matching wiki macro
|
|---|
| 177 | for macro_provider in WikiSystem(self.env).macro_providers:
|
|---|
| 178 | for macro_name in macro_provider.get_macros() or []:
|
|---|
| 179 | if self.name == macro_name:
|
|---|
| 180 | if hasattr(macro_provider, 'expand_macro'):
|
|---|
| 181 | self.processor = self._macro_processor
|
|---|
| 182 | else:
|
|---|
| 183 | raise TracError(
|
|---|
| 184 | tag_("Pre-0.11 macros with the %(method)s "
|
|---|
| 185 | "method are no longer supported.",
|
|---|
| 186 | method=tag.code("render_macro")))
|
|---|
| 187 | self.macro_provider = macro_provider
|
|---|
| 188 | self.inline_check = getattr(macro_provider, 'is_inline',
|
|---|
| 189 | False)
|
|---|
| 190 | break
|
|---|
| 191 | if not self.processor:
|
|---|
| 192 | # Find a matching mimeview renderer
|
|---|
| 193 | mimeview = Mimeview(formatter.env)
|
|---|
| 194 | for renderer in mimeview.renderers:
|
|---|
| 195 | if renderer.get_quality_ratio(self.name) > 1:
|
|---|
| 196 | self.processor = self._mimeview_processor
|
|---|
| 197 | break
|
|---|
| 198 | if not self.processor:
|
|---|
| 199 | mimetype = mimeview.get_mimetype(self.name)
|
|---|
| 200 | if mimetype:
|
|---|
| 201 | self.name = mimetype
|
|---|
| 202 | self.processor = self._mimeview_processor
|
|---|
| 203 | if not self.processor:
|
|---|
| 204 | self.processor = self._default_processor
|
|---|
| 205 | self.error = _("No macro or processor named '%(name)s' found",
|
|---|
| 206 | name=name)
|
|---|
| 207 |
|
|---|
| 208 | # inline checks
|
|---|
| 209 |
|
|---|
| 210 | def _html_is_inline(self, text):
|
|---|
| 211 | if text:
|
|---|
| 212 | tag = text[1:].lstrip()
|
|---|
| 213 | idx = tag.find(' ')
|
|---|
| 214 | if idx > -1:
|
|---|
| 215 | tag = tag[:idx]
|
|---|
| 216 | return tag.lower() in ('a', 'span', 'bdo', 'img',
|
|---|
| 217 | 'big', 'small', 'font',
|
|---|
| 218 | 'tt', 'i', 'b', 'u', 's', 'strike',
|
|---|
| 219 | 'em', 'strong', 'dfn', 'code', 'q',
|
|---|
| 220 | 'samp', 'kbd', 'var', 'cite', 'abbr',
|
|---|
| 221 | 'acronym', 'sub', 'sup')
|
|---|
| 222 | # builtin processors
|
|---|
| 223 |
|
|---|
| 224 | def _comment_processor(self, text):
|
|---|
| 225 | return ''
|
|---|
| 226 |
|
|---|
| 227 | def _default_processor(self, text):
|
|---|
| 228 | if self.args and 'lineno' in self.args:
|
|---|
| 229 | self.name = \
|
|---|
| 230 | Mimeview(self.formatter.env).get_mimetype('text/plain')
|
|---|
| 231 | return self._mimeview_processor(text)
|
|---|
| 232 | else:
|
|---|
| 233 | return tag.pre(text, class_="wiki")
|
|---|
| 234 |
|
|---|
| 235 | def _html_processor(self, text):
|
|---|
| 236 | if WikiSystem(self.env).render_unsafe_content:
|
|---|
| 237 | return Markup(text)
|
|---|
| 238 | return self._sanitizer.sanitize(text)
|
|---|
| 239 |
|
|---|
| 240 | def _htmlcomment_processor(self, text):
|
|---|
| 241 | if "--" in text:
|
|---|
| 242 | return system_message(_('Error: Forbidden character sequence '
|
|---|
| 243 | '"--" in htmlcomment wiki code block'))
|
|---|
| 244 | return Markup('<!--\n%s-->\n' % text)
|
|---|
| 245 |
|
|---|
| 246 | def _elt_processor(self, eltname, format_to, text):
|
|---|
| 247 | # Note: as long as _processor_param_re is not re.UNICODE, **args is OK.
|
|---|
| 248 | # Also, parse_args is using strict mode when processing [[span(...)]].
|
|---|
| 249 | elt = getattr(tag, eltname)(**(self.args or {}))
|
|---|
| 250 | if not WikiSystem(self.env).render_unsafe_content:
|
|---|
| 251 | sanitized_elt = getattr(tag, eltname)
|
|---|
| 252 | sanitized_elt.attrib = self._sanitizer.sanitize_attrs(eltname,
|
|---|
| 253 | elt.attrib)
|
|---|
| 254 | elt = sanitized_elt
|
|---|
| 255 | elt.append(format_to(self.env, self.formatter.context, text))
|
|---|
| 256 | return elt
|
|---|
| 257 |
|
|---|
| 258 | def _div_processor(self, text):
|
|---|
| 259 | if not self.args:
|
|---|
| 260 | self.args = {}
|
|---|
| 261 | self.args.setdefault('class', 'wikipage')
|
|---|
| 262 | return self._elt_processor('div', format_to_html, text)
|
|---|
| 263 |
|
|---|
| 264 | def _rtl_processor(self, text):
|
|---|
| 265 | if not self.args:
|
|---|
| 266 | self.args = {}
|
|---|
| 267 | self.args['class'] = ('rtl ' + self.args.get('class', '')).rstrip()
|
|---|
| 268 | return self._elt_processor('div', format_to_html, text)
|
|---|
| 269 |
|
|---|
| 270 | def _span_processor(self, text):
|
|---|
| 271 | if self.args is None:
|
|---|
| 272 | args, self.args = parse_args(text, strict=True)
|
|---|
| 273 | text = ', '.join(args)
|
|---|
| 274 | return self._elt_processor('span', format_to_oneliner, text)
|
|---|
| 275 |
|
|---|
| 276 | def _td_processor(self, text):
|
|---|
| 277 | return self._tablecell_processor('td', text)
|
|---|
| 278 |
|
|---|
| 279 | def _th_processor(self, text):
|
|---|
| 280 | return self._tablecell_processor('th', text)
|
|---|
| 281 |
|
|---|
| 282 | def _tr_processor(self, text):
|
|---|
| 283 | try:
|
|---|
| 284 | elt = self._elt_processor('tr', self._format_row, text)
|
|---|
| 285 | self.formatter.open_table()
|
|---|
| 286 | return elt
|
|---|
| 287 | except ProcessorError as e:
|
|---|
| 288 | return system_message(e)
|
|---|
| 289 |
|
|---|
| 290 | def _table_processor(self, text):
|
|---|
| 291 | if not self.args:
|
|---|
| 292 | self.args = {}
|
|---|
| 293 | self.args.setdefault('class', 'wiki')
|
|---|
| 294 | try:
|
|---|
| 295 | return self._elt_processor('table', self._format_table, text)
|
|---|
| 296 | except ProcessorError as e:
|
|---|
| 297 | return system_message(e)
|
|---|
| 298 |
|
|---|
| 299 | def _tablecell_processor(self, eltname, text):
|
|---|
| 300 | self.formatter.open_table_row()
|
|---|
| 301 | return self._elt_processor(eltname, format_to_html, text)
|
|---|
| 302 |
|
|---|
| 303 | _has_multiple_tables_re = re.compile(r"</table>.*?<table",
|
|---|
| 304 | re.MULTILINE | re.DOTALL)
|
|---|
| 305 |
|
|---|
| 306 | _inner_table_re = re.compile(r"""\s*
|
|---|
| 307 | <table[^>]*>\s*
|
|---|
| 308 | ((?:<tr[^>]*>)?
|
|---|
| 309 | (.*?)
|
|---|
| 310 | (?:</tr>)?)\s*
|
|---|
| 311 | </table>\s*$
|
|---|
| 312 | """, re.MULTILINE | re.DOTALL | re.VERBOSE)
|
|---|
| 313 |
|
|---|
| 314 | # Note: the need for "parsing" that crude way the formatted content
|
|---|
| 315 | # will go away as soon as we have a WikiDOM to manipulate...
|
|---|
| 316 |
|
|---|
| 317 | def _parse_inner_table(self, text):
|
|---|
| 318 | if self._has_multiple_tables_re.search(text):
|
|---|
| 319 | raise ProcessorError(_("!#%(name)s must contain at most one table",
|
|---|
| 320 | name=self.name))
|
|---|
| 321 | match = self._inner_table_re.match(text)
|
|---|
| 322 | if not match:
|
|---|
| 323 | raise ProcessorError(_("!#%(name)s must contain at least one table"
|
|---|
| 324 | " cell (and table cells only)",
|
|---|
| 325 | name=self.name))
|
|---|
| 326 | return Markup(match.group(1 if self.name == 'table' else 2))
|
|---|
| 327 |
|
|---|
| 328 | def _format_row(self, env, context, text):
|
|---|
| 329 | if text:
|
|---|
| 330 | out = io.StringIO()
|
|---|
| 331 | Formatter(env, context).format(text, out)
|
|---|
| 332 | text = self._parse_inner_table(out.getvalue())
|
|---|
| 333 | return text
|
|---|
| 334 |
|
|---|
| 335 | def _format_table(self, env, context, text):
|
|---|
| 336 | if text:
|
|---|
| 337 | out = io.StringIO()
|
|---|
| 338 | Formatter(env, context).format(text, out)
|
|---|
| 339 | text = self._parse_inner_table(out.getvalue())
|
|---|
| 340 | return text
|
|---|
| 341 |
|
|---|
| 342 | # generic processors
|
|---|
| 343 |
|
|---|
| 344 | def _macro_processor(self, text):
|
|---|
| 345 | self.env.log.debug('Executing Wiki macro %s by provider %s',
|
|---|
| 346 | self.name, self.macro_provider)
|
|---|
| 347 | if arity(self.macro_provider.expand_macro) == 4:
|
|---|
| 348 | return self.macro_provider.expand_macro(self.formatter, self.name,
|
|---|
| 349 | text, self.args)
|
|---|
| 350 | else:
|
|---|
| 351 | return self.macro_provider.expand_macro(self.formatter, self.name,
|
|---|
| 352 | text)
|
|---|
| 353 |
|
|---|
| 354 | def _mimeview_processor(self, text):
|
|---|
| 355 | annotations = []
|
|---|
| 356 | context = self.formatter.context.child()
|
|---|
| 357 | args = self.args.copy() if self.args else self.args
|
|---|
| 358 | if args and 'lineno' in args:
|
|---|
| 359 | lineno = as_int(args.pop('lineno'), 1, min=1)
|
|---|
| 360 | context.set_hints(lineno=lineno)
|
|---|
| 361 | id = str(args.pop('id', '')) or \
|
|---|
| 362 | self.formatter._unique_anchor('a')
|
|---|
| 363 | context.set_hints(id=id + '-')
|
|---|
| 364 | if 'marks' in args:
|
|---|
| 365 | context.set_hints(marks=args.pop('marks'))
|
|---|
| 366 | annotations.append('lineno')
|
|---|
| 367 | if args: # Remaining args are assumed to be lexer options
|
|---|
| 368 | context.set_hints(lexer_options=args)
|
|---|
| 369 | return tag.div(class_='wiki-code')(
|
|---|
| 370 | Mimeview(self.env).render(context, self.name, text,
|
|---|
| 371 | annotations=annotations))
|
|---|
| 372 | # TODO: use convert('text/html') instead of render
|
|---|
| 373 |
|
|---|
| 374 | def process(self, text, in_paragraph=False):
|
|---|
| 375 | if self.error:
|
|---|
| 376 | text = system_message(tag_("Error: Failed to load processor "
|
|---|
| 377 | "%(name)s", name=tag.code(self.name)),
|
|---|
| 378 | self.error)
|
|---|
| 379 | else:
|
|---|
| 380 | text = self.processor(text)
|
|---|
| 381 | return text or ''
|
|---|
| 382 |
|
|---|
| 383 | def is_inline(self, text):
|
|---|
| 384 | if callable(self.inline_check):
|
|---|
| 385 | return self.inline_check(text)
|
|---|
| 386 | else:
|
|---|
| 387 | return self.inline_check
|
|---|
| 388 |
|
|---|
| 389 | def ensure_inline(self, text, in_paragraph=True):
|
|---|
| 390 | content_for_span = None
|
|---|
| 391 | interrupt_paragraph = False
|
|---|
| 392 | if isinstance(text, Element):
|
|---|
| 393 | tagname = text.tag.lower()
|
|---|
| 394 | if tagname == 'div':
|
|---|
| 395 | class_ = text.attrib.get('class', '')
|
|---|
| 396 | if class_ and 'code' in class_:
|
|---|
| 397 | content_for_span = text.children
|
|---|
| 398 | else:
|
|---|
| 399 | interrupt_paragraph = True
|
|---|
| 400 | elif tagname == 'table':
|
|---|
| 401 | interrupt_paragraph = True
|
|---|
| 402 | else:
|
|---|
| 403 | # FIXME: do something smarter for Streams
|
|---|
| 404 | text = _markup_to_unicode(text)
|
|---|
| 405 | match = re.match(self._code_block_re, text)
|
|---|
| 406 | if match:
|
|---|
| 407 | if match.group(1) and 'code' in match.group(1):
|
|---|
| 408 | content_for_span = match.group(2)
|
|---|
| 409 | else:
|
|---|
| 410 | interrupt_paragraph = True
|
|---|
| 411 | elif re.match(self._block_elem_re, text):
|
|---|
| 412 | interrupt_paragraph = True
|
|---|
| 413 | if content_for_span:
|
|---|
| 414 | text = tag.span(class_='code-block')(*content_for_span)
|
|---|
| 415 | elif interrupt_paragraph and in_paragraph:
|
|---|
| 416 | text = "</p>%s<p>" % _markup_to_unicode(text)
|
|---|
| 417 | return text
|
|---|
| 418 |
|
|---|
| 419 |
|
|---|
| 420 | class Formatter(object):
|
|---|
| 421 | """Base Wiki formatter.
|
|---|
| 422 |
|
|---|
| 423 | Parses and formats wiki text, in a given `RenderingContext`.
|
|---|
| 424 | """
|
|---|
| 425 |
|
|---|
| 426 | flavor = 'default'
|
|---|
| 427 |
|
|---|
| 428 | def __init__(self, env, context):
|
|---|
| 429 | self.env = env
|
|---|
| 430 | self.context = context.child()
|
|---|
| 431 | self.context.set_hints(disable_warnings=True)
|
|---|
| 432 | self.req = context.req
|
|---|
| 433 | self.href = context.href
|
|---|
| 434 | self.resource = context.resource
|
|---|
| 435 | self.perm = context.perm
|
|---|
| 436 | self.wiki = WikiSystem(self.env)
|
|---|
| 437 | self.wikiparser = WikiParser(self.env)
|
|---|
| 438 | self._anchors = {}
|
|---|
| 439 | self._open_tags = []
|
|---|
| 440 | self._safe_schemes = None
|
|---|
| 441 | if not self.wiki.render_unsafe_content:
|
|---|
| 442 | self._safe_schemes = set(self.wiki.safe_schemes)
|
|---|
| 443 |
|
|---|
| 444 |
|
|---|
| 445 | def split_link(self, target):
|
|---|
| 446 | return split_url_into_path_query_fragment(target)
|
|---|
| 447 |
|
|---|
| 448 | # -- Pre- IWikiSyntaxProvider rules (Font styles)
|
|---|
| 449 |
|
|---|
| 450 | _indirect_tags = {
|
|---|
| 451 | 'MM_BOLD': ('<strong>', '</strong>'),
|
|---|
| 452 | 'WC_BOLD': ('<strong>', '</strong>'),
|
|---|
| 453 | 'MM_ITALIC': ('<em>', '</em>'),
|
|---|
| 454 | 'WC_ITALIC': ('<em>', '</em>'),
|
|---|
| 455 | 'MM_UNDERLINE': ('<span class="underline">', '</span>'),
|
|---|
| 456 | 'MM_STRIKE': ('<del>', '</del>'),
|
|---|
| 457 | 'MM_SUBSCRIPT': ('<sub>', '</sub>'),
|
|---|
| 458 | 'MM_SUPERSCRIPT': ('<sup>', '</sup>'),
|
|---|
| 459 | }
|
|---|
| 460 |
|
|---|
| 461 | def _get_open_tag(self, tag):
|
|---|
| 462 | """Retrieve opening tag for direct or indirect `tag`."""
|
|---|
| 463 | if not isinstance(tag, tuple):
|
|---|
| 464 | tag = self._indirect_tags[tag]
|
|---|
| 465 | return tag[0]
|
|---|
| 466 |
|
|---|
| 467 | def _get_close_tag(self, tag):
|
|---|
| 468 | """Retrieve closing tag for direct or indirect `tag`."""
|
|---|
| 469 | if not isinstance(tag, tuple):
|
|---|
| 470 | tag = self._indirect_tags[tag]
|
|---|
| 471 | return tag[1]
|
|---|
| 472 |
|
|---|
| 473 | def tag_open_p(self, tag):
|
|---|
| 474 | """Do we currently have any open tag with `tag` as end-tag?"""
|
|---|
| 475 | return tag in self._open_tags
|
|---|
| 476 |
|
|---|
| 477 | def pop_tags(self):
|
|---|
| 478 | while self._open_tags:
|
|---|
| 479 | yield self._open_tags.pop()
|
|---|
| 480 |
|
|---|
| 481 | def flush_tags(self):
|
|---|
| 482 | for tag in self.pop_tags():
|
|---|
| 483 | self.out.write(self._get_close_tag(tag))
|
|---|
| 484 |
|
|---|
| 485 | def open_tag(self, tag_open, tag_close=None):
|
|---|
| 486 | """Open an inline style tag.
|
|---|
| 487 |
|
|---|
| 488 | If `tag_close` is not specified, `tag_open` is an indirect tag (0.12)
|
|---|
| 489 | """
|
|---|
| 490 | if tag_close:
|
|---|
| 491 | self._open_tags.append((tag_open, tag_close))
|
|---|
| 492 | else:
|
|---|
| 493 | self._open_tags.append(tag_open)
|
|---|
| 494 | tag_open = self._get_open_tag(tag_open)
|
|---|
| 495 | return tag_open
|
|---|
| 496 |
|
|---|
| 497 | def close_tag(self, open_tag, close_tag=None):
|
|---|
| 498 | """Open a inline style tag.
|
|---|
| 499 |
|
|---|
| 500 | If `close_tag` is not specified, it's an indirect tag (0.12)
|
|---|
| 501 | """
|
|---|
| 502 | tmp = ''
|
|---|
| 503 | for i in range(len(self._open_tags) - 1, -1, -1):
|
|---|
| 504 | tag = self._open_tags[i]
|
|---|
| 505 | tmp += self._get_close_tag(tag)
|
|---|
| 506 | if (open_tag == tag,
|
|---|
| 507 | (open_tag, close_tag) == tag)[bool(close_tag)]:
|
|---|
| 508 | del self._open_tags[i]
|
|---|
| 509 | for j in range(i, len(self._open_tags)):
|
|---|
| 510 | tmp += self._get_open_tag(self._open_tags[j])
|
|---|
| 511 | break
|
|---|
| 512 | return tmp
|
|---|
| 513 |
|
|---|
| 514 | def _indirect_tag_handler(self, match, tag):
|
|---|
| 515 | """Handle binary inline style tags (indirect way, 0.12)"""
|
|---|
| 516 | if self._list_stack and not self.in_list_item:
|
|---|
| 517 | self.close_list()
|
|---|
| 518 |
|
|---|
| 519 | if self.tag_open_p(tag):
|
|---|
| 520 | return self.close_tag(tag)
|
|---|
| 521 | else:
|
|---|
| 522 | return self.open_tag(tag)
|
|---|
| 523 |
|
|---|
| 524 | def _bolditalic_formatter(self, match, fullmatch):
|
|---|
| 525 | if self._list_stack and not self.in_list_item:
|
|---|
| 526 | self.close_list()
|
|---|
| 527 |
|
|---|
| 528 | bold_open = self.tag_open_p('MM_BOLD')
|
|---|
| 529 | italic_open = self.tag_open_p('MM_ITALIC')
|
|---|
| 530 | if bold_open and italic_open:
|
|---|
| 531 | bold_idx = self._open_tags.index('MM_BOLD')
|
|---|
| 532 | italic_idx = self._open_tags.index('MM_ITALIC')
|
|---|
| 533 | if italic_idx < bold_idx:
|
|---|
| 534 | close_tags = ('MM_BOLD', 'MM_ITALIC')
|
|---|
| 535 | else:
|
|---|
| 536 | close_tags = ('MM_ITALIC', 'MM_BOLD')
|
|---|
| 537 | open_tags = ()
|
|---|
| 538 | elif bold_open:
|
|---|
| 539 | close_tags = ('MM_BOLD',)
|
|---|
| 540 | open_tags = ('MM_ITALIC',)
|
|---|
| 541 | elif italic_open:
|
|---|
| 542 | close_tags = ('MM_ITALIC',)
|
|---|
| 543 | open_tags = ('MM_BOLD',)
|
|---|
| 544 | else:
|
|---|
| 545 | close_tags = ()
|
|---|
| 546 | open_tags = ('MM_BOLD', 'MM_ITALIC')
|
|---|
| 547 |
|
|---|
| 548 | tmp = []
|
|---|
| 549 | tmp.extend(self.close_tag(tag) for tag in close_tags)
|
|---|
| 550 | tmp.extend(self.open_tag(tag) for tag in open_tags)
|
|---|
| 551 | return ''.join(tmp)
|
|---|
| 552 |
|
|---|
| 553 | def _bold_formatter(self, match, fullmatch):
|
|---|
| 554 | return self._indirect_tag_handler(match, 'MM_BOLD')
|
|---|
| 555 |
|
|---|
| 556 | def _bold_wc_formatter(self, match, fullmatch):
|
|---|
| 557 | return self._indirect_tag_handler(match, 'WC_BOLD')
|
|---|
| 558 |
|
|---|
| 559 | def _italic_formatter(self, match, fullmatch):
|
|---|
| 560 | return self._indirect_tag_handler(match, 'MM_ITALIC')
|
|---|
| 561 |
|
|---|
| 562 | def _italic_wc_formatter(self, match, fullmatch):
|
|---|
| 563 | return self._indirect_tag_handler(match, 'WC_ITALIC')
|
|---|
| 564 |
|
|---|
| 565 | def _underline_formatter(self, match, fullmatch):
|
|---|
| 566 | return self._indirect_tag_handler(match, 'MM_UNDERLINE')
|
|---|
| 567 |
|
|---|
| 568 | def _strike_formatter(self, match, fullmatch):
|
|---|
| 569 | return self._indirect_tag_handler(match, 'MM_STRIKE')
|
|---|
| 570 |
|
|---|
| 571 | def _subscript_formatter(self, match, fullmatch):
|
|---|
| 572 | return self._indirect_tag_handler(match, 'MM_SUBSCRIPT')
|
|---|
| 573 |
|
|---|
| 574 | def _superscript_formatter(self, match, fullmatch):
|
|---|
| 575 | return self._indirect_tag_handler(match, 'MM_SUPERSCRIPT')
|
|---|
| 576 |
|
|---|
| 577 | def _inlinecode_formatter(self, match, fullmatch):
|
|---|
| 578 | return tag.code(fullmatch.group('inline'))
|
|---|
| 579 |
|
|---|
| 580 | def _inlinecode2_formatter(self, match, fullmatch):
|
|---|
| 581 | return tag.code(fullmatch.group('inline2'))
|
|---|
| 582 |
|
|---|
| 583 | # pre-0.12 public API (no longer used by Trac itself but kept for plugins)
|
|---|
| 584 |
|
|---|
| 585 | def simple_tag_handler(self, match, open_tag, close_tag):
|
|---|
| 586 | """Generic handler for simple binary style tags"""
|
|---|
| 587 | if self.tag_open_p((open_tag, close_tag)):
|
|---|
| 588 | return self.close_tag(open_tag, close_tag)
|
|---|
| 589 | else:
|
|---|
| 590 | self.open_tag(open_tag, close_tag)
|
|---|
| 591 | return open_tag
|
|---|
| 592 |
|
|---|
| 593 | # -- Post- IWikiSyntaxProvider rules
|
|---|
| 594 |
|
|---|
| 595 | # WikiCreole line breaks
|
|---|
| 596 |
|
|---|
| 597 | def _linebreak_wc_formatter(self, match, fullmatch):
|
|---|
| 598 | return '<br />'
|
|---|
| 599 |
|
|---|
| 600 | # E-mails
|
|---|
| 601 |
|
|---|
| 602 | def _email_formatter(self, match, fullmatch):
|
|---|
| 603 | from trac.web.chrome import Chrome
|
|---|
| 604 | omatch = Chrome(self.env).format_emails(self.context, match)
|
|---|
| 605 | if omatch == match: # not obfuscated, make a link
|
|---|
| 606 | return self._make_mail_link('mailto:'+match, match)
|
|---|
| 607 | else:
|
|---|
| 608 | return omatch
|
|---|
| 609 |
|
|---|
| 610 | # HTML escape of &, < and >
|
|---|
| 611 |
|
|---|
| 612 | def _htmlescape_formatter(self, match, fullmatch):
|
|---|
| 613 | return "&" if match == "&" \
|
|---|
| 614 | else "<" if match == "<" else ">"
|
|---|
| 615 |
|
|---|
| 616 | # Short form (shref) and long form (lhref) of TracLinks
|
|---|
| 617 |
|
|---|
| 618 | def _shrefbr_formatter(self, match, fullmatch):
|
|---|
| 619 | ns = fullmatch.group('snsbr')
|
|---|
| 620 | target = unquote_label(fullmatch.group('stgtbr'))
|
|---|
| 621 | match = match[1:-1]
|
|---|
| 622 | return '<%s>' % \
|
|---|
| 623 | self._make_link(ns, target, match, match, fullmatch)
|
|---|
| 624 |
|
|---|
| 625 | def _shref_formatter(self, match, fullmatch):
|
|---|
| 626 | ns = fullmatch.group('sns')
|
|---|
| 627 | target = unquote_label(fullmatch.group('stgt'))
|
|---|
| 628 | return self._make_link(ns, target, match, match, fullmatch)
|
|---|
| 629 |
|
|---|
| 630 | def _lhref_formatter(self, match, fullmatch):
|
|---|
| 631 | rel = fullmatch.group('rel')
|
|---|
| 632 | ns = fullmatch.group('lns')
|
|---|
| 633 | target = unquote_label(fullmatch.group('ltgt'))
|
|---|
| 634 | label = fullmatch.group('label')
|
|---|
| 635 | return self._make_lhref_link(match, fullmatch, rel, ns, target, label)
|
|---|
| 636 |
|
|---|
| 637 | def _make_lhref_link(self, match, fullmatch, rel, ns, target, label):
|
|---|
| 638 | if not label: # e.g. `[http://target]` or `[wiki:target]`
|
|---|
| 639 | if target:
|
|---|
| 640 | if ns and target.startswith('//'): # for `[http://target]`
|
|---|
| 641 | label = ns + ':' + target # use `http://target`
|
|---|
| 642 | else: # for `wiki:target`
|
|---|
| 643 | label = target.lstrip('/') # use only `target`
|
|---|
| 644 | else: # e.g. `[search:]`
|
|---|
| 645 | label = ns
|
|---|
| 646 | else:
|
|---|
| 647 | label = unquote_label(label)
|
|---|
| 648 | if rel:
|
|---|
| 649 | if not label:
|
|---|
| 650 | label = self.wiki.make_label_from_target(rel)
|
|---|
| 651 | path, query, fragment = self.split_link(rel)
|
|---|
| 652 | if path.startswith('//'):
|
|---|
| 653 | path = '/' + path.lstrip('/')
|
|---|
| 654 | elif path.startswith('/'):
|
|---|
| 655 | path = self.href + path
|
|---|
| 656 | else:
|
|---|
| 657 | resource = get_relative_resource(self.resource, path)
|
|---|
| 658 | path = get_resource_url(self.env, resource, self.href)
|
|---|
| 659 | if resource.id:
|
|---|
| 660 | target = concat_path_query_fragment(str(resource.id),
|
|---|
| 661 | query, fragment)
|
|---|
| 662 | if resource.realm == 'wiki':
|
|---|
| 663 | target = '/' + target # Avoid wiki page scoping
|
|---|
| 664 | return self._make_link(resource.realm, target, match,
|
|---|
| 665 | label, fullmatch)
|
|---|
| 666 | return tag.a(label,
|
|---|
| 667 | href=concat_path_query_fragment(path, query, fragment))
|
|---|
| 668 | else:
|
|---|
| 669 | return self._make_link(ns or 'wiki', target or '', match, label,
|
|---|
| 670 | fullmatch)
|
|---|
| 671 |
|
|---|
| 672 | def _make_link(self, ns, target, match, label, fullmatch):
|
|---|
| 673 | # first check for an alias defined in trac.ini
|
|---|
| 674 | ns = self.env.config['intertrac'].get(ns, ns)
|
|---|
| 675 | if ns in self.wikiparser.link_resolvers:
|
|---|
| 676 | resolver = self.wikiparser.link_resolvers[ns]
|
|---|
| 677 | if arity(resolver) == 5:
|
|---|
| 678 | return resolver(self, ns, target, escape(label, False),
|
|---|
| 679 | fullmatch)
|
|---|
| 680 | else:
|
|---|
| 681 | return resolver(self, ns, target, escape(label, False))
|
|---|
| 682 | elif ns == "mailto":
|
|---|
| 683 | from trac.web.chrome import Chrome
|
|---|
| 684 | chrome = Chrome(self.env)
|
|---|
| 685 | if chrome.never_obfuscate_mailto:
|
|---|
| 686 | otarget, olabel = target, label
|
|---|
| 687 | else:
|
|---|
| 688 | otarget = chrome.format_emails(self.context, target)
|
|---|
| 689 | olabel = chrome.format_emails(self.context, label)
|
|---|
| 690 | if (otarget, olabel) == (target, label):
|
|---|
| 691 | return self._make_mail_link('mailto:'+target, label)
|
|---|
| 692 | else:
|
|---|
| 693 | return olabel or otarget
|
|---|
| 694 | elif target.startswith('//'):
|
|---|
| 695 | if self._safe_schemes is None or ns in self._safe_schemes:
|
|---|
| 696 | return self._make_ext_link(ns + ':' + target, label)
|
|---|
| 697 | else:
|
|---|
| 698 | return escape(match)
|
|---|
| 699 | else:
|
|---|
| 700 | return self._make_intertrac_link(ns, target, label) or \
|
|---|
| 701 | self._make_interwiki_link(ns, target, label) or \
|
|---|
| 702 | escape(match)
|
|---|
| 703 |
|
|---|
| 704 | def _make_intertrac_link(self, ns, target, label):
|
|---|
| 705 | res = self.get_intertrac_url(ns, target)
|
|---|
| 706 | if res:
|
|---|
| 707 | return self._make_ext_link(res[0], label, res[1])
|
|---|
| 708 |
|
|---|
| 709 | def get_intertrac_url(self, ns, target):
|
|---|
| 710 | intertrac = self.env.config['intertrac']
|
|---|
| 711 | url = intertrac.get(ns + '.url')
|
|---|
| 712 | name = _("Trac project %(name)s", name=ns)
|
|---|
| 713 | if not url and ns.lower() == 'trac':
|
|---|
| 714 | url = 'https://trac.edgewall.org'
|
|---|
| 715 | name = _("The Trac Project")
|
|---|
| 716 | if url:
|
|---|
| 717 | name = intertrac.get(ns + '.title', name)
|
|---|
| 718 | url = '%s/intertrac/%s' % (url, unicode_quote(target))
|
|---|
| 719 | if target:
|
|---|
| 720 | title = _("%(target)s in %(name)s", target=target, name=name)
|
|---|
| 721 | else:
|
|---|
| 722 | title = name
|
|---|
| 723 | return url, title
|
|---|
| 724 |
|
|---|
| 725 | def shorthand_intertrac_helper(self, ns, target, label, fullmatch):
|
|---|
| 726 | if fullmatch: # short form
|
|---|
| 727 | it_group = fullmatch.groupdict().get('it_' + ns)
|
|---|
| 728 | if it_group:
|
|---|
| 729 | alias = it_group.strip()
|
|---|
| 730 | intertrac = self.env.config['intertrac']
|
|---|
| 731 | target = '%s:%s' % (ns, target[len(it_group):])
|
|---|
| 732 | return self._make_intertrac_link(intertrac.get(alias, alias),
|
|---|
| 733 | target, label) or label
|
|---|
| 734 |
|
|---|
| 735 | def _make_interwiki_link(self, ns, target, label):
|
|---|
| 736 | from trac.wiki.interwiki import InterWikiMap
|
|---|
| 737 | interwiki = InterWikiMap(self.env)
|
|---|
| 738 | if ns in interwiki:
|
|---|
| 739 | url, title = interwiki.url(ns, target)
|
|---|
| 740 | if url:
|
|---|
| 741 | return self._make_ext_link(url, label, title)
|
|---|
| 742 |
|
|---|
| 743 | def _make_ext_link(self, url, text, title=''):
|
|---|
| 744 | local_url = self.env.project_url or self.env.abs_href.base
|
|---|
| 745 | if not url.startswith(local_url):
|
|---|
| 746 | return tag.a(tag.span('\u200b', class_="icon"), text,
|
|---|
| 747 | class_="ext-link", href=url, title=title or None)
|
|---|
| 748 | else:
|
|---|
| 749 | return tag.a(text, href=url, title=title or None)
|
|---|
| 750 |
|
|---|
| 751 | def _make_mail_link(self, url, text, title=''):
|
|---|
| 752 | return tag.a(tag.span('\u200b', class_="icon"), text,
|
|---|
| 753 | class_="mail-link", href=url, title=title or None)
|
|---|
| 754 |
|
|---|
| 755 | # Anchors
|
|---|
| 756 |
|
|---|
| 757 | def _anchor_formatter(self, match, fullmatch):
|
|---|
| 758 | anchor = fullmatch.group('anchorname')
|
|---|
| 759 | label = fullmatch.group('anchorlabel') or ''
|
|---|
| 760 | if label:
|
|---|
| 761 | label = format_to_oneliner(self.env, self.context, label)
|
|---|
| 762 | return '<span class="wikianchor" id="%s">%s</span>' % (anchor, label)
|
|---|
| 763 |
|
|---|
| 764 | def _unique_anchor(self, anchor):
|
|---|
| 765 | i = 1
|
|---|
| 766 | anchor_base = anchor
|
|---|
| 767 | while anchor in self._anchors:
|
|---|
| 768 | anchor = anchor_base + str(i)
|
|---|
| 769 | i += 1
|
|---|
| 770 | self._anchors[anchor] = True
|
|---|
| 771 | return anchor
|
|---|
| 772 |
|
|---|
| 773 | # WikiMacros or WikiCreole links
|
|---|
| 774 |
|
|---|
| 775 | def _macrolink_formatter(self, match, fullmatch):
|
|---|
| 776 | # check for a known [[macro]]
|
|---|
| 777 | macro_or_link = match[2:-2]
|
|---|
| 778 | if macro_or_link.startswith('=#'):
|
|---|
| 779 | fullmatch = WikiParser._set_anchor_wc_re.match(macro_or_link)
|
|---|
| 780 | if fullmatch:
|
|---|
| 781 | return self._anchor_formatter(macro_or_link, fullmatch)
|
|---|
| 782 | fullmatch = WikiParser._macro_re.match(macro_or_link)
|
|---|
| 783 | if fullmatch:
|
|---|
| 784 | name = fullmatch.group('macroname')
|
|---|
| 785 | args = fullmatch.group('macroargs')
|
|---|
| 786 | macro = False # not a macro
|
|---|
| 787 | macrolist = name[-1] == '?'
|
|---|
| 788 | if name.lower() == 'br' or name == '?':
|
|---|
| 789 | macro = None
|
|---|
| 790 | else:
|
|---|
| 791 | macro = WikiProcessor(self, (name, name[:-1])[macrolist])
|
|---|
| 792 | if macro.error:
|
|---|
| 793 | macro = False
|
|---|
| 794 | if macro is not False:
|
|---|
| 795 | if macrolist:
|
|---|
| 796 | macro = WikiProcessor(self, 'MacroList')
|
|---|
| 797 | return self._macro_formatter(match, fullmatch, macro)
|
|---|
| 798 | fullmatch = WikiParser._creolelink_re.match(macro_or_link)
|
|---|
| 799 | return self._lhref_formatter(match, fullmatch)
|
|---|
| 800 |
|
|---|
| 801 | def _macro_formatter(self, match, fullmatch, macro, only_inline=False):
|
|---|
| 802 | name = fullmatch.group('macroname')
|
|---|
| 803 | if name and name[-1] == '?': # Macro?() shortcut for MacroList(Macro)
|
|---|
| 804 | args = name[:-1] or '*'
|
|---|
| 805 | else:
|
|---|
| 806 | args = fullmatch.group('macroargs')
|
|---|
| 807 | if name.lower() == 'br':
|
|---|
| 808 | return self.emit_linebreak(args)
|
|---|
| 809 | in_paragraph = not (getattr(self, 'in_list_item', True) or
|
|---|
| 810 | getattr(self, 'in_table', True) or
|
|---|
| 811 | getattr(self, 'in_def_list', True))
|
|---|
| 812 | try:
|
|---|
| 813 | return macro.ensure_inline(macro.process(args), in_paragraph)
|
|---|
| 814 | except MacroError as e:
|
|---|
| 815 | return system_message(_("Macro %(name)s(%(args)s) failed",
|
|---|
| 816 | name=name, args=args), to_fragment(e))
|
|---|
| 817 | except Exception as e:
|
|---|
| 818 | self.env.log.error("Macro %s(%s) failed for %s:%s", name,
|
|---|
| 819 | args, self.resource,
|
|---|
| 820 | exception_to_unicode(e, traceback=True))
|
|---|
| 821 | return system_message(_("Error: Macro %(name)s(%(args)s) failed",
|
|---|
| 822 | name=name, args=args), to_fragment(e))
|
|---|
| 823 | def emit_linebreak(self, args):
|
|---|
| 824 | if args:
|
|---|
| 825 | sep = ':' if ':' in args else '='
|
|---|
| 826 | kv = args.split(sep, 1)
|
|---|
| 827 | if kv[0] == 'clear':
|
|---|
| 828 | clear = kv[-1] if kv[-1] in ['left', 'right'] else 'both'
|
|---|
| 829 | return '<br style="clear: {0}" />'.format(clear)
|
|---|
| 830 | return '<br />'
|
|---|
| 831 |
|
|---|
| 832 | # Headings
|
|---|
| 833 |
|
|---|
| 834 | def _parse_heading(self, match, fullmatch, shorten):
|
|---|
| 835 | match = match.strip()
|
|---|
| 836 |
|
|---|
| 837 | hdepth = fullmatch.group('hdepth')
|
|---|
| 838 | depth = len(hdepth)
|
|---|
| 839 | anchor = fullmatch.group('hanchor') or ''
|
|---|
| 840 | htext = fullmatch.group('htext').strip()
|
|---|
| 841 | if htext.endswith(hdepth):
|
|---|
| 842 | htext = htext[:-depth]
|
|---|
| 843 | heading = format_to_oneliner(self.env, self.context, htext, False)
|
|---|
| 844 | if anchor:
|
|---|
| 845 | anchor = anchor[1:]
|
|---|
| 846 | else:
|
|---|
| 847 | sans_markup = plaintext(heading, keeplinebreaks=False)
|
|---|
| 848 | anchor = WikiParser._anchor_re.sub('', sans_markup)
|
|---|
| 849 | if not anchor or anchor[0].isdigit() or anchor[0] in '.-':
|
|---|
| 850 | # an ID must start with a Name-start character in XHTML
|
|---|
| 851 | anchor = 'a' + anchor # keeping 'a' for backward compat
|
|---|
| 852 | anchor = self._unique_anchor(anchor)
|
|---|
| 853 | if shorten:
|
|---|
| 854 | heading = format_to_oneliner(self.env, self.context, htext, True)
|
|---|
| 855 | return depth, heading, anchor
|
|---|
| 856 |
|
|---|
| 857 | def _heading_formatter(self, match, fullmatch):
|
|---|
| 858 | self.close_table()
|
|---|
| 859 | self.close_paragraph()
|
|---|
| 860 | self.close_indentation()
|
|---|
| 861 | self.close_list()
|
|---|
| 862 | self.close_def_list()
|
|---|
| 863 | depth, heading, anchor = self._parse_heading(match, fullmatch, False)
|
|---|
| 864 | self.out.write('<h%d class="section" id="%s">%s</h%d>' %
|
|---|
| 865 | (depth, anchor, heading, depth))
|
|---|
| 866 |
|
|---|
| 867 | # Generic indentation (as defined by lists and quotes)
|
|---|
| 868 |
|
|---|
| 869 | def _set_tab(self, depth):
|
|---|
| 870 | """Append a new tab if needed and truncate tabs deeper than `depth`
|
|---|
| 871 |
|
|---|
| 872 | given: -*-----*--*---*--
|
|---|
| 873 | setting: *
|
|---|
| 874 | results in: -*-----*-*-------
|
|---|
| 875 | """
|
|---|
| 876 | tabstops = []
|
|---|
| 877 | for ts in self._tabstops:
|
|---|
| 878 | if ts >= depth:
|
|---|
| 879 | break
|
|---|
| 880 | tabstops.append(ts)
|
|---|
| 881 | tabstops.append(depth)
|
|---|
| 882 | self._tabstops = tabstops
|
|---|
| 883 |
|
|---|
| 884 | # Lists
|
|---|
| 885 |
|
|---|
| 886 | def _list_formatter(self, match, fullmatch):
|
|---|
| 887 | ldepth = len(fullmatch.group('ldepth'))
|
|---|
| 888 | listid = match[ldepth]
|
|---|
| 889 | self.in_list_item = True
|
|---|
| 890 | class_ = start = None
|
|---|
| 891 | if listid in WikiParser.BULLET_CHARS:
|
|---|
| 892 | type_ = 'ul'
|
|---|
| 893 | else:
|
|---|
| 894 | type_ = 'ol'
|
|---|
| 895 | lstart = fullmatch.group('lstart')
|
|---|
| 896 | if listid == 'i':
|
|---|
| 897 | class_ = 'lowerroman'
|
|---|
| 898 | elif listid == 'I':
|
|---|
| 899 | class_ = 'upperroman'
|
|---|
| 900 | elif listid.isdigit() and lstart != '1':
|
|---|
| 901 | start = int(lstart)
|
|---|
| 902 | elif listid.islower():
|
|---|
| 903 | class_ = 'loweralpha'
|
|---|
| 904 | if len(lstart) == 1 and lstart != 'a':
|
|---|
| 905 | start = ord(lstart) - ord('a') + 1
|
|---|
| 906 | elif listid.isupper():
|
|---|
| 907 | class_ = 'upperalpha'
|
|---|
| 908 | if len(lstart) == 1 and lstart != 'A':
|
|---|
| 909 | start = ord(lstart) - ord('A') + 1
|
|---|
| 910 | self._set_list_depth(ldepth, type_, class_, start)
|
|---|
| 911 | return ''
|
|---|
| 912 |
|
|---|
| 913 | def _get_list_depth(self):
|
|---|
| 914 | """Return the space offset associated to the deepest opened list."""
|
|---|
| 915 | if self._list_stack:
|
|---|
| 916 | return self._list_stack[-1][1]
|
|---|
| 917 | return -1
|
|---|
| 918 |
|
|---|
| 919 | def _set_list_depth(self, depth, new_type=None, lclass=None, start=None):
|
|---|
| 920 | def open_list():
|
|---|
| 921 | self.close_table()
|
|---|
| 922 | self.close_paragraph()
|
|---|
| 923 | self.close_indentation() # FIXME: why not lists in quotes?
|
|---|
| 924 | self._list_stack.append((new_type, depth))
|
|---|
| 925 | self._set_tab(depth)
|
|---|
| 926 | class_attr = ' class="%s"' % lclass if lclass else ''
|
|---|
| 927 | start_attr = ' start="%s"' % start if start is not None else ''
|
|---|
| 928 | self.out.write('<' + new_type + class_attr + start_attr + '><li>')
|
|---|
| 929 | def close_item():
|
|---|
| 930 | self.flush_tags()
|
|---|
| 931 | self.out.write('</li>')
|
|---|
| 932 | def close_list(tp):
|
|---|
| 933 | self._list_stack.pop()
|
|---|
| 934 | close_item()
|
|---|
| 935 | self.out.write('</%s>' % tp)
|
|---|
| 936 |
|
|---|
| 937 | # depending on the indent/dedent, open or close lists
|
|---|
| 938 | if depth > self._get_list_depth():
|
|---|
| 939 | open_list()
|
|---|
| 940 | else:
|
|---|
| 941 | while self._list_stack:
|
|---|
| 942 | deepest_type, deepest_offset = self._list_stack[-1]
|
|---|
| 943 | if depth >= deepest_offset:
|
|---|
| 944 | break
|
|---|
| 945 | close_list(deepest_type)
|
|---|
| 946 | if new_type and depth >= 0:
|
|---|
| 947 | if self._list_stack:
|
|---|
| 948 | old_type, old_offset = self._list_stack[-1]
|
|---|
| 949 | if new_type and old_type != new_type:
|
|---|
| 950 | close_list(old_type)
|
|---|
| 951 | open_list()
|
|---|
| 952 | else:
|
|---|
| 953 | if old_offset != depth: # adjust last depth
|
|---|
| 954 | self._list_stack[-1] = (old_type, depth)
|
|---|
| 955 | close_item()
|
|---|
| 956 | self.out.write('<li>')
|
|---|
| 957 | else:
|
|---|
| 958 | open_list()
|
|---|
| 959 |
|
|---|
| 960 | def close_list(self, depth=-1):
|
|---|
| 961 | self._set_list_depth(depth)
|
|---|
| 962 |
|
|---|
| 963 | # Definition Lists
|
|---|
| 964 |
|
|---|
| 965 | def _definition_formatter(self, match, fullmatch):
|
|---|
| 966 | if self.in_def_list:
|
|---|
| 967 | tmp = '</dd>'
|
|---|
| 968 | else:
|
|---|
| 969 | self.close_paragraph()
|
|---|
| 970 | tmp = '<dl class="wiki">'
|
|---|
| 971 | definition = match[:match.find('::')]
|
|---|
| 972 | tmp += '<dt>%s</dt><dd>' % format_to_oneliner(self.env, self.context,
|
|---|
| 973 | definition)
|
|---|
| 974 | self.in_def_list = True
|
|---|
| 975 | return tmp
|
|---|
| 976 |
|
|---|
| 977 | def close_def_list(self):
|
|---|
| 978 | if self.in_def_list:
|
|---|
| 979 | self.out.write('</dd></dl>\n')
|
|---|
| 980 | self.in_def_list = False
|
|---|
| 981 |
|
|---|
| 982 | # Blockquote
|
|---|
| 983 |
|
|---|
| 984 | def _indent_formatter(self, match, fullmatch):
|
|---|
| 985 | idepth = len(fullmatch.group('idepth'))
|
|---|
| 986 | if self._list_stack:
|
|---|
| 987 | ltype, ldepth = self._list_stack[-1]
|
|---|
| 988 | if idepth < ldepth:
|
|---|
| 989 | for _, ldepth in self._list_stack:
|
|---|
| 990 | if idepth > ldepth:
|
|---|
| 991 | self.in_list_item = True
|
|---|
| 992 | self._set_list_depth(idepth)
|
|---|
| 993 | return ''
|
|---|
| 994 | elif idepth <= ldepth + (3 if ltype == 'ol' else 2):
|
|---|
| 995 | self.in_list_item = True
|
|---|
| 996 | return ''
|
|---|
| 997 | if not self.in_def_list:
|
|---|
| 998 | self._set_quote_depth(idepth)
|
|---|
| 999 | return ''
|
|---|
| 1000 |
|
|---|
| 1001 | def close_indentation(self):
|
|---|
| 1002 | self._set_quote_depth(0)
|
|---|
| 1003 |
|
|---|
| 1004 | def _get_quote_depth(self):
|
|---|
| 1005 | """Return the space offset associated to the deepest opened quote."""
|
|---|
| 1006 | return self._quote_stack[-1] if self._quote_stack else 0
|
|---|
| 1007 |
|
|---|
| 1008 | def _set_quote_depth(self, depth, citation=False):
|
|---|
| 1009 | def open_quote(depth):
|
|---|
| 1010 | self.close_table()
|
|---|
| 1011 | self.close_paragraph()
|
|---|
| 1012 | self.close_list()
|
|---|
| 1013 | def open_one_quote(d):
|
|---|
| 1014 | self._quote_stack.append(d)
|
|---|
| 1015 | self._set_tab(d)
|
|---|
| 1016 | class_attr = ' class="citation"' if citation else ''
|
|---|
| 1017 | self.out.write('<blockquote%s>\n' % class_attr)
|
|---|
| 1018 | if citation:
|
|---|
| 1019 | for d in range(quote_depth+1, depth+1):
|
|---|
| 1020 | open_one_quote(d)
|
|---|
| 1021 | else:
|
|---|
| 1022 | open_one_quote(depth)
|
|---|
| 1023 | def close_quote():
|
|---|
| 1024 | self.close_table()
|
|---|
| 1025 | self.close_paragraph()
|
|---|
| 1026 | self._quote_stack.pop()
|
|---|
| 1027 | self.out.write('</blockquote>\n')
|
|---|
| 1028 | quote_depth = self._get_quote_depth()
|
|---|
| 1029 | if depth > quote_depth:
|
|---|
| 1030 | self._set_tab(depth)
|
|---|
| 1031 | tabstops = self._tabstops[::-1]
|
|---|
| 1032 | while tabstops:
|
|---|
| 1033 | tab = tabstops.pop()
|
|---|
| 1034 | if tab > quote_depth:
|
|---|
| 1035 | open_quote(tab)
|
|---|
| 1036 | else:
|
|---|
| 1037 | while self._quote_stack:
|
|---|
| 1038 | deepest_offset = self._quote_stack[-1]
|
|---|
| 1039 | if depth >= deepest_offset:
|
|---|
| 1040 | break
|
|---|
| 1041 | close_quote()
|
|---|
| 1042 | if not citation and depth > 0:
|
|---|
| 1043 | if self._quote_stack:
|
|---|
| 1044 | old_offset = self._quote_stack[-1]
|
|---|
| 1045 | if old_offset != depth: # adjust last depth
|
|---|
| 1046 | self._quote_stack[-1] = depth
|
|---|
| 1047 | else:
|
|---|
| 1048 | open_quote(depth)
|
|---|
| 1049 | if depth > 0:
|
|---|
| 1050 | self.in_quote = True
|
|---|
| 1051 |
|
|---|
| 1052 | # Table
|
|---|
| 1053 |
|
|---|
| 1054 | def _table_cell_formatter(self, match, fullmatch):
|
|---|
| 1055 | self.open_table()
|
|---|
| 1056 | self.open_table_row()
|
|---|
| 1057 | self.continue_table = 1
|
|---|
| 1058 | separator = fullmatch.group('table_cell_sep')
|
|---|
| 1059 | is_last = fullmatch.group('table_cell_last')
|
|---|
| 1060 | numpipes = len(separator)
|
|---|
| 1061 | cell = 'td'
|
|---|
| 1062 | if separator[0] == '=':
|
|---|
| 1063 | numpipes -= 1
|
|---|
| 1064 | if separator[-1] == '=':
|
|---|
| 1065 | numpipes -= 1
|
|---|
| 1066 | cell = 'th'
|
|---|
| 1067 | colspan = numpipes // 2
|
|---|
| 1068 | if is_last is not None:
|
|---|
| 1069 | if is_last and is_last[-1] == '\\':
|
|---|
| 1070 | self.continue_table_row = 1
|
|---|
| 1071 | colspan -= 1
|
|---|
| 1072 | if not colspan:
|
|---|
| 1073 | return ''
|
|---|
| 1074 | attrs = ''
|
|---|
| 1075 | if colspan > 1:
|
|---|
| 1076 | attrs = ' colspan="%d"' % int(colspan)
|
|---|
| 1077 | # alignment: ||left || right||default|| default || center ||
|
|---|
| 1078 | after_sep = fullmatch.end('table_cell_sep')
|
|---|
| 1079 | alignleft = after_sep < len(self.line) and self.line[after_sep] != ' '
|
|---|
| 1080 | # lookahead next || (FIXME: this fails on ` || ` inside the cell)
|
|---|
| 1081 | next_sep = re.search(r'([^!])=?\|\|', self.line[after_sep:])
|
|---|
| 1082 | alignright = next_sep and next_sep.group(1) != ' '
|
|---|
| 1083 | textalign = None
|
|---|
| 1084 | if alignleft:
|
|---|
| 1085 | if not alignright:
|
|---|
| 1086 | textalign = 'left'
|
|---|
| 1087 | elif alignright:
|
|---|
| 1088 | textalign = 'right'
|
|---|
| 1089 | elif next_sep: # check for the extra spaces specifying a center align
|
|---|
| 1090 | first_extra = after_sep + 1
|
|---|
| 1091 | last_extra = after_sep + next_sep.start() - 1
|
|---|
| 1092 | if first_extra < last_extra and \
|
|---|
| 1093 | self.line[first_extra] == self.line[last_extra] == ' ':
|
|---|
| 1094 | textalign = 'center'
|
|---|
| 1095 | if textalign:
|
|---|
| 1096 | attrs += ' style="text-align: %s"' % textalign
|
|---|
| 1097 | td = '<%s%s>' % (cell, attrs)
|
|---|
| 1098 | if self.in_table_cell:
|
|---|
| 1099 | close_tags = ''.join(self._get_close_tag(tag)
|
|---|
| 1100 | for tag in self.pop_tags())
|
|---|
| 1101 | td = '%s</%s>%s' % (close_tags, self.in_table_cell, td)
|
|---|
| 1102 | self.in_table_cell = cell
|
|---|
| 1103 | return td
|
|---|
| 1104 |
|
|---|
| 1105 | def _table_row_sep_formatter(self, match, fullmatch):
|
|---|
| 1106 | self.open_table()
|
|---|
| 1107 | self.close_table_row(force=True)
|
|---|
| 1108 | params = fullmatch.group('table_row_params')
|
|---|
| 1109 | if params:
|
|---|
| 1110 | tr = WikiProcessor(self, 'tr', self.parse_processor_args(params))
|
|---|
| 1111 | processed = _markup_to_unicode(tr.process(''))
|
|---|
| 1112 | params = processed[3:processed.find('>')]
|
|---|
| 1113 | self.open_table_row(params or '')
|
|---|
| 1114 | self.continue_table = 1
|
|---|
| 1115 | self.continue_table_row = 1
|
|---|
| 1116 |
|
|---|
| 1117 | def open_table(self):
|
|---|
| 1118 | if not self.in_table:
|
|---|
| 1119 | self.close_paragraph()
|
|---|
| 1120 | self.close_list()
|
|---|
| 1121 | self.close_def_list()
|
|---|
| 1122 | self.in_table = 1
|
|---|
| 1123 | self.out.write('<table class="wiki">\n')
|
|---|
| 1124 |
|
|---|
| 1125 | def open_table_row(self, params=''):
|
|---|
| 1126 | if not self.in_table_row:
|
|---|
| 1127 | self.open_table()
|
|---|
| 1128 | self.in_table_row = 1
|
|---|
| 1129 | self.out.write('<tr%s>' % params)
|
|---|
| 1130 |
|
|---|
| 1131 | def close_table_row(self, force=False):
|
|---|
| 1132 | if self.in_table_row and (not self.continue_table_row or force):
|
|---|
| 1133 | self.in_table_row = 0
|
|---|
| 1134 | if self.in_table_cell:
|
|---|
| 1135 | self.flush_tags()
|
|---|
| 1136 | self.out.write('</%s>' % self.in_table_cell)
|
|---|
| 1137 | self.in_table_cell = ''
|
|---|
| 1138 | self.out.write('</tr>')
|
|---|
| 1139 | self.continue_table_row = 0
|
|---|
| 1140 |
|
|---|
| 1141 | def close_table(self):
|
|---|
| 1142 | if self.in_table:
|
|---|
| 1143 | self.close_table_row(force=True)
|
|---|
| 1144 | self.out.write('</table>\n')
|
|---|
| 1145 | self.in_table = 0
|
|---|
| 1146 |
|
|---|
| 1147 | # Paragraphs
|
|---|
| 1148 |
|
|---|
| 1149 | def open_paragraph(self):
|
|---|
| 1150 | if not self.paragraph_open:
|
|---|
| 1151 | self.out.write('<p>\n')
|
|---|
| 1152 | self.paragraph_open = 1
|
|---|
| 1153 |
|
|---|
| 1154 | def close_paragraph(self):
|
|---|
| 1155 | self.flush_tags()
|
|---|
| 1156 | if self.paragraph_open:
|
|---|
| 1157 | self.out.write('</p>\n')
|
|---|
| 1158 | self.paragraph_open = 0
|
|---|
| 1159 |
|
|---|
| 1160 | # Code blocks
|
|---|
| 1161 |
|
|---|
| 1162 | def parse_processor_args(self, line):
|
|---|
| 1163 | return parse_processor_args(line)
|
|---|
| 1164 |
|
|---|
| 1165 | def handle_code_block(self, line, startmatch=None):
|
|---|
| 1166 | if startmatch:
|
|---|
| 1167 | self.in_code_block += 1
|
|---|
| 1168 | if self.in_code_block == 1:
|
|---|
| 1169 | name = startmatch.group(2)
|
|---|
| 1170 | if name:
|
|---|
| 1171 | args = parse_processor_args(line[startmatch.end():])
|
|---|
| 1172 | self.code_processor = WikiProcessor(self, name, args)
|
|---|
| 1173 | else:
|
|---|
| 1174 | self.code_processor = None
|
|---|
| 1175 | self.code_buf = []
|
|---|
| 1176 | self.code_prefix = line[:line.find(WikiParser.STARTBLOCK)]
|
|---|
| 1177 | else:
|
|---|
| 1178 | self.code_buf.append(line)
|
|---|
| 1179 | if not self.code_processor:
|
|---|
| 1180 | self.code_processor = WikiProcessor(self, 'default')
|
|---|
| 1181 | elif line.strip() == WikiParser.ENDBLOCK:
|
|---|
| 1182 | self.in_code_block -= 1
|
|---|
| 1183 | if self.in_code_block == 0 and self.code_processor:
|
|---|
| 1184 | if self.code_processor.name not in ('th', 'td', 'tr'):
|
|---|
| 1185 | self.close_table()
|
|---|
| 1186 | self.close_paragraph()
|
|---|
| 1187 | if self.code_buf:
|
|---|
| 1188 | if self.code_prefix and all(not l or
|
|---|
| 1189 | l.startswith(self.code_prefix)
|
|---|
| 1190 | for l in self.code_buf):
|
|---|
| 1191 | code_indent = len(self.code_prefix)
|
|---|
| 1192 | self.code_buf = [l[code_indent:]
|
|---|
| 1193 | for l in self.code_buf]
|
|---|
| 1194 | self.code_buf.append('')
|
|---|
| 1195 | code_text = '\n'.join(self.code_buf)
|
|---|
| 1196 | processed = self._exec_processor(self.code_processor,
|
|---|
| 1197 | code_text)
|
|---|
| 1198 | self.out.write(_markup_to_unicode(processed))
|
|---|
| 1199 | else:
|
|---|
| 1200 | self.code_buf.append(line)
|
|---|
| 1201 | elif not self.code_processor:
|
|---|
| 1202 | match = WikiParser._processor_re.match(line)
|
|---|
| 1203 | if match:
|
|---|
| 1204 | self.code_prefix = match.group(1)
|
|---|
| 1205 | name = match.group(2)
|
|---|
| 1206 | args = parse_processor_args(line[match.end():])
|
|---|
| 1207 | self.code_processor = WikiProcessor(self, name, args)
|
|---|
| 1208 | else:
|
|---|
| 1209 | self.code_buf.append(line)
|
|---|
| 1210 | self.code_processor = WikiProcessor(self, 'default')
|
|---|
| 1211 | else:
|
|---|
| 1212 | self.code_buf.append(line)
|
|---|
| 1213 |
|
|---|
| 1214 | def close_code_blocks(self):
|
|---|
| 1215 | while self.in_code_block > 0:
|
|---|
| 1216 | self.handle_code_block(WikiParser.ENDBLOCK)
|
|---|
| 1217 |
|
|---|
| 1218 | def _exec_processor(self, processor, text):
|
|---|
| 1219 | try:
|
|---|
| 1220 | return processor.process(text)
|
|---|
| 1221 | except ProcessorError as e:
|
|---|
| 1222 | return system_message(_("Processor %(name)s failed",
|
|---|
| 1223 | name=processor.name), to_fragment(e))
|
|---|
| 1224 | except Exception as e:
|
|---|
| 1225 | self.env.log.error("Processor %s failed for %s:%s",
|
|---|
| 1226 | processor.name, self.resource,
|
|---|
| 1227 | exception_to_unicode(e, traceback=True))
|
|---|
| 1228 | return system_message(_("Error: Processor %(name)s failed",
|
|---|
| 1229 | name=processor.name), to_fragment(e))
|
|---|
| 1230 |
|
|---|
| 1231 | # > quotes
|
|---|
| 1232 |
|
|---|
| 1233 | def handle_quote_block(self, line):
|
|---|
| 1234 | self.close_paragraph()
|
|---|
| 1235 | depth = line.find('>')
|
|---|
| 1236 | # Close lists up to current level:
|
|---|
| 1237 | #
|
|---|
| 1238 | # - first level item
|
|---|
| 1239 | # - second level item
|
|---|
| 1240 | # > citation part of first level item
|
|---|
| 1241 | #
|
|---|
| 1242 | # (depth == 3, _list_stack == [1, 3])
|
|---|
| 1243 | if not self._quote_buffer and depth < self._get_list_depth():
|
|---|
| 1244 | self.close_list(depth)
|
|---|
| 1245 | self._quote_buffer.append(line[depth + 1:])
|
|---|
| 1246 |
|
|---|
| 1247 | def close_quote_block(self, escape_newlines):
|
|---|
| 1248 | if self._quote_buffer:
|
|---|
| 1249 | # avoid an extra <blockquote> when there's consistently one space
|
|---|
| 1250 | # after the '>'
|
|---|
| 1251 | if all(not line or line[0] in '> ' for line in self._quote_buffer):
|
|---|
| 1252 | self._quote_buffer = [line[bool(line and line[0] == ' '):]
|
|---|
| 1253 | for line in self._quote_buffer]
|
|---|
| 1254 | self.out.write('<blockquote class="citation">\n')
|
|---|
| 1255 | Formatter(self.env, self.context).format(self._quote_buffer,
|
|---|
| 1256 | self.out, escape_newlines)
|
|---|
| 1257 | self.out.write('</blockquote>\n')
|
|---|
| 1258 | self._quote_buffer = []
|
|---|
| 1259 |
|
|---|
| 1260 | # -- Wiki engine
|
|---|
| 1261 |
|
|---|
| 1262 | def handle_match(self, fullmatch):
|
|---|
| 1263 | for itype, match in fullmatch.groupdict().items():
|
|---|
| 1264 | if match and itype not in self.wikiparser.helper_patterns:
|
|---|
| 1265 | # Check for preceding escape character '!'
|
|---|
| 1266 | if match[0] == '!':
|
|---|
| 1267 | return escape(match[1:])
|
|---|
| 1268 | if itype in self.wikiparser.external_handlers:
|
|---|
| 1269 | external_handler = self.wikiparser.external_handlers[itype]
|
|---|
| 1270 | return external_handler(self, match, fullmatch)
|
|---|
| 1271 | else:
|
|---|
| 1272 | internal_handler = getattr(self, '_%s_formatter' % itype)
|
|---|
| 1273 | return internal_handler(match, fullmatch)
|
|---|
| 1274 |
|
|---|
| 1275 | def replace(self, fullmatch):
|
|---|
| 1276 | """Replace one match with its corresponding expansion"""
|
|---|
| 1277 | replacement = self.handle_match(fullmatch)
|
|---|
| 1278 | if replacement:
|
|---|
| 1279 | return _markup_to_unicode(replacement)
|
|---|
| 1280 |
|
|---|
| 1281 | _normalize_re = re.compile(r'[\v\f]', re.UNICODE)
|
|---|
| 1282 |
|
|---|
| 1283 | def reset(self, source, out=None):
|
|---|
| 1284 | if isinstance(source, str):
|
|---|
| 1285 | source = re.sub(self._normalize_re, ' ', source)
|
|---|
| 1286 | self.source = source
|
|---|
| 1287 | class NullOut(object):
|
|---|
| 1288 | def write(self, data):
|
|---|
| 1289 | pass
|
|---|
| 1290 | self.out = out or NullOut()
|
|---|
| 1291 | self._open_tags = []
|
|---|
| 1292 | self._list_stack = []
|
|---|
| 1293 | self._quote_stack = []
|
|---|
| 1294 | self._tabstops = []
|
|---|
| 1295 | self._quote_buffer = []
|
|---|
| 1296 |
|
|---|
| 1297 | self.in_code_block = 0
|
|---|
| 1298 | self.in_table = 0
|
|---|
| 1299 | self.in_def_list = 0
|
|---|
| 1300 | self.in_table_row = 0
|
|---|
| 1301 | self.continue_table = 0
|
|---|
| 1302 | self.continue_table_row = 0
|
|---|
| 1303 | self.in_table_cell = ''
|
|---|
| 1304 | self.paragraph_open = 0
|
|---|
| 1305 | return source
|
|---|
| 1306 |
|
|---|
| 1307 | def format(self, text, out=None, escape_newlines=False):
|
|---|
| 1308 | text = self.reset(text, out)
|
|---|
| 1309 | if isinstance(text, str):
|
|---|
| 1310 | text = text.splitlines()
|
|---|
| 1311 |
|
|---|
| 1312 | for line in text:
|
|---|
| 1313 | # Detect start of code block (new block or embedded block)
|
|---|
| 1314 | block_start_match = None
|
|---|
| 1315 | if WikiParser.ENDBLOCK not in line:
|
|---|
| 1316 | block_start_match = WikiParser._startblock_re.match(line)
|
|---|
| 1317 | # Handle content or end of code block
|
|---|
| 1318 | if self.in_code_block:
|
|---|
| 1319 | self.handle_code_block(line, block_start_match)
|
|---|
| 1320 | continue
|
|---|
| 1321 | # Handle citation quotes '> ...'
|
|---|
| 1322 | if line.strip().startswith('>'):
|
|---|
| 1323 | self.handle_quote_block(line)
|
|---|
| 1324 | continue
|
|---|
| 1325 | # Handle end of citation quotes
|
|---|
| 1326 | self.close_quote_block(escape_newlines)
|
|---|
| 1327 | # Handle start of a new block
|
|---|
| 1328 | if block_start_match:
|
|---|
| 1329 | self.handle_code_block(line, block_start_match)
|
|---|
| 1330 | continue
|
|---|
| 1331 | # Handle Horizontal ruler
|
|---|
| 1332 | if line[0:4] == '----':
|
|---|
| 1333 | self.close_table()
|
|---|
| 1334 | self.close_paragraph()
|
|---|
| 1335 | self.close_indentation()
|
|---|
| 1336 | self.close_list()
|
|---|
| 1337 | self.close_def_list()
|
|---|
| 1338 | self.out.write('<hr />\n')
|
|---|
| 1339 | continue
|
|---|
| 1340 | # Handle new paragraph
|
|---|
| 1341 | if line == '':
|
|---|
| 1342 | self.close_table()
|
|---|
| 1343 | self.close_paragraph()
|
|---|
| 1344 | self.close_indentation()
|
|---|
| 1345 | self.close_list()
|
|---|
| 1346 | self.close_def_list()
|
|---|
| 1347 | continue
|
|---|
| 1348 |
|
|---|
| 1349 | # Tab expansion and clear tabstops if no indent
|
|---|
| 1350 | line = line.replace('\t', ' '*8)
|
|---|
| 1351 | if not line.startswith(' '):
|
|---|
| 1352 | self._tabstops = []
|
|---|
| 1353 |
|
|---|
| 1354 | # Handle end of indentation
|
|---|
| 1355 | if not line.startswith(' ') and self._quote_stack:
|
|---|
| 1356 | self.close_indentation()
|
|---|
| 1357 |
|
|---|
| 1358 | self.in_list_item = False
|
|---|
| 1359 | self.in_quote = False
|
|---|
| 1360 | # Throw a bunch of regexps on the problem
|
|---|
| 1361 | self.line = line
|
|---|
| 1362 | result = re.sub(self.wikiparser.rules, self.replace, line)
|
|---|
| 1363 |
|
|---|
| 1364 | if not self.in_list_item:
|
|---|
| 1365 | self.close_list()
|
|---|
| 1366 |
|
|---|
| 1367 | if not self.in_quote:
|
|---|
| 1368 | self.close_indentation()
|
|---|
| 1369 |
|
|---|
| 1370 | if self.in_def_list and not line.startswith(' '):
|
|---|
| 1371 | self.close_def_list()
|
|---|
| 1372 |
|
|---|
| 1373 | if self.in_table and not self.continue_table:
|
|---|
| 1374 | self.close_table()
|
|---|
| 1375 | self.continue_table = 0
|
|---|
| 1376 |
|
|---|
| 1377 | sep = '\n'
|
|---|
| 1378 | if not(self.in_list_item or self.in_def_list or self.in_table):
|
|---|
| 1379 | if len(result):
|
|---|
| 1380 | self.open_paragraph()
|
|---|
| 1381 | if escape_newlines and self.paragraph_open and \
|
|---|
| 1382 | not result.rstrip().endswith('<br />'):
|
|---|
| 1383 | sep = '<br />' + sep
|
|---|
| 1384 | self.out.write(result + sep)
|
|---|
| 1385 | self.close_table_row()
|
|---|
| 1386 |
|
|---|
| 1387 | self.close_code_blocks()
|
|---|
| 1388 | self.close_quote_block(escape_newlines)
|
|---|
| 1389 | self.close_table()
|
|---|
| 1390 | self.close_paragraph()
|
|---|
| 1391 | self.close_indentation()
|
|---|
| 1392 | self.close_list()
|
|---|
| 1393 | self.close_def_list()
|
|---|
| 1394 |
|
|---|
| 1395 |
|
|---|
| 1396 | class OneLinerFormatter(Formatter):
|
|---|
| 1397 | """
|
|---|
| 1398 | A special version of the wiki formatter that only implement a
|
|---|
| 1399 | subset of the wiki formatting functions. This version is useful
|
|---|
| 1400 | for rendering short wiki-formatted messages on a single line
|
|---|
| 1401 | """
|
|---|
| 1402 | flavor = 'oneliner'
|
|---|
| 1403 |
|
|---|
| 1404 | # Override a few formatters to disable some wiki syntax in "oneliner"-mode
|
|---|
| 1405 | def _list_formatter(self, match, fullmatch):
|
|---|
| 1406 | return match
|
|---|
| 1407 | def _indent_formatter(self, match, fullmatch):
|
|---|
| 1408 | return match
|
|---|
| 1409 | def _citation_formatter(self, match, fullmatch):
|
|---|
| 1410 | return escape(match, False)
|
|---|
| 1411 | def _heading_formatter(self, match, fullmatch):
|
|---|
| 1412 | return escape(match, False)
|
|---|
| 1413 | def _definition_formatter(self, match, fullmatch):
|
|---|
| 1414 | return escape(match, False)
|
|---|
| 1415 | def _table_cell_formatter(self, match, fullmatch):
|
|---|
| 1416 | return match
|
|---|
| 1417 | def _table_row_sep_formatter(self, match, fullmatch):
|
|---|
| 1418 | return ''
|
|---|
| 1419 |
|
|---|
| 1420 | def _linebreak_wc_formatter(self, match, fullmatch):
|
|---|
| 1421 | return ' '
|
|---|
| 1422 |
|
|---|
| 1423 | def _macro_formatter(self, match, fullmatch, macro):
|
|---|
| 1424 | name = fullmatch.group('macroname')
|
|---|
| 1425 | if name.lower() == 'br':
|
|---|
| 1426 | return ' '
|
|---|
| 1427 | args = fullmatch.group('macroargs')
|
|---|
| 1428 | if macro.is_inline(args):
|
|---|
| 1429 | return Formatter._macro_formatter(self, match, fullmatch, macro)
|
|---|
| 1430 | else:
|
|---|
| 1431 | return '[[%s%s]]' % (name, '(...)' if args else '')
|
|---|
| 1432 |
|
|---|
| 1433 | def format(self, text, out, shorten=False):
|
|---|
| 1434 | if not text:
|
|---|
| 1435 | return
|
|---|
| 1436 | text = self.reset(text, out)
|
|---|
| 1437 |
|
|---|
| 1438 | # Simplify code blocks
|
|---|
| 1439 | in_code_block = 0
|
|---|
| 1440 | processor = None
|
|---|
| 1441 | buf = io.StringIO()
|
|---|
| 1442 | for line in text.strip().splitlines():
|
|---|
| 1443 | if WikiParser.ENDBLOCK not in line and \
|
|---|
| 1444 | WikiParser._startblock_re.match(line):
|
|---|
| 1445 | in_code_block += 1
|
|---|
| 1446 | elif line.strip() == WikiParser.ENDBLOCK:
|
|---|
| 1447 | if in_code_block:
|
|---|
| 1448 | in_code_block -= 1
|
|---|
| 1449 | if in_code_block == 0:
|
|---|
| 1450 | if processor != 'comment':
|
|---|
| 1451 | buf.write(' [...]\n')
|
|---|
| 1452 | processor = None
|
|---|
| 1453 | elif in_code_block:
|
|---|
| 1454 | if not processor:
|
|---|
| 1455 | if line.startswith('#!'):
|
|---|
| 1456 | processor = line[2:].strip()
|
|---|
| 1457 | else:
|
|---|
| 1458 | buf.write(line + '\n')
|
|---|
| 1459 | result = buf.getvalue()[:-len('\n')]
|
|---|
| 1460 |
|
|---|
| 1461 | if shorten:
|
|---|
| 1462 | result = shorten_line(result)
|
|---|
| 1463 |
|
|---|
| 1464 | result = re.sub(self.wikiparser.rules, self.replace, result)
|
|---|
| 1465 | result = result.replace('[...]', '[\u2026]')
|
|---|
| 1466 | if result.endswith('...'):
|
|---|
| 1467 | result = result[:-3] + '\u2026'
|
|---|
| 1468 |
|
|---|
| 1469 | self.out.write(result)
|
|---|
| 1470 | # Close all open 'one line'-tags
|
|---|
| 1471 | self.flush_tags()
|
|---|
| 1472 | # Flush unterminated code blocks
|
|---|
| 1473 | if in_code_block > 0:
|
|---|
| 1474 | self.out.write('[\u2026]')
|
|---|
| 1475 |
|
|---|
| 1476 |
|
|---|
| 1477 | class OutlineFormatter(Formatter):
|
|---|
| 1478 | """Special formatter that generates an outline of all the headings."""
|
|---|
| 1479 | flavor = 'outline'
|
|---|
| 1480 |
|
|---|
| 1481 | # Avoid the possible side-effects of rendering WikiProcessors
|
|---|
| 1482 | def _macro_formatter(self, match, fullmatch, macro):
|
|---|
| 1483 | name = fullmatch.group('macroname')
|
|---|
| 1484 | if name.lower() == 'br':
|
|---|
| 1485 | return ' '
|
|---|
| 1486 | args = fullmatch.group('macroargs')
|
|---|
| 1487 | if macro.is_inline(args):
|
|---|
| 1488 | return Formatter._macro_formatter(self, match, fullmatch, macro)
|
|---|
| 1489 | return ''
|
|---|
| 1490 |
|
|---|
| 1491 | def handle_code_block(self, line, startmatch=None):
|
|---|
| 1492 | if WikiParser.ENDBLOCK not in line and \
|
|---|
| 1493 | WikiParser._startblock_re.match(line):
|
|---|
| 1494 | self.in_code_block += 1
|
|---|
| 1495 | elif line.strip() == WikiParser.ENDBLOCK:
|
|---|
| 1496 | self.in_code_block -= 1
|
|---|
| 1497 |
|
|---|
| 1498 | def format(self, text, out, max_depth=6, min_depth=1, shorten=True):
|
|---|
| 1499 | self.shorten = shorten
|
|---|
| 1500 | whitespace_indent = ' '
|
|---|
| 1501 | self.outline = []
|
|---|
| 1502 | Formatter.format(self, text)
|
|---|
| 1503 |
|
|---|
| 1504 | if min_depth > max_depth:
|
|---|
| 1505 | min_depth, max_depth = max_depth, min_depth
|
|---|
| 1506 | max_depth = min(6, max_depth)
|
|---|
| 1507 | min_depth = max(1, min_depth)
|
|---|
| 1508 |
|
|---|
| 1509 | curr_depth = min_depth - 1
|
|---|
| 1510 | out.write('\n')
|
|---|
| 1511 | for depth, anchor, text in self.outline:
|
|---|
| 1512 | if depth < min_depth or depth > max_depth:
|
|---|
| 1513 | continue
|
|---|
| 1514 | if depth > curr_depth: # Deeper indent
|
|---|
| 1515 | for i in range(curr_depth, depth):
|
|---|
| 1516 | out.write(whitespace_indent * (2*i) + '<ol>\n' +
|
|---|
| 1517 | whitespace_indent * (2*i+1) + '<li>\n')
|
|---|
| 1518 | elif depth < curr_depth: # Shallower indent
|
|---|
| 1519 | for i in range(curr_depth-1, depth-1, -1):
|
|---|
| 1520 | out.write(whitespace_indent * (2*i+1) + '</li>\n' +
|
|---|
| 1521 | whitespace_indent * (2*i) + '</ol>\n')
|
|---|
| 1522 | out.write(whitespace_indent * (2*depth-1) + '</li>\n' +
|
|---|
| 1523 | whitespace_indent * (2*depth-1) + '<li>\n')
|
|---|
| 1524 | else: # Same indent
|
|---|
| 1525 | out.write( whitespace_indent * (2*depth-1) + '</li>\n' +
|
|---|
| 1526 | whitespace_indent * (2*depth-1) + '<li>\n')
|
|---|
| 1527 | curr_depth = depth
|
|---|
| 1528 | out.write(whitespace_indent * (2*depth) +
|
|---|
| 1529 | '<a href="#%s">%s</a>\n' % (anchor, text))
|
|---|
| 1530 | # Close out all indentation
|
|---|
| 1531 | for i in range(curr_depth-1, min_depth-2, -1):
|
|---|
| 1532 | out.write(whitespace_indent * (2*i+1) + '</li>\n' +
|
|---|
| 1533 | whitespace_indent * (2*i) + '</ol>\n')
|
|---|
| 1534 |
|
|---|
| 1535 | def _heading_formatter(self, match, fullmatch):
|
|---|
| 1536 | depth, heading, anchor = self._parse_heading(match, fullmatch,
|
|---|
| 1537 | self.shorten)
|
|---|
| 1538 | heading = re.sub(r'</?a(?: .*?)?>', '', heading) # Strip out link tags
|
|---|
| 1539 | self.outline.append((depth, anchor, heading))
|
|---|
| 1540 |
|
|---|
| 1541 |
|
|---|
| 1542 | class LinkFormatter(OutlineFormatter):
|
|---|
| 1543 | """Special formatter that focuses on TracLinks."""
|
|---|
| 1544 | flavor = 'link'
|
|---|
| 1545 |
|
|---|
| 1546 | def _heading_formatter(self, match, fullmatch):
|
|---|
| 1547 | return ''
|
|---|
| 1548 |
|
|---|
| 1549 | def match(self, wikitext):
|
|---|
| 1550 | """Return the Wiki match found at the beginning of the `wikitext`"""
|
|---|
| 1551 | wikitext = self.reset(wikitext)
|
|---|
| 1552 | self.line = wikitext
|
|---|
| 1553 | match = re.match(self.wikiparser.rules, wikitext)
|
|---|
| 1554 | if match:
|
|---|
| 1555 | return self.handle_match(match)
|
|---|
| 1556 |
|
|---|
| 1557 |
|
|---|
| 1558 | # Pure Wiki Formatter
|
|---|
| 1559 |
|
|---|
| 1560 | class HtmlFormatter(object):
|
|---|
| 1561 | """Format parsed wiki text to HTML"""
|
|---|
| 1562 |
|
|---|
| 1563 | flavor = 'default'
|
|---|
| 1564 |
|
|---|
| 1565 | def __init__(self, env, context, wikidom):
|
|---|
| 1566 | self.env = env
|
|---|
| 1567 | self.context = context
|
|---|
| 1568 | if isinstance(wikidom, str):
|
|---|
| 1569 | wikidom = WikiParser(env).parse(wikidom)
|
|---|
| 1570 | self.wikidom = wikidom
|
|---|
| 1571 |
|
|---|
| 1572 | def generate(self, escape_newlines=False):
|
|---|
| 1573 | """Generate HTML elements.
|
|---|
| 1574 |
|
|---|
| 1575 | newlines in the wikidom will be preserved if `escape_newlines` is set.
|
|---|
| 1576 | """
|
|---|
| 1577 | # FIXME: compatibility code only for now
|
|---|
| 1578 | out = io.StringIO()
|
|---|
| 1579 | Formatter(self.env, self.context).format(self.wikidom, out,
|
|---|
| 1580 | escape_newlines)
|
|---|
| 1581 | return Markup(out.getvalue())
|
|---|
| 1582 |
|
|---|
| 1583 |
|
|---|
| 1584 | class InlineHtmlFormatter(object):
|
|---|
| 1585 | """Format parsed wiki text to inline elements HTML.
|
|---|
| 1586 |
|
|---|
| 1587 | Block level content will be discarded or compacted.
|
|---|
| 1588 | """
|
|---|
| 1589 |
|
|---|
| 1590 | flavor = 'oneliner'
|
|---|
| 1591 |
|
|---|
| 1592 | def __init__(self, env, context, wikidom):
|
|---|
| 1593 | self.env = env
|
|---|
| 1594 | self.context = context
|
|---|
| 1595 | if isinstance(wikidom, str):
|
|---|
| 1596 | wikidom = WikiParser(env).parse(wikidom)
|
|---|
| 1597 | self.wikidom = wikidom
|
|---|
| 1598 |
|
|---|
| 1599 | def generate(self, shorten=False):
|
|---|
| 1600 | """Generate HTML inline elements.
|
|---|
| 1601 |
|
|---|
| 1602 | If `shorten` is set, the generation will stop once enough characters
|
|---|
| 1603 | have been emitted.
|
|---|
| 1604 | """
|
|---|
| 1605 | # FIXME: compatibility code only for now
|
|---|
| 1606 | out = io.StringIO()
|
|---|
| 1607 | OneLinerFormatter(self.env, self.context).format(self.wikidom, out,
|
|---|
| 1608 | shorten)
|
|---|
| 1609 | return Markup(out.getvalue())
|
|---|
| 1610 |
|
|---|
| 1611 |
|
|---|
| 1612 | def format_to(env, flavor, context, wikidom, **options):
|
|---|
| 1613 | if flavor is None:
|
|---|
| 1614 | flavor = context.get_hint('wiki_flavor', 'html')
|
|---|
| 1615 | if flavor == 'oneliner':
|
|---|
| 1616 | return format_to_oneliner(env, context, wikidom, **options)
|
|---|
| 1617 | else:
|
|---|
| 1618 | return format_to_html(env, context, wikidom, **options)
|
|---|
| 1619 |
|
|---|
| 1620 | def format_to_html(env, context, wikidom, escape_newlines=None):
|
|---|
| 1621 | if not wikidom:
|
|---|
| 1622 | return Markup()
|
|---|
| 1623 | if escape_newlines is None:
|
|---|
| 1624 | escape_newlines = context.get_hint('preserve_newlines', False)
|
|---|
| 1625 | return HtmlFormatter(env, context, wikidom).generate(escape_newlines)
|
|---|
| 1626 |
|
|---|
| 1627 | def format_to_oneliner(env, context, wikidom, shorten=None):
|
|---|
| 1628 | if not wikidom:
|
|---|
| 1629 | return Markup()
|
|---|
| 1630 | if shorten is None:
|
|---|
| 1631 | shorten = context.get_hint('shorten_lines', False)
|
|---|
| 1632 | return InlineHtmlFormatter(env, context, wikidom).generate(shorten)
|
|---|
| 1633 |
|
|---|
| 1634 | def extract_link(env, context, wikidom):
|
|---|
| 1635 | if not wikidom:
|
|---|
| 1636 | return Markup()
|
|---|
| 1637 | return LinkFormatter(env, context).match(wikidom)
|
|---|
| 1638 |
|
|---|
| 1639 |
|
|---|
| 1640 | # pre-0.11 wiki text to Markup compatibility methods
|
|---|
| 1641 |
|
|---|
| 1642 | def wiki_to_outline(wikitext, env, db=None,
|
|---|
| 1643 | absurls=False, max_depth=None, min_depth=None, req=None):
|
|---|
| 1644 | """:deprecated: will be removed in 1.0 and replaced by something else"""
|
|---|
| 1645 | if not wikitext:
|
|---|
| 1646 | return Markup()
|
|---|
| 1647 | abs_ref, href = (req or env).abs_href, (req or env).href
|
|---|
| 1648 | from trac.web.chrome import web_context
|
|---|
| 1649 | context = web_context(req, absurls=absurls)
|
|---|
| 1650 | out = io.StringIO()
|
|---|
| 1651 | OutlineFormatter(env, context).format(wikitext, out, max_depth, min_depth)
|
|---|
| 1652 | return Markup(out.getvalue())
|
|---|