2
reStructuredText renderer
3
=========================
5
.. versionadded:: 1.1.0
7
`reStructuredText <http://docutils.sourceforge.net/rst.html>`_ is an
8
easy-to-read, what-you-see-is-what-you-get plaintext markup syntax and parser
13
This widget is highly experimental. The whole styling and
14
implementation are not stable until this warning has been removed.
27
This is an **emphased text**, some ``interpreted text``.
28
And this is a reference to top_::
30
$ print("Hello world")
33
document = RstDocument(text=text)
35
The rendering will output:
37
.. image:: images/rstdocument.png
42
You can also render a rst file using the :attr:`RstDocument.source` property::
44
document = RstDocument(source='index.rst')
46
You can reference other documents with the role ``:doc:``. For example, in the
47
document ``index.rst`` you can write::
49
Go to my next document: :doc:`moreinfo.rst`
51
It will generate a link that, when clicked, opens the ``moreinfo.rst``
56
__all__ = ('RstDocument', )
59
from os.path import dirname, join, exists, abspath
60
from kivy.clock import Clock
61
from kivy.compat import PY2
62
from kivy.properties import ObjectProperty, NumericProperty, \
63
DictProperty, ListProperty, StringProperty, \
64
BooleanProperty, OptionProperty, AliasProperty
65
from kivy.lang import Builder
66
from kivy.utils import get_hex_from_color, get_color_from_hex
67
from kivy.uix.widget import Widget
68
from kivy.uix.scrollview import ScrollView
69
from kivy.uix.gridlayout import GridLayout
70
from kivy.uix.label import Label
71
from kivy.uix.image import AsyncImage, Image
72
from kivy.uix.videoplayer import VideoPlayer
73
from kivy.uix.anchorlayout import AnchorLayout
74
from kivy.animation import Animation
75
from kivy.logger import Logger
76
from docutils.parsers import rst
77
from docutils.parsers.rst import roles
78
from docutils import nodes, frontend, utils
79
from docutils.parsers.rst import Directive, directives
80
from docutils.parsers.rst.roles import set_classes
81
from kivy.parser import parse_color
85
# Handle some additional roles
87
if 'KIVY_DOC' not in os.environ:
89
class role_doc(nodes.Inline, nodes.TextElement):
92
class role_video(nodes.General, nodes.TextElement):
95
class VideoDirective(Directive):
97
required_arguments = 1
98
optional_arguments = 0
99
final_argument_whitespace = True
100
option_spec = {'width': directives.nonnegative_int,
101
'height': directives.nonnegative_int}
104
set_classes(self.options)
105
node = role_video(source=self.arguments[0], **self.options)
111
for rolename, nodeclass in generic_docroles.items():
112
generic = roles.GenericRole(rolename, nodeclass)
113
role = roles.CustomRole(rolename, generic, {'classes': [rolename]})
114
roles.register_local_role(rolename, role)
116
directives.register_directive('video', VideoDirective)
118
Builder.load_string('''
119
#:import parse_color kivy.parser.parse_color
129
rgba: parse_color(root.colors['background'])
137
height: content.minimum_height
140
do_translation: False, False
147
height: self.minimum_height
155
sp(self.document.base_font_size - self.section * (
156
self.document.base_font_size / 31.0 * 2))
158
height: self.texture_size[1] + dp(20)
159
text_size: self.width, None
164
rgba: parse_color(self.document.underline_color)
166
pos: self.x, self.y + 5
174
height: self.texture_size[1] + self.my
175
text_size: self.width - self.mx, None
176
font_size: sp(self.document.base_font_size / 2.0)
179
size_hint: None, None
187
size_hint: None, None
188
size: self.texture_size[0] + dp(10), self.texture_size[1] + dp(10)
189
font_size: sp(root.document.base_font_size / 2.0)
195
height: content.height
203
height: self.minimum_height
209
height: content.texture_size[1] + dp(20)
212
rgb: parse_color('#cccccc')
214
pos: self.x - 1, self.y - 1
215
size: self.width + 2, self.height + 2
217
rgb: parse_color('#eeeeee')
225
text_size: self.width - 20, None
226
font_name: 'data/fonts/RobotoMono-Regular.ttf'
232
height: self.minimum_height
237
height: self.minimum_height
242
height: self.minimum_height
255
height: self.minimum_height
260
pos: self.x + 10, self.y + 10
261
size: self.width - 20, self.height - 20
266
height: self.minimum_height
273
height: self.minimum_height
278
pos: self.x + 10, self.y + 10
279
size: self.width - 20, self.height - 20
284
height: self.minimum_height
287
size_hint: None, None
288
size: self.texture_size[0], self.texture_size[1] + dp(10)
291
size_hint: None, None
292
size: self.texture_size[0], self.texture_size[1] + dp(10)
297
height: self.minimum_height
298
font_size: sp(self.document.base_font_size / 2.0)
303
height: self.minimum_height
304
font_size: sp(self.document.base_font_size / 2.0)
309
height: self.minimum_height
317
text_size: self.width-10, self.height - 10
319
font_size: sp(self.document.base_font_size / 2.0)
324
height: self.minimum_height
328
height: self.minimum_height
333
height: self.minimum_height
358
points: [self.x, self.center_y, self.right, self.center_y]
364
width: self.texture_size[0] + dp(10)
365
text_size: None, self.height - dp(10)
366
font_size: sp(self.document.base_font_size / 2.0)
369
size_hint: 0.01, 0.01
371
<RstDefinitionSpace>:
374
font_size: sp(self.document.base_font_size / 2.0)
377
options: {'allow_stretch': True}
382
source: 'atlas://data/images/defaulttheme/player-background'
383
pos: self.x - 25, self.y - 25
384
size: self.width + 50, self.height + 50
385
border: (25, 25, 25, 25)
389
class RstVideoPlayer(VideoPlayer):
393
class RstDocument(ScrollView):
394
'''Base widget used to store an Rst document. See module documentation for
397
source = StringProperty(None)
398
'''Filename of the RST document.
400
:attr:`source` is a :class:`~kivy.properties.StringProperty` and
404
source_encoding = StringProperty('utf-8')
405
'''Encoding to be used for the :attr:`source` file.
407
:attr:`source_encoding` is a :class:`~kivy.properties.StringProperty` and
411
It is your responsibility to ensure that the value provided is a
412
valid codec supported by python.
415
source_error = OptionProperty('strict',
416
options=('strict', 'ignore', 'replace',
419
'''Error handling to be used while encoding the :attr:`source` file.
421
:attr:`source_error` is an :class:`~kivy.properties.OptionProperty` and
422
defaults to `strict`. Can be one of 'strict', 'ignore', 'replace',
423
'xmlcharrefreplace' or 'backslashreplac'.
426
text = StringProperty(None)
427
'''RST markup text of the document.
429
:attr:`text` is a :class:`~kivy.properties.StringProperty` and defaults to
433
document_root = StringProperty(None)
434
'''Root path where :doc: will search for rst documents. If no path is
435
given, it will use the directory of the first loaded source file.
437
:attr:`document_root` is a :class:`~kivy.properties.StringProperty` and
441
base_font_size = NumericProperty(31)
442
'''Font size for the biggest title, 31 by default. All other font sizes are
445
.. versionadded:: 1.8.0
448
show_errors = BooleanProperty(False)
449
'''Indicate whether RST parsers errors should be shown on the screen
452
:attr:`show_errors` is a :class:`~kivy.properties.BooleanProperty` and
457
return get_color_from_hex(self.colors.background)
459
def _set_bgc(self, value):
460
self.colors.background = get_hex_from_color(value)[1:]
462
background_color = AliasProperty(_get_bgc, _set_bgc, bind=('colors',))
463
'''Specifies the background_color to be used for the RstDocument.
465
.. versionadded:: 1.8.0
467
:attr:`background_color` is an :class:`~kivy.properties.AliasProperty`
468
for colors['background'].
471
colors = DictProperty({
472
'background': 'e5e6e9ff',
474
'paragraph': '202020ff',
476
'bullet': '000000ff'})
477
'''Dictionary of all the colors used in the RST rendering.
481
This dictionary is needs special handling. You also need to call
482
:meth:`RstDocument.render` if you change them after loading.
484
:attr:`colors` is a :class:`~kivy.properties.DictProperty`.
487
title = StringProperty('')
488
'''Title of the current document.
490
:attr:`title` is a :class:`~kivy.properties.StringProperty` and defaults to
494
toctrees = DictProperty({})
495
'''Toctree of all loaded or preloaded documents. This dictionary is filled
496
when a rst document is explicitly loaded or where :meth:`preload` has been
499
If the document has no filename, e.g. when the document is loaded from a
500
text file, the key will be ''.
502
:attr:`toctrees` is a :class:`~kivy.properties.DictProperty` and defaults
506
underline_color = StringProperty('204a9699')
507
'''underline color of the titles, expressed in html color notation
509
:attr:`underline_color` is a
510
:class:`~kivy.properties.StringProperty` and defaults to '204a9699'.
512
.. versionadded: 1.9.0
516
content = ObjectProperty(None)
517
scatter = ObjectProperty(None)
518
anchors_widgets = ListProperty([])
519
refs_assoc = DictProperty({})
521
def __init__(self, **kwargs):
522
self._trigger_load = Clock.create_trigger(self._load_from_text, -1)
523
self._parser = rst.Parser()
524
self._settings = frontend.OptionParser(
525
components=(rst.Parser, )).get_default_values()
526
super(RstDocument, self).__init__(**kwargs)
528
def on_source(self, instance, value):
531
if self.document_root is None:
532
# set the documentation root to the directory name of the
534
self.document_root = abspath(dirname(value))
535
self._load_from_source()
537
def on_text(self, instance, value):
541
'''Force document rendering.
543
self._load_from_text()
545
def resolve_path(self, filename):
546
'''Get the path for this filename. If the filename doesn't exist,
547
it returns the document_root + filename.
551
return join(self.document_root, filename)
553
def preload(self, filename, encoding='utf-8', errors='strict'):
554
'''Preload a rst file to get its toctree and its title.
556
The result will be stored in :attr:`toctrees` with the ``filename`` as
560
with open(filename, 'rb') as fd:
561
text = fd.read().decode(encoding, errors)
563
document = utils.new_document('Document', self._settings)
564
self._parser.parse(text, document)
565
# fill the current document node
566
visitor = _ToctreeVisitor(document)
567
document.walkabout(visitor)
568
self.toctrees[filename] = visitor.toctree
571
def _load_from_source(self):
572
filename = self.resolve_path(self.source)
573
self.text = self.preload(filename,
574
self.source_encoding,
577
def _load_from_text(self, *largs):
579
# clear the current widgets
580
self.content.clear_widgets()
581
self.anchors_widgets = []
585
document = utils.new_document('Document', self._settings)
587
if PY2 and type(text) is str:
588
text = text.decode('utf-8')
589
self._parser.parse(text, document)
591
# fill the current document node
592
visitor = _Visitor(self, document)
593
document.walkabout(visitor)
595
self.title = visitor.title or 'No title'
597
Logger.exception('Rst: error while loading text')
599
def on_ref_press(self, node, ref):
602
def goto(self, ref, *largs):
603
'''Scroll to the reference. If it's not found, nothing will be done.
609
This is something I always wanted.
613
from kivy.clock import Clock
614
from functools import partial
616
doc = RstDocument(...)
617
Clock.schedule_once(partial(doc.goto, 'myref'), 0.1)
621
It is preferable to delay the call of the goto if you just loaded
622
the document because the layout might not be finished or the
623
size of the RstDocument has not yet been determined. In
624
either case, the calculation of the scrolling would be
627
You can, however, do a direct call if the document is already
630
.. versionadded:: 1.3.0
632
# check if it's a file ?
633
if ref.endswith('.rst'):
634
# whether it's a valid or invalid file, let source deal with it
638
# get the association
639
ref = self.refs_assoc.get(ref, ref)
641
# search into all the nodes containing anchors
643
for node in self.anchors_widgets:
644
if ref in node.anchors:
645
ax, ay = node.anchors[ref]
648
# not found, stop here
652
# found, calculate the real coordinate
654
# get the anchor coordinate inside widget space
659
# what's the current coordinate for us?
660
sx, sy = self.scatter.x, self.scatter.top
661
#ax, ay = self.scatter.to_parent(ax, ay)
665
dx, dy = self.convert_distance_to_scroll(0, ay)
666
dy = max(0, min(1, dy))
667
Animation(scroll_y=dy, d=.25, t='in_out_expo').start(self)
669
def add_anchors(self, node):
670
self.anchors_widgets.append(node)
673
class RstTitle(Label):
675
section = NumericProperty(0)
677
document = ObjectProperty(None)
680
class RstParagraph(Label):
682
mx = NumericProperty(10)
684
my = NumericProperty(10)
686
document = ObjectProperty(None)
689
class RstTerm(AnchorLayout):
691
text = StringProperty('')
693
document = ObjectProperty(None)
696
class RstBlockQuote(GridLayout):
697
content = ObjectProperty(None)
700
class RstLiteralBlock(GridLayout):
701
content = ObjectProperty(None)
704
class RstList(GridLayout):
708
class RstListItem(GridLayout):
709
content = ObjectProperty(None)
712
class RstListBullet(Label):
714
document = ObjectProperty(None)
717
class RstSystemMessage(GridLayout):
721
class RstWarning(GridLayout):
722
content = ObjectProperty(None)
725
class RstNote(GridLayout):
726
content = ObjectProperty(None)
729
class RstImage(Image):
733
class RstAsyncImage(AsyncImage):
737
class RstDefinitionList(GridLayout):
739
document = ObjectProperty(None)
742
class RstDefinition(GridLayout):
744
document = ObjectProperty(None)
747
class RstFieldList(GridLayout):
751
class RstFieldName(Label):
753
document = ObjectProperty(None)
756
class RstFieldBody(GridLayout):
760
class RstGridLayout(GridLayout):
764
class RstTable(GridLayout):
768
class RstEntry(GridLayout):
772
class RstTransition(Widget):
776
class RstEmptySpace(Widget):
780
class RstDefinitionSpace(Widget):
782
document = ObjectProperty(None)
785
class _ToctreeVisitor(nodes.NodeVisitor):
787
def __init__(self, *largs):
788
self.toctree = self.current = []
791
nodes.NodeVisitor.__init__(self, *largs)
793
def push(self, tree):
794
self.queue.append(tree)
798
self.current = self.queue.pop()
800
def dispatch_visit(self, node):
802
if cls is nodes.section:
805
'names': node['names'],
808
if isinstance(self.current, dict):
809
self.current['children'].append(section)
811
self.current.append(section)
813
elif cls is nodes.title:
815
elif cls is nodes.Text:
818
def dispatch_departure(self, node):
820
if cls is nodes.section:
822
elif cls is nodes.title:
823
self.current['title'] = self.text
826
class _Visitor(nodes.NodeVisitor):
828
def __init__(self, root, *largs):
831
self.current_list = []
835
self.text_have_anchor = False
837
self.do_strip_text = False
838
nodes.NodeVisitor.__init__(self, *largs)
840
def push(self, widget):
841
self.current_list.append(self.current)
842
self.current = widget
845
self.current = self.current_list.pop()
847
def dispatch_visit(self, node):
849
if cls is nodes.document:
850
self.push(self.root.content)
852
elif cls is nodes.section:
855
elif cls is nodes.title:
856
label = RstTitle(section=self.section, document=self.root)
857
self.current.add_widget(label)
859
#assert(self.text == '')
861
elif cls is nodes.Text:
862
if self.do_strip_text:
863
node = node.replace('\n', ' ')
864
node = node.replace(' ', ' ')
865
node = node.replace('\t', ' ')
866
node = node.replace(' ', ' ')
867
if node.startswith(' '):
868
node = ' ' + node.lstrip(' ')
869
if node.endswith(' '):
870
node = node.rstrip(' ') + ' '
871
if self.text.endswith(' ') and node.startswith(' '):
875
elif cls is nodes.paragraph:
876
self.do_strip_text = True
877
label = RstParagraph(document=self.root)
878
if isinstance(self.current, RstEntry):
880
self.current.add_widget(label)
883
elif cls is nodes.literal_block:
884
box = RstLiteralBlock()
885
self.current.add_widget(box)
888
elif cls is nodes.emphasis:
891
elif cls is nodes.strong:
894
elif cls is nodes.literal:
895
self.text += '[font=fonts/RobotoMono-Regular.ttf]'
897
elif cls is nodes.block_quote:
898
box = RstBlockQuote()
899
self.current.add_widget(box)
900
self.push(box.content)
901
assert(self.text == '')
903
elif cls is nodes.enumerated_list:
905
self.current.add_widget(box)
909
elif cls is nodes.bullet_list:
911
self.current.add_widget(box)
915
elif cls is nodes.list_item:
917
if self.idx_list is not None:
919
bullet = '%d.' % self.idx_list
920
bullet = self.colorize(bullet, 'bullet')
922
self.current.add_widget(RstListBullet(
923
text=bullet, document=self.root))
924
self.current.add_widget(item)
927
elif cls is nodes.system_message:
928
label = RstSystemMessage()
929
if self.root.show_errors:
930
self.current.add_widget(label)
933
elif cls is nodes.warning:
935
self.current.add_widget(label)
936
self.push(label.content)
937
assert(self.text == '')
939
elif cls is nodes.note:
941
self.current.add_widget(label)
942
self.push(label.content)
943
assert(self.text == '')
945
elif cls is nodes.image:
947
if uri.startswith('/') and self.root.document_root:
948
uri = join(self.root.document_root, uri[1:])
949
if uri.startswith('http://') or uri.startswith('https://'):
950
image = RstAsyncImage(source=uri)
952
image = RstImage(source=uri)
954
align = node.get('align', 'center')
955
root = AnchorLayout(size_hint_y=None, anchor_x=align,
957
image.bind(height=root.setter('height'))
958
root.add_widget(image)
959
self.current.add_widget(root)
961
elif cls is nodes.definition_list:
962
lst = RstDefinitionList(document=self.root)
963
self.current.add_widget(lst)
966
elif cls is nodes.term:
967
assert(isinstance(self.current, RstDefinitionList))
968
term = RstTerm(document=self.root)
969
self.current.add_widget(term)
972
elif cls is nodes.definition:
973
assert(isinstance(self.current, RstDefinitionList))
974
definition = RstDefinition(document=self.root)
975
definition.add_widget(RstDefinitionSpace(document=self.root))
976
self.current.add_widget(definition)
977
self.push(definition)
979
elif cls is nodes.field_list:
980
fieldlist = RstFieldList()
981
self.current.add_widget(fieldlist)
984
elif cls is nodes.field_name:
985
name = RstFieldName(document=self.root)
986
self.current.add_widget(name)
989
elif cls is nodes.field_body:
990
body = RstFieldBody()
991
self.current.add_widget(body)
994
elif cls is nodes.table:
995
table = RstTable(cols=0)
996
self.current.add_widget(table)
999
elif cls is nodes.colspec:
1000
self.current.cols += 1
1002
elif cls is nodes.entry:
1004
self.current.add_widget(entry)
1007
elif cls is nodes.transition:
1008
self.current.add_widget(RstTransition())
1010
elif cls is nodes.reference:
1011
name = node.get('name', node.get('refuri'))
1012
self.text += '[ref=%s][color=%s]' % (
1013
name, self.root.colors.get(
1014
'link', self.root.colors.get('paragraph')))
1015
if 'refname' in node and 'name' in node:
1016
self.root.refs_assoc[node['name']] = node['refname']
1018
elif cls is nodes.target:
1021
name = node['ids'][0]
1022
elif 'names' in node:
1023
name = node['names'][0]
1024
self.text += '[anchor=%s]' % name
1025
self.text_have_anchor = True
1027
elif cls is role_doc:
1028
self.doc_index = len(self.text)
1030
elif cls is role_video:
1033
def dispatch_departure(self, node):
1034
cls = node.__class__
1035
if cls is nodes.document:
1038
elif cls is nodes.section:
1041
elif cls is nodes.title:
1042
assert(isinstance(self.current, RstTitle))
1044
self.title = self.text
1045
self.set_text(self.current, 'title')
1048
elif cls is nodes.Text:
1051
elif cls is nodes.paragraph:
1052
self.do_strip_text = False
1053
assert(isinstance(self.current, RstParagraph))
1054
self.set_text(self.current, 'paragraph')
1057
elif cls is nodes.literal_block:
1058
assert(isinstance(self.current, RstLiteralBlock))
1059
self.set_text(self.current.content, 'literal_block')
1062
elif cls is nodes.emphasis:
1065
elif cls is nodes.strong:
1068
elif cls is nodes.literal:
1069
self.text += '[/font]'
1071
elif cls is nodes.block_quote:
1074
elif cls is nodes.enumerated_list:
1075
self.idx_list = None
1078
elif cls is nodes.bullet_list:
1081
elif cls is nodes.list_item:
1084
elif cls is nodes.system_message:
1087
elif cls is nodes.warning:
1090
elif cls is nodes.note:
1093
elif cls is nodes.definition_list:
1096
elif cls is nodes.term:
1097
assert(isinstance(self.current, RstTerm))
1098
self.set_text(self.current, 'term')
1101
elif cls is nodes.definition:
1104
elif cls is nodes.field_list:
1107
elif cls is nodes.field_name:
1108
assert(isinstance(self.current, RstFieldName))
1109
self.set_text(self.current, 'field_name')
1112
elif cls is nodes.field_body:
1115
elif cls is nodes.table:
1118
elif cls is nodes.colspec:
1121
elif cls is nodes.entry:
1124
elif cls is nodes.reference:
1125
self.text += '[/color][/ref]'
1127
elif cls is role_doc:
1128
docname = self.text[self.doc_index:]
1129
rst_docname = docname
1130
if rst_docname.endswith('.rst'):
1131
docname = docname[:-4]
1133
rst_docname += '.rst'
1136
filename = self.root.resolve_path(rst_docname)
1137
self.root.preload(filename)
1139
# if exist, use the title of the first section found in the
1142
if filename in self.root.toctrees:
1143
toctree = self.root.toctrees[filename]
1145
title = toctree[0]['title']
1147
# replace the text with a good reference
1148
text = '[ref=%s]%s[/ref]' % (
1150
self.colorize(title, 'link'))
1151
self.text = self.text[:self.doc_index] + text
1153
elif cls is role_video:
1154
width = node['width'] if 'width' in node.attlist() else 400
1155
height = node['height'] if 'height' in node.attlist() else 300
1156
uri = node['source']
1157
if uri.startswith('/') and self.root.document_root:
1158
uri = join(self.root.document_root, uri[1:])
1159
video = RstVideoPlayer(
1161
size_hint=(None, None),
1162
size=(width, height))
1163
anchor = AnchorLayout(size_hint_y=None, height=height + 20)
1164
anchor.add_widget(video)
1165
self.current.add_widget(anchor)
1167
def set_text(self, node, parent):
1169
if parent == 'term' or parent == 'field_name':
1170
text = '[b]%s[/b]' % text
1172
node.text = self.colorize(text, parent)
1173
node.bind(on_ref_press=self.root.on_ref_press)
1174
if self.text_have_anchor:
1175
self.root.add_anchors(node)
1177
self.text_have_anchor = False
1179
def colorize(self, text, name):
1180
return '[color=%s]%s[/color]' % (
1181
self.root.colors.get(name, self.root.colors['paragraph']),
1184
if __name__ == '__main__':
1185
from kivy.base import runTouchApp
1187
runTouchApp(RstDocument(source=sys.argv[1]))