diff --git a/CHANGES.rst b/CHANGES.rst
index 1f9c6a17dcd..fdc7ecfc9f2 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -28,6 +28,8 @@ Features added
.. rst-class:: compact
+* Add ``level`` option to :rst:dir:`rubric` directive.
+ Patch by Chris Sewell.
* Add optional ``description`` argument to
:meth:`~sphinx.application.Sphinx.add_config_value`.
Patch by Chris Sewell.
diff --git a/doc/usage/restructuredtext/directives.rst b/doc/usage/restructuredtext/directives.rst
index acf4acbcdc7..5e7b1cd0579 100644
--- a/doc/usage/restructuredtext/directives.rst
+++ b/doc/usage/restructuredtext/directives.rst
@@ -373,8 +373,18 @@ units as well as normal text.
.. rst:directive:: .. rubric:: title
- This directive creates a paragraph heading that is not used to create a
- table of contents node.
+ A rubric is like an informal heading that doesn't correspond to the document's structure,
+ i.e. it does not create a table of contents node.
+
+ .. rst:directive:option:: level: n
+ :type: number from 1 to 6
+
+ .. versionadded:: 7.4
+
+ Use this option to specify the heading level of the rubric.
+ In this case the rubric will be rendered as ``
`` to ```` for HTML output,
+ or as the corresponding non-numbered sectioning command for LaTeX
+ (see :confval:`latex_toplevel_sectioning`).
.. note::
diff --git a/sphinx/directives/patches.py b/sphinx/directives/patches.py
index 145f1f5d9ff..b9b3d932397 100644
--- a/sphinx/directives/patches.py
+++ b/sphinx/directives/patches.py
@@ -176,12 +176,38 @@ def add_target(self, ret: list[Node]) -> None:
ret.insert(0, target)
+class Rubric(SphinxDirective):
+ """A patch of the docutils' :rst:dir:`rubric` directive,
+ which adds a level option to specify the heading level of the rubric.
+ """
+
+ required_arguments = 1
+ optional_arguments = 0
+ final_argument_whitespace = True
+ option_spec = {
+ 'class': directives.class_option,
+ 'name': directives.unchanged,
+ 'level': lambda c: directives.choice(c, ('1', '2', '3', '4', '5', '6')),
+ }
+
+ def run(self) -> list[Node]:
+ set_classes(self.options)
+ rubric_text = self.arguments[0]
+ textnodes, messages = self.parse_inline(rubric_text, lineno=self.lineno)
+ rubric = nodes.rubric(rubric_text, '', *textnodes, **self.options)
+ self.add_name(rubric)
+ if 'level' in self.options:
+ rubric['level'] = int(self.options['level'])
+ return [rubric, *messages]
+
+
def setup(app: Sphinx) -> ExtensionMetadata:
directives.register_directive('figure', Figure)
directives.register_directive('meta', Meta)
directives.register_directive('csv-table', CSVTable)
directives.register_directive('code', Code)
directives.register_directive('math', MathDirective)
+ directives.register_directive('rubric', Rubric)
return {
'version': 'builtin',
diff --git a/sphinx/writers/html5.py b/sphinx/writers/html5.py
index 944e1ff35ae..66c3cf3d242 100644
--- a/sphinx/writers/html5.py
+++ b/sphinx/writers/html5.py
@@ -509,6 +509,30 @@ def depart_title(self, node: Element) -> None:
super().depart_title(node)
+ # overwritten
+ def visit_rubric(self, node: Element) -> None:
+ if "level" in node:
+ level = node["level"]
+ if level in (1, 2, 3, 4, 5, 6):
+ self.body.append(self.starttag(node, f'h{level}', '', CLASS='rubric'))
+ else:
+ logger.warning(
+ __('unsupported rubric heading level: %s'),
+ level,
+ type='html',
+ location=node
+ )
+ super().visit_rubric(node)
+ else:
+ super().visit_rubric(node)
+
+ # overwritten
+ def depart_rubric(self, node: Element) -> None:
+ if level := node.get("level"):
+ self.body.append(f'\n')
+ else:
+ super().depart_rubric(node)
+
# overwritten
def visit_literal_block(self, node: Element) -> None:
if node.rawsource != node.astext():
diff --git a/sphinx/writers/latex.py b/sphinx/writers/latex.py
index b02975a0ea0..fd750a579ee 100644
--- a/sphinx/writers/latex.py
+++ b/sphinx/writers/latex.py
@@ -967,7 +967,20 @@ def depart_seealso(self, node: Element) -> None:
def visit_rubric(self, node: Element) -> None:
if len(node) == 1 and node.astext() in ('Footnotes', _('Footnotes')):
raise nodes.SkipNode
- self.body.append(r'\subsubsection*{')
+ tag = 'subsubsection'
+ if "level" in node:
+ level = node["level"]
+ try:
+ tag = self.sectionnames[self.top_sectionlevel - 1 + level]
+ except Exception:
+ logger.warning(
+ __('unsupported rubric heading level: %s'),
+ level,
+ type='latex',
+ location=node
+ )
+
+ self.body.append(rf'\{tag}*{{')
self.context.append('}' + CR)
self.in_title = 1
diff --git a/tests/roots/test-markup-rubric/conf.py b/tests/roots/test-markup-rubric/conf.py
index e274bde806b..eccdbf78895 100644
--- a/tests/roots/test-markup-rubric/conf.py
+++ b/tests/roots/test-markup-rubric/conf.py
@@ -1,3 +1,4 @@
latex_documents = [
('index', 'test.tex', 'The basic Sphinx documentation for testing', 'Sphinx', 'report')
]
+latex_toplevel_sectioning = 'section'
diff --git a/tests/roots/test-markup-rubric/index.rst b/tests/roots/test-markup-rubric/index.rst
index c2ae68a86ea..ab78e826047 100644
--- a/tests/roots/test-markup-rubric/index.rst
+++ b/tests/roots/test-markup-rubric/index.rst
@@ -5,3 +5,35 @@ test-markup-rubric
.. rubric:: This is
a multiline rubric
+
+.. rubric:: A rubric with a class
+ :class: myclass
+
+.. rubric:: A rubric with a heading level 1
+ :level: 1
+ :class: myclass
+
+.. rubric:: A rubric with a heading level 2
+ :level: 2
+ :class: myclass
+
+.. rubric:: A rubric with a heading level 3
+ :level: 3
+ :class: myclass
+
+.. rubric:: A rubric with a heading level 4
+ :level: 4
+ :class: myclass
+
+.. rubric:: A rubric with a heading level 5
+ :level: 5
+ :class: myclass
+
+.. rubric:: A rubric with a heading level 6
+ :level: 6
+ :class: myclass
+
+.. rubric:: A rubric with a heading level 7
+ :level: 7
+ :class: myclass
+
diff --git a/tests/test_builders/test_build_html_5_output.py b/tests/test_builders/test_build_html_5_output.py
index 49ad633a46b..60079cbdf39 100644
--- a/tests/test_builders/test_build_html_5_output.py
+++ b/tests/test_builders/test_build_html_5_output.py
@@ -278,3 +278,12 @@ def checker(nodes):
def test_html5_output(app, cached_etree_parse, fname, path, check):
app.build()
check_xpath(cached_etree_parse(app.outdir / fname), fname, path, check)
+
+
+@pytest.mark.sphinx('html', testroot='markup-rubric')
+def test_html5_rubric(app):
+ app.build()
+ assert '"7" unknown' in app.warning.getvalue()
+ content = (app.outdir / 'index.html').read_text(encoding='utf8')
+ assert '
This is a rubric
' in content
+ assert 'A rubric with a heading level 2
' in content
diff --git a/tests/test_builders/test_build_latex.py b/tests/test_builders/test_build_latex.py
index a34d69e1f48..0ef73f47fa8 100644
--- a/tests/test_builders/test_build_latex.py
+++ b/tests/test_builders/test_build_latex.py
@@ -1759,3 +1759,11 @@ def test_one_parameter_per_line(app, status, warning):
assert ('\\pysiglinewithargsret{\\sphinxbfcode{\\sphinxupquote{hello}}}' in result)
assert ('\\pysigwithonelineperarg{\\sphinxbfcode{\\sphinxupquote{foo}}}' in result)
+
+
+@pytest.mark.sphinx('latex', testroot='markup-rubric')
+def test_latex_rubric(app):
+ app.build()
+ content = (app.outdir / 'test.tex').read_text(encoding='utf8')
+ assert r'\subsubsection*{This is a rubric}' in content
+ assert r'\subsection*{A rubric with a heading level 2}' in content
diff --git a/tests/test_builders/test_build_texinfo.py b/tests/test_builders/test_build_texinfo.py
index 539ec9a96e7..6abbc969d26 100644
--- a/tests/test_builders/test_build_texinfo.py
+++ b/tests/test_builders/test_build_texinfo.py
@@ -43,6 +43,7 @@ def test_texinfo_rubric(app, status, warning):
output = (app.outdir / 'projectnamenotset.texi').read_text(encoding='utf8')
assert '@heading This is a rubric' in output
assert '@heading This is a multiline rubric' in output
+ assert '@heading A rubric with a heading level' in output
@pytest.mark.sphinx('texinfo', testroot='markup-citation')