diff --git a/CHANGELOG.md b/CHANGELOG.md index bb4c31f6e..808fb0bba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1634,6 +1634,7 @@ raw template {%- endblock in_prompt -%} """ + exporter_attr = AttrExporter() output_attr, _ = exporter_attr.from_notebook_node(nb) assert "raw template" in output_attr diff --git a/docs/source/api/preprocessors.rst b/docs/source/api/preprocessors.rst index 6276007a6..b5f74a9bc 100644 --- a/docs/source/api/preprocessors.rst +++ b/docs/source/api/preprocessors.rst @@ -36,6 +36,8 @@ Converting text .. autoclass:: HighlightMagicsPreprocessor +.. autoclass:: NumberedHeadingsPreprocessor + Metadata and header control ~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/nbconvert/exporters/exporter.py b/nbconvert/exporters/exporter.py index ca6739491..b446cfc71 100644 --- a/nbconvert/exporters/exporter.py +++ b/nbconvert/exporters/exporter.py @@ -97,6 +97,7 @@ class Exporter(LoggingConfigurable): "nbconvert.preprocessors.ExtractOutputPreprocessor", "nbconvert.preprocessors.ExtractAttachmentsPreprocessor", "nbconvert.preprocessors.ClearMetadataPreprocessor", + "nbconvert.preprocessors.NumberedHeadingsPreprocessor", ], help="""List of preprocessors available by default, by name, namespace, instance, or type.""", diff --git a/nbconvert/preprocessors/__init__.py b/nbconvert/preprocessors/__init__.py index d752edf90..de527d0ec 100644 --- a/nbconvert/preprocessors/__init__.py +++ b/nbconvert/preprocessors/__init__.py @@ -13,6 +13,7 @@ from .extractoutput import ExtractOutputPreprocessor from .highlightmagics import HighlightMagicsPreprocessor from .latex import LatexPreprocessor +from .numbered_headings import NumberedHeadingsPreprocessor from .regexremove import RegexRemovePreprocessor from .svg2pdf import SVG2PDFPreprocessor from .tagremove import TagRemovePreprocessor @@ -30,6 +31,7 @@ "ExtractOutputPreprocessor", "HighlightMagicsPreprocessor", "LatexPreprocessor", + "NumberedHeadingsPreprocessor", "RegexRemovePreprocessor", "SVG2PDFPreprocessor", "TagRemovePreprocessor", diff --git a/nbconvert/preprocessors/numbered_headings.py b/nbconvert/preprocessors/numbered_headings.py new file mode 100644 index 000000000..5a31bb34a --- /dev/null +++ b/nbconvert/preprocessors/numbered_headings.py @@ -0,0 +1,65 @@ +""" +Preprocessor that transforms markdown cells: Insert numbering in from of heading +""" + +from nbconvert.preprocessors.base import Preprocessor + +try: # for Mistune >= 3.0 + import mistune + from mistune.core import BlockState + from mistune.renderers.markdown import MarkdownRenderer + + MISTUNE_V3 = True +except ImportError: # for Mistune >= 2.0 + MISTUNE_V3 = False + +WRONG_MISTUNE_VERSION_ERROR = "Error: NumberedHeadingsPreprocessor requires mistune >= 3" + + +class NumberedHeadingsPreprocessor(Preprocessor): + """Pre-processor that will rewrite markdown headings to include numberings.""" + + def __init__(self, *args, **kwargs): + """Init""" + super().__init__(*args, **kwargs) + if not MISTUNE_V3: + raise Exception(WRONG_MISTUNE_VERSION_ERROR) + self.md_parser = mistune.create_markdown(renderer=None) + self.md_renderer = MarkdownRenderer() + self.current_numbering = [0] + + def format_numbering(self): + """Return a string representation of the current numbering""" + return ".".join(str(n) for n in self.current_numbering) + + def _inc_current_numbering(self, level): + """Increase internal counter keeping track of numberings""" + if level > len(self.current_numbering): + self.current_numbering = self.current_numbering + [0] * ( + level - len(self.current_numbering) + ) + elif level < len(self.current_numbering): + self.current_numbering = self.current_numbering[:level] + self.current_numbering[level - 1] += 1 + + def preprocess_cell(self, cell, resources, index): + """Rewrites all the headings in the cell if it is markdown""" + if cell["cell_type"] != "markdown": + return cell, resources + try: + md_ast = self.md_parser(cell["source"]) + assert not isinstance(md_ast, str) # type guard ; str is not returned by ast parser + for element in md_ast: + if element["type"] == "heading": + level = element["attrs"]["level"] + self._inc_current_numbering(level) + if len(element["children"]) > 0: + child = element["children"][0] + if child["type"] == "text": + child["raw"] = self.format_numbering() + " " + child["raw"] + new_source = self.md_renderer(md_ast, BlockState()) + cell["source"] = new_source + return cell, resources + except Exception: + self.log.warning("Failed processing cell headings", exc_info=True) + return cell, resources diff --git a/tests/preprocessors/test_numbered_headings.py b/tests/preprocessors/test_numbered_headings.py new file mode 100644 index 000000000..abe93e4ac --- /dev/null +++ b/tests/preprocessors/test_numbered_headings.py @@ -0,0 +1,106 @@ +""" +Module with tests for the Numbered Headings preprocessor. +""" + +from nbformat import v4 as nbformat + +from nbconvert.preprocessors.numbered_headings import NumberedHeadingsPreprocessor + +from .base import PreprocessorTestsBase + +MARKDOWN_1 = """ +# Heading 1 + +## Sub-heading + +some content +""" + +MARKDOWN_1_POST = """ +# 1 Heading 1 + +## 1.1 Sub-heading + +some content +""" + + +MARKDOWN_2 = """ + +## Second sub-heading + +# Another main heading + +## Sub-heading + + +some more content + +### Third heading +""" + +MARKDOWN_2_POST = """ + +## 1.2 Second sub-heading + +# 2 Another main heading + +## 2.1 Sub-heading + +some more content + +### 2.1.1 Third heading +""" + +MARKDOWN_3 = """ +# HEADING + +``` +# this is not a heading + +## this neither +``` +""" + +MARKDOWN_3_POST = """ +# 3 HEADING + +``` +# this is not a heading + +## this neither +``` +""" + + +class TestNumberedHeadings(PreprocessorTestsBase): + def build_notebook(self): + cells = [ + nbformat.new_code_cell(source="$ e $", execution_count=1), + nbformat.new_markdown_cell(source=MARKDOWN_1), + nbformat.new_code_cell(source="$ e $", execution_count=1), + nbformat.new_markdown_cell(source=MARKDOWN_2), + nbformat.new_markdown_cell(source=MARKDOWN_3), + ] + + return nbformat.new_notebook(cells=cells) + + def build_preprocessor(self): + """Make an instance of a preprocessor""" + preprocessor = NumberedHeadingsPreprocessor() + preprocessor.enabled = True + return preprocessor + + def test_constructor(self): + """Can a NumberedHeadingsPreprocessor be constructed?""" + self.build_preprocessor() + + def test_output(self): + """Test the output of the NumberedHeadingsPreprocessor""" + nb = self.build_notebook() + res = self.build_resources() + preprocessor = self.build_preprocessor() + nb, res = preprocessor(nb, res) + assert nb.cells[1].source.strip() == MARKDOWN_1_POST.strip() + assert nb.cells[3].source.strip() == MARKDOWN_2_POST.strip() + assert nb.cells[4].source.strip() == MARKDOWN_3_POST.strip()