Skip to content

Commit

Permalink
Merge pull request #2654 from jonascj/rtf-linenos
Browse files Browse the repository at this point in the history
Add line numbers and line highlighting to the RTF-formatter
  • Loading branch information
Anteru committed Apr 27, 2024
2 parents 41a8a63 + fa8d083 commit a5cd0f1
Show file tree
Hide file tree
Showing 3 changed files with 688 additions and 32 deletions.
2 changes: 2 additions & 0 deletions AUTHORS
Expand Up @@ -116,6 +116,8 @@ Other contributors, listed alphabetically, are:
MSDOS session, BC, WDiff
* Brian R. Jackson -- Tea lexer
* Christian Jann -- ShellSession lexer
* Jonas Camillus Jeppesen -- Line numbers and line highlighting for
RTF-formatter
* Dennis Kaarsemaker -- sources.list lexer
* Dmitri Kabak -- Inferno Limbo lexer
* Igor Kalnitsky -- vhdl lexer
Expand Down
251 changes: 228 additions & 23 deletions pygments/formatters/rtf.py
Expand Up @@ -8,8 +8,10 @@
:license: BSD, see LICENSE for details.
"""

from collections import OrderedDict
from pygments.formatter import Formatter
from pygments.util import get_int_opt, surrogatepair
from pygments.style import _ansimap
from pygments.util import get_bool_opt, get_int_opt, get_list_opt, surrogatepair


__all__ = ['RtfFormatter']
Expand Down Expand Up @@ -42,6 +44,59 @@ class RtfFormatter(Formatter):
default is 24 half-points, giving a size 12 font.
.. versionadded:: 2.0
`linenos`
Turn on line numbering (default: ``False``).
.. versionadded:: 2.18
`lineno_fontsize`
Font size for line numbers. Size is specified in half points
(default: `fontsize`).
.. versionadded:: 2.18
`lineno_padding`
Number of spaces between the (inline) line numbers and the
source code (default: ``2``).
.. versionadded:: 2.18
`linenostart`
The line number for the first line (default: ``1``).
.. versionadded:: 2.18
`linenostep`
If set to a number n > 1, only every nth line number is printed.
.. versionadded:: 2.18
`lineno_color`
Color for line numbers specified as a hex triplet, e.g. ``'5e5e5e'``.
Defaults to the style's line number color if it is a hex triplet,
otherwise ansi bright black.
.. versionadded:: 2.18
`hl_lines`
Specify a list of lines to be highlighted, as line numbers separated by
spaces, e.g. ``'3 7 8'``. The line numbers are relative to the input
(i.e. the first line is line 1) unless `hl_linenostart` is set.
.. versionadded:: 2.18
`hl_color`
Color for highlighting the lines specified in `hl_lines`, specified as
a hex triplet (default: style's `highlight_color`).
.. versionadded:: 2.18
`hl_linenostart`
If set to ``True`` line numbers in `hl_lines` are specified
relative to `linenostart` (default ``False``).
.. versionadded:: 2.18
"""
name = 'RTF'
aliases = ['rtf']
Expand All @@ -62,6 +117,40 @@ def __init__(self, **options):
Formatter.__init__(self, **options)
self.fontface = options.get('fontface') or ''
self.fontsize = get_int_opt(options, 'fontsize', 0)
self.linenos = get_bool_opt(options, 'linenos', False)
self.lineno_fontsize = get_int_opt(options, 'lineno_fontsize',
self.fontsize)
self.lineno_padding = get_int_opt(options, 'lineno_padding', 2)
self.linenostart = abs(get_int_opt(options, 'linenostart', 1))
self.linenostep = abs(get_int_opt(options, 'linenostep', 1))
self.hl_linenostart = get_bool_opt(options, 'hl_linenostart', False)

self.hl_color = options.get('hl_color', '')
if not self.hl_color:
self.hl_color = self.style.highlight_color

self.hl_lines = []
for lineno in get_list_opt(options, 'hl_lines', []):
try:
lineno = int(lineno)
if self.hl_linenostart:
lineno = lineno - self.linenostart + 1
self.hl_lines.append(lineno)
except ValueError:
pass

self.lineno_color = options.get('lineno_color', '')
if not self.lineno_color:
if self.style.line_number_color == 'inherit':
# style color is the css value 'inherit'
# default to ansi bright-black
self.lineno_color = _ansimap['ansibrightblack']
else:
# style color is assumed to be a hex triplet as other
# colors in pygments/style.py
self.lineno_color = self.style.line_number_color

self.color_mapping = self._create_color_mapping()

def _escape(self, text):
return text.replace('\\', '\\\\') \
Expand Down Expand Up @@ -90,43 +179,147 @@ def _escape_text(self, text):
# Force surrogate pairs
buf.append('{\\u%d}{\\u%d}' % surrogatepair(cn))

return ''.join(buf).replace('\n', '\\par\n')
return ''.join(buf).replace('\n', '\\par')

def format_unencoded(self, tokensource, outfile):
# rtf 1.8 header
outfile.write('{\\rtf1\\ansi\\uc0\\deff0'
'{\\fonttbl{\\f0\\fmodern\\fprq1\\fcharset0%s;}}'
'{\\colortbl;' % (self.fontface and
' ' + self._escape(self.fontface) or
''))

