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')