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