# convert colors and save them in a mapping to access them later.
color_mapping = {}
@staticmethod
def hex_to_rtf_color(hex_color):
if hex_color[0] == "#":
hex_color = hex_color[1:]

return '\\red%d\\green%d\\blue%d;' % (
int(hex_color[0:2], 16),
int(hex_color[2:4], 16),
int(hex_color[4:6], 16)
)

def _split_tokens_on_newlines(self, tokensource):
"""
Split tokens containing newline characters into multiple token
each representing a line of the input file. Needed for numbering
lines of e.g. multiline comments.
"""
for ttype, value in tokensource:
if value == '\n':
yield (ttype, value)
elif "\n" in value:
lines = value.split("\n")
for line in lines[:-1]:
yield (ttype, line+"\n")
if lines[-1]:
yield (ttype, lines[-1])
else:
yield (ttype, value)

def _create_color_mapping(self):
"""
Create a mapping of style hex colors to index/offset in
the RTF color table.
"""
color_mapping = OrderedDict()
offset = 1

if self.linenos:
color_mapping[self.lineno_color] = offset
offset += 1

if self.hl_lines:
color_mapping[self.hl_color] = offset
offset += 1

for _, style in self.style:
for color in style['color'], style['bgcolor'], style['border']:
if color and color not in color_mapping:
color_mapping[color] = offset
outfile.write('\\red%d\\green%d\\blue%d;' % (
int(color[0:2], 16),
int(color[2:4], 16),
int(color[4:6], 16)
))
offset += 1
outfile.write('}\\f0 ')

return color_mapping

@property
def _lineno_template(self):
if self.lineno_fontsize != self.fontsize:
return '{\\fs%s \\cf%s %%s%s}' \
% (self.lineno_fontsize,
self.color_mapping[self.lineno_color],
" " * self.lineno_padding)

return '{\\cf%s %%s%s}' \
% (self.color_mapping[self.lineno_color],
" " * self.lineno_padding)

@property
def _hl_open_str(self):
return r'{\highlight%s ' % self.color_mapping[self.hl_color]

@property
def _rtf_header(self):
lines = []
# rtf 1.8 header
lines.append('{\\rtf1\\ansi\\uc0\\deff0'
'{\\fonttbl{\\f0\\fmodern\\fprq1\\fcharset0%s;}}'
% (self.fontface and ' '
+ self._escape(self.fontface) or ''))

# color table
lines.append('{\\colortbl;')
for color, _ in self.color_mapping.items():
lines.append(self.hex_to_rtf_color(color))
lines.append('}')

# font and fontsize
lines.append('\\f0')
if self.fontsize:
outfile.write('\\fs%d' % self.fontsize)
lines.append('\\fs%d' % self.fontsize)

# ensure Libre Office Writer imports and renders consecutive
# space characters the same width, needed for line numbering.
# https://bugs.documentfoundation.org/show_bug.cgi?id=144050
lines.append('\\dntblnsbdb')

return lines

def format_unencoded(self, tokensource, outfile):
for line in self._rtf_header:
outfile.write(line + "\n")

tokensource = self._split_tokens_on_newlines(tokensource)

# first pass of tokens to count lines, needed for line numbering
if self.linenos:
line_count = 0
tokens = [] # for copying the token source generator
for ttype, value in tokensource:
tokens.append((ttype, value))
if value.endswith("\n"):
line_count += 1

# width of line number strings (for padding with spaces)
linenos_width = len(str(line_count+self.linenostart-1))

tokensource = tokens

# highlight stream
lineno = 1
start_new_line = True
for ttype, value in tokensource:
if start_new_line and lineno in self.hl_lines:
outfile.write(self._hl_open_str)

if start_new_line and self.linenos:
if (lineno-self.linenostart+1)%self.linenostep == 0:
current_lineno = lineno + self.linenostart - 1
lineno_str = str(current_lineno).rjust(linenos_width)
else:
lineno_str = "".rjust(linenos_width)
outfile.write(self._lineno_template % lineno_str)

while not self.style.styles_token(ttype) and ttype.parent:
ttype = ttype.parent
style = self.style.style_for_token(ttype)
buf = []
if style['bgcolor']:
buf.append('\\cb%d' % color_mapping[style['bgcolor']])
buf.append('\\cb%d' % self.color_mapping[style['bgcolor']])
if style['color']:
buf.append('\\cf%d' % color_mapping[style['color']])
buf.append('\\cf%d' % self.color_mapping[style['color']])
if style['bold']:
buf.append('\\b')
if style['italic']:
Expand All @@ -135,12 +328,24 @@ def format_unencoded(self, tokensource, outfile):
buf.append('\\ul')
if style['border']:
buf.append('\\chbrdr\\chcfpat%d' %
color_mapping[style['border']])
self.color_mapping[style['border']])
start = ''.join(buf)
if start:
outfile.write('{%s ' % start)
outfile.write(self._escape_text(value))
if start:
outfile.write('}')
start_new_line = False

# complete line of input
if value.endswith("\n"):
# close line highlighting
if lineno in self.hl_lines:
outfile.write('}')
# newline in RTF file after closing }
outfile.write("\n")

start_new_line = True
lineno += 1

outfile.write('}')
outfile.write('}\n')

0 comments on commit a5cd0f1

Please sign in to comment.