1# $Id$
2# Authors: David Goodger <goodger@python.org>; Ueli Schlaepfer; Günter Milde
3# Maintainer: docutils-develop@lists.sourceforge.net
4# Copyright: This module has been placed in the public domain.
5
6"""
7Transforms needed by most or all documents:
8
9- `Decorations`: Generate a document's header & footer.
10- `ExposeInternals`: Expose internal attributes.
11- `Messages`: Placement of system messages generated after parsing.
12- `FilterMessages`: Remove system messages below verbosity threshold.
13- `TestMessages`: Like `Messages`, used on test runs.
14- `StripComments`: Remove comment elements from the document tree.
15- `StripClassesAndElements`: Remove elements with classes
16 in `self.document.settings.strip_elements_with_classes`
17 and class values in `self.document.settings.strip_classes`.
18- `SmartQuotes`: Replace ASCII quotation marks with typographic form.
19"""
20
21__docformat__ = 'reStructuredText'
22
23import re
24import time
25from docutils import nodes, utils
26from docutils.transforms import Transform
27from docutils.utils import smartquotes
28
29
30class Decorations(Transform):
31
32 """
33 Populate a document's decoration element (header, footer).
34 """
35
36 default_priority = 820
37
38 def apply(self):
39 header_nodes = self.generate_header()
40 if header_nodes:
41 decoration = self.document.get_decoration()
42 header = decoration.get_header()
43 header.extend(header_nodes)
44 footer_nodes = self.generate_footer()
45 if footer_nodes:
46 decoration = self.document.get_decoration()
47 footer = decoration.get_footer()
48 footer.extend(footer_nodes)
49
50 def generate_header(self):
51 return None
52
53 def generate_footer(self):
54 # @@@ Text is hard-coded for now.
55 # Should be made dynamic (language-dependent).
56 # @@@ Use timestamp from the `SOURCE_DATE_EPOCH`_ environment variable
57 # for the datestamp?
58 # See https://sourceforge.net/p/docutils/patches/132/
59 # and https://reproducible-builds.org/specs/source-date-epoch/
60 settings = self.document.settings
61 if (settings.generator or settings.datestamp
62 or settings.source_link or settings.source_url):
63 text = []
64 if (settings.source_link and settings._source
65 or settings.source_url):
66 if settings.source_url:
67 source = settings.source_url
68 else:
69 source = utils.relative_path(settings._destination,
70 settings._source)
71 text.extend([
72 nodes.reference('', 'View document source',
73 refuri=source),
74 nodes.Text('.\n')])
75 if settings.datestamp:
76 datestamp = time.strftime(settings.datestamp, time.gmtime())
77 text.append(nodes.Text('Generated on: ' + datestamp + '.\n'))
78 if settings.generator:
79 text.extend([
80 nodes.Text('Generated by '),
81 nodes.reference('', 'Docutils',
82 refuri='https://docutils.sourceforge.io/'),
83 nodes.Text(' from '),
84 nodes.reference('', 'reStructuredText',
85 refuri='https://docutils.sourceforge.io/'
86 'rst.html'),
87 nodes.Text(' source.\n')])
88 return [nodes.paragraph('', '', *text)]
89 else:
90 return None
91
92
93class ExposeInternals(Transform):
94
95 """
96 Expose internal attributes if ``expose_internals`` setting is set.
97 """
98
99 default_priority = 840
100
101 def not_Text(self, node):
102 return not isinstance(node, nodes.Text)
103
104 def apply(self):
105 if self.document.settings.expose_internals:
106 for node in self.document.findall(self.not_Text):
107 for att in self.document.settings.expose_internals:
108 value = getattr(node, att, None)
109 if value is not None:
110 node['internal:' + att] = value
111
112
113class Messages(Transform):
114
115 """
116 Place any system messages generated after parsing into a dedicated section
117 of the document.
118 """
119
120 default_priority = 860
121
122 def apply(self):
123 messages = self.document.transform_messages
124 loose_messages = [msg for msg in messages if not msg.parent]
125 if loose_messages:
126 section = nodes.section(classes=['system-messages'])
127 # @@@ get this from the language module?
128 section += nodes.title('', 'Docutils System Messages')
129 section += loose_messages
130 self.document.transform_messages[:] = []
131 self.document += section
132
133
134class FilterMessages(Transform):
135
136 """
137 Remove system messages below verbosity threshold.
138
139 Also convert <problematic> nodes referencing removed messages
140 to <Text> nodes and remove "System Messages" section if empty.
141 """
142
143 default_priority = 870
144
145 def apply(self):
146 for node in tuple(self.document.findall(nodes.system_message)):
147 if node['level'] < self.document.reporter.report_level:
148 node.parent.remove(node)
149 try: # also remove id-entry
150 del self.document.ids[node['ids'][0]]
151 except (IndexError):
152 pass
153 for node in tuple(self.document.findall(nodes.problematic)):
154 if 'refid' in node and node['refid'] not in self.document.ids:
155 node.parent.replace(node, nodes.Text(node.astext()))
156 for node in self.document.findall(nodes.section):
157 if "system-messages" in node['classes'] and len(node) == 1:
158 node.parent.remove(node)
159
160
161class TestMessages(Transform):
162
163 """
164 Append all post-parse system messages to the end of the document.
165
166 Used for testing purposes.
167 """
168
169 # marker for pytest to ignore this class during test discovery
170 __test__ = False
171
172 default_priority = 880
173
174 def apply(self):
175 for msg in self.document.transform_messages:
176 if not msg.parent:
177 self.document += msg
178
179
180class StripComments(Transform):
181
182 """
183 Remove comment elements from the document tree (only if the
184 ``strip_comments`` setting is enabled).
185 """
186
187 default_priority = 740
188
189 def apply(self):
190 if self.document.settings.strip_comments:
191 for node in tuple(self.document.findall(nodes.comment)):
192 node.parent.remove(node)
193
194
195class StripClassesAndElements(Transform):
196
197 """
198 Remove from the document tree all elements with classes in
199 `self.document.settings.strip_elements_with_classes` and all "classes"
200 attribute values in `self.document.settings.strip_classes`.
201 """
202
203 default_priority = 420
204
205 def apply(self):
206 if self.document.settings.strip_elements_with_classes:
207 self.strip_elements = {*self.document.settings
208 .strip_elements_with_classes}
209 # Iterate over a tuple as removing the current node
210 # corrupts the iterator returned by `iter`:
211 for node in tuple(self.document.findall(self.check_classes)):
212 node.parent.remove(node)
213
214 if not self.document.settings.strip_classes:
215 return
216 strip_classes = self.document.settings.strip_classes
217 for node in self.document.findall(nodes.Element):
218 for class_value in strip_classes:
219 try:
220 node['classes'].remove(class_value)
221 except ValueError:
222 pass
223
224 def check_classes(self, node):
225 if not isinstance(node, nodes.Element):
226 return False
227 for class_value in node['classes'][:]:
228 if class_value in self.strip_elements:
229 return True
230 return False
231
232
233class SmartQuotes(Transform):
234
235 """
236 Replace ASCII quotation marks with typographic form.
237
238 Also replace multiple dashes with em-dash/en-dash characters.
239 """
240
241 default_priority = 855
242
243 nodes_to_skip = (nodes.FixedTextElement, nodes.Special)
244 """Do not apply "smartquotes" to instances of these block-level nodes."""
245
246 literal_nodes = (nodes.FixedTextElement, nodes.Special,
247 nodes.image, nodes.literal, nodes.math,
248 nodes.raw, nodes.problematic)
249 """Do not apply smartquotes to instances of these inline nodes."""
250
251 smartquotes_action = 'qDe'
252 """Setting to select smartquote transformations.
253
254 The default 'qDe' educates normal quote characters: (", '),
255 em- and en-dashes (---, --) and ellipses (...).
256 """
257
258 def __init__(self, document, startnode):
259 Transform.__init__(self, document, startnode=startnode)
260 self.unsupported_languages = set()
261
262 def get_tokens(self, txtnodes):
263 # A generator that yields ``(texttype, nodetext)`` tuples for a list
264 # of "Text" nodes (interface to ``smartquotes.educate_tokens()``).
265 for node in txtnodes:
266 if (isinstance(node.parent, self.literal_nodes)
267 or isinstance(node.parent.parent, self.literal_nodes)):
268 yield 'literal', str(node)
269 else:
270 # SmartQuotes uses backslash escapes instead of null-escapes
271 # Insert backslashes before escaped "active" characters.
272 txt = re.sub('(?<=\x00)([-\\\'".`])', r'\\\1', str(node))
273 yield 'plain', txt
274
275 def apply(self):
276 smart_quotes = self.document.settings.setdefault('smart_quotes',
277 False)
278 if not smart_quotes:
279 return
280 try:
281 alternative = smart_quotes.startswith('alt')
282 except AttributeError:
283 alternative = False
284
285 document_language = self.document.settings.language_code
286 lc_smartquotes = self.document.settings.smartquotes_locales
287 if lc_smartquotes:
288 smartquotes.smartchars.quotes.update(dict(lc_smartquotes))
289
290 # "Educate" quotes in normal text. Handle each block of text
291 # (TextElement node) as a unit to keep context around inline nodes:
292 for node in self.document.findall(nodes.TextElement):
293 # skip preformatted text blocks and special elements:
294 if isinstance(node, self.nodes_to_skip):
295 continue
296 # nested TextElements are not "block-level" elements:
297 if isinstance(node.parent, nodes.TextElement):
298 continue
299
300 # list of text nodes in the "text block":
301 txtnodes = [txtnode for txtnode in node.findall(nodes.Text)
302 if not isinstance(txtnode.parent,
303 nodes.option_string)]
304
305 # language: use typographical quotes for language "lang"
306 lang = node.get_language_code(document_language)
307 # use alternative form if `smart-quotes` setting starts with "alt":
308 if alternative:
309 if '-x-altquot' in lang:
310 lang = lang.replace('-x-altquot', '')
311 else:
312 lang += '-x-altquot'
313 # drop unsupported subtags:
314 for tag in utils.normalize_language_tag(lang):
315 if tag in smartquotes.smartchars.quotes:
316 lang = tag
317 break
318 else: # language not supported -- keep ASCII quotes
319 if lang not in self.unsupported_languages:
320 self.document.reporter.warning(
321 'No smart quotes defined for language "%s".' % lang,
322 base_node=node)
323 self.unsupported_languages.add(lang)
324 lang = ''
325
326 # Iterator educating quotes in plain text:
327 # (see "utils/smartquotes.py" for the attribute setting)
328 teacher = smartquotes.educate_tokens(
329 self.get_tokens(txtnodes),
330 attr=self.smartquotes_action, language=lang)
331
332 for txtnode, newtext in zip(txtnodes, teacher):
333 txtnode.parent.replace(txtnode, nodes.Text(newtext))
334
335 self.unsupported_languages.clear()