From 072aaf2e49dc7d07bf0729aa1f31b3facbda07c0 Mon Sep 17 00:00:00 2001 From: Lars Weiler Date: Tue, 6 Jan 2026 12:01:25 +0100 Subject: [PATCH] feature: Add hierarchical figure numbers for picture, photo, and table tags --- renderer/fifty_ohm_html_renderer.py | 83 +++++++- renderer/fifty_ohm_html_slide_renderer.py | 4 + src/build.py | 76 ++++++-- test/test_hierarchical_numbering.py | 222 ++++++++++++++++++++++ test/test_references.py | 9 +- 5 files changed, 374 insertions(+), 20 deletions(-) create mode 100644 test/test_hierarchical_numbering.py diff --git a/renderer/fifty_ohm_html_renderer.py b/renderer/fifty_ohm_html_renderer.py index 8cb9e66..9d3a746 100644 --- a/renderer/fifty_ohm_html_renderer.py +++ b/renderer/fifty_ohm_html_renderer.py @@ -28,7 +28,16 @@ class FiftyOhmHtmlRenderer(HtmlRenderer): ref_id = 0 def __init__( - self, *extras, question_renderer=None, picture_handler=None, photo_handler=None, include_handler=None, **kwargs + self, + *extras, + question_renderer=None, + picture_handler=None, + photo_handler=None, + include_handler=None, + edition=None, + chapter=None, + section=None, + **kwargs, ): super().__init__( Dash, @@ -61,6 +70,17 @@ class FiftyOhmHtmlRenderer(HtmlRenderer): self.photo_handler = photo_handler self.include_handler = include_handler + # Figure numbering context + self.edition = edition + self.chapter = chapter + self.section = section + + # Figure map: stores ref -> hierarchical number mapping + self.figure_map = {} + + # Single unified counter for all figure types (pictures, photos, tables) + self.figure_counter = 0 + def render_dash(self, token): return " – " @@ -175,10 +195,56 @@ class FiftyOhmHtmlRenderer(HtmlRenderer): lookup = {"": "", " ": " "} return f"{lookup[token.first]}{token.second}{lookup[token.third]}" + def collect_figures(self, document): + """First pass: collect all figures and assign hierarchical numbers.""" + self.figure_counter = 0 + self.figure_map = {} + + self._collect_figures_recursive(document) + + def _collect_figures_recursive(self, token): + """Recursively traverse the document tree to collect figures.""" + if hasattr(token, "children") and token.children is not None: + for child in token.children: + # Check if this is a Picture token + if isinstance(child, Picture): + self.figure_counter += 1 + hierarchical_num = self._format_figure_number(self.figure_counter) + self.figure_map[child.ref] = hierarchical_num + child.number = hierarchical_num + # Check if this is a Photo token + elif isinstance(child, Photo): + self.figure_counter += 1 + hierarchical_num = self._format_figure_number(self.figure_counter) + self.figure_map[child.ref] = hierarchical_num + child.number = hierarchical_num + # Check if this is a Table token + elif isinstance(child, Table): + if child.name: # Only number tables with names + self.figure_counter += 1 + hierarchical_num = self._format_figure_number(self.figure_counter) + self.figure_map[child.name] = hierarchical_num + child.number = hierarchical_num + else: + child.number = "" + + # Recurse into children + self._collect_figures_recursive(child) + + def _format_figure_number(self, counter): + """Format the hierarchical figure number based on context.""" + if self.edition and self.chapter and self.section: + return f"{self.edition}-{self.chapter}.{self.section}.{counter}" + else: + # Fallback for tests or when context is not provided + return str(counter) + def render_references(self, token): + # Look up the figure number from the map + figure_num = self.figure_map.get(token.first, "?") return ( f'{self.ref_id}" + f" onclick=\"highlightRef('{token.first}');\">{figure_num}" ) def render_question(self, token): @@ -222,10 +288,19 @@ class FiftyOhmHtmlRenderer(HtmlRenderer): return self.render_photo_helper(token.id, token.ref, token.text, token.number) def render_table(self, token: Table): - table = f'\n{self.render_inner(token)}' + # Add id and name attributes if table has a name + table_attrs = "" + if token.name: + table_attrs = f' id="ref_{token.name}" name="{token.name}"' + + table = f'
\n{self.render_inner(token)}' if token.caption != "": - table += f"\n" + # Include hierarchical number in caption if available + caption_text = token.caption + if hasattr(token, "number") and token.number: + caption_text = f"Tabelle {token.number}: {token.caption}" + table += f"\n" table += "
{token.caption}{caption_text}
" diff --git a/renderer/fifty_ohm_html_slide_renderer.py b/renderer/fifty_ohm_html_slide_renderer.py index 8ded848..f867962 100644 --- a/renderer/fifty_ohm_html_slide_renderer.py +++ b/renderer/fifty_ohm_html_slide_renderer.py @@ -10,6 +10,10 @@ class FiftyOhmHtmlSlideRenderer(FiftyOhmHtmlRenderer): **kwargs, ) + # Add "S" suffix to edition for slides + if self.edition: + self.edition = f"{self.edition}S" + def render_slide_break(self, token): inner = self.render_inner(token) diff --git a/src/build.py b/src/build.py index 26c8964..c5f64b5 100644 --- a/src/build.py +++ b/src/build.py @@ -161,19 +161,39 @@ class Build: # cached def __build_section( - self, edition, edition_name, section, section_id, chapter, next_section=None, next_chapter=None + self, + edition, + edition_name, + section, + section_id, + chapter, + next_section=None, + next_chapter=None, + chapter_number=None, ): section_template = self.env.get_template("html/section.html") next_section_template = self.env.get_template("html/next_section.html") next_chapter_template = self.env.get_template("html/next_chapter.html") with (self.config.p_build / f"{edition}_{section['ident']}.html").open("w") as file: + # Use provided chapter_number or fall back to chapter dict + chapter_num = str(chapter_number) if chapter_number is not None else chapter.get("number", "0") + section_num = str(section_id) + with FiftyOhmHtmlRenderer( question_renderer=self.__build_question, picture_handler=self.__picture_handler, photo_handler=self.__photo_handler, include_handler=self.__include_handler, + edition=edition, + chapter=chapter_num, + section=section_num, ) as renderer: - section["content"] = renderer.render(Document(section["content"])) + # First pass: collect all figures and assign hierarchical numbers + doc = Document(section["content"]) + renderer.collect_figures(doc) + + # Second pass: render with hierarchical numbers + section["content"] = renderer.render(doc) result = section_template.render( edition=edition, @@ -197,30 +217,53 @@ class Build: result = self.__build_page(result, course_wrapper=True) file.write(result) - def __build_chapter_slidedeck(self, edition, chapter, sections, next_chapter): + def __build_chapter_slidedeck(self, edition, chapter, sections, next_chapter, chapter_number=None): with (self.config.p_build / f"{edition}_slide_{chapter['ident']}.html").open("w") as file: slide_template = self.env.get_template("slide/slide.html") help_template = self.env.get_template("slide/help.html") next_template = self.env.get_template("slide/next.html") + + # Parse chapter number + chapter_num = chapter.get("number", "0") + with FiftyOhmHtmlSlideRenderer( question_renderer=self.__build_question_slide, picture_handler=self.__picture_handler, photo_handler=self.__photo_handler, include_handler=self.__include_handler, + edition=edition, + chapter=chapter_num, + section="0", # Will be updated per section ) as renderer: result = "
\n" result += f'
\n

{chapter["title"]}

\n
\n' result += help_template.render() result += "
\n" + + section_counter = 0 for section in sections: if section["slide"] is None: continue + section_counter += 1 + # Update section number for this section + renderer.section = str(section_counter) + # Reset figure counter for each section + renderer.figure_counter = 0 + renderer.figure_map = {} + if not section["slide"].startswith("---"): section["slide"] = "---\n" + section["slide"] + + # First pass: collect figures + doc = Document(section["slide"]) + renderer.collect_figures(doc) + + # Second pass: render with hierarchical numbers tmp = f'
\n

{section["title"]}

\n
\n' - tmp += renderer.render(Document(section["slide"])) + tmp += renderer.render(doc) result += f"
{tmp}
\n" + result += next_template.render( edition=edition, next_chapter=next_chapter, @@ -271,15 +314,26 @@ class Build: edition_name = book["title"] self.__build_book_index(book) self.__build_slide_index(book) - for number, chapter in enumerate(tqdm(chapters, desc=f"Build Edition: {edition}"), 1): - next_chapter = chapters[number] if number < len(chapters) else None - self.__build_chapter(edition, edition_name, number, chapter, next_chapter) - self.__build_chapter_slidedeck(edition, chapter, chapter["sections"], next_chapter) + for chapter_number, chapter in enumerate(tqdm(chapters, desc=f"Build Edition: {edition}"), 1): + next_chapter = chapters[chapter_number] if chapter_number < len(chapters) else None + self.__build_chapter(edition, edition_name, chapter_number, chapter, next_chapter) + self.__build_chapter_slidedeck(edition, chapter, chapter["sections"], next_chapter, chapter_number) - for i, section in enumerate(chapter["sections"], 1): + for section_number, section in enumerate(chapter["sections"], 1): tqdm.write(f"Rendering section {section['title']}") - next_section = chapter["sections"][i] if i < len(chapter["sections"]) else None - self.__build_section(edition, edition_name, section, i, chapter, next_section, next_chapter) + next_section = ( + chapter["sections"][section_number] if section_number < len(chapter["sections"]) else None + ) + self.__build_section( + edition, + edition_name, + section, + section_number, + chapter, + next_section, + next_chapter, + chapter_number, + ) def build_assets(self): self.config.p_build.mkdir(exist_ok=True) diff --git a/test/test_hierarchical_numbering.py b/test/test_hierarchical_numbering.py new file mode 100644 index 0000000..2974182 --- /dev/null +++ b/test/test_hierarchical_numbering.py @@ -0,0 +1,222 @@ +import mistletoe +import pytest + +from renderer.fifty_ohm_html_renderer import FiftyOhmHtmlRenderer +from renderer.fifty_ohm_html_slide_renderer import FiftyOhmHtmlSlideRenderer + + +@pytest.mark.html +def test_hierarchical_picture_numbering(): + """Test that pictures get hierarchical numbers in format edition-chapter.section.counter""" + markdown_text = """ +[picture:123:pic1:First picture] + +Some text here. + +[picture:456:pic2:Second picture] + +Reference to first: [ref:pic1] +Reference to second: [ref:pic2] +""" + + renderer = FiftyOhmHtmlRenderer(edition="E", chapter="2", section="3") + doc = mistletoe.Document(markdown_text) + + # First pass: collect figures + renderer.collect_figures(doc) + + # Verify figure map + assert renderer.figure_map["pic1"] == "E-2.3.1" + assert renderer.figure_map["pic2"] == "E-2.3.2" + + # Second pass: render + result = renderer.render(doc) + + # Verify hierarchical numbers in output + assert "Abbildung E-2.3.1:" in result + assert "Abbildung E-2.3.2:" in result + + # Verify references are resolved + assert ">E-2.3.1" in result + assert ">E-2.3.2" in result + + +@pytest.mark.html +def test_hierarchical_photo_numbering(): + """Test that photos get hierarchical numbers""" + markdown_text = """ +[photo:789:photo1:First photo] + +[photo:101:photo2:Second photo] + +Reference: [ref:photo1] +""" + + renderer = FiftyOhmHtmlRenderer(edition="N", chapter="1", section="2") + doc = mistletoe.Document(markdown_text) + + renderer.collect_figures(doc) + + assert renderer.figure_map["photo1"] == "N-1.2.1" + assert renderer.figure_map["photo2"] == "N-1.2.2" + + result = renderer.render(doc) + + assert "Abbildung N-1.2.1:" in result + assert "Abbildung N-1.2.2:" in result + assert ">N-1.2.1" in result + + +@pytest.mark.html +def test_hierarchical_table_numbering(): + """Test that tables with names get hierarchical numbers""" + markdown_text = """ +| Header 1 | Header 2 | +| Data 1 | Data 2 | +[table:table1:First table] + +| Col A | Col B | +| Val 1 | Val 2 | +[table:table2:Second table] + +Reference: [ref:table1] +""" + + renderer = FiftyOhmHtmlRenderer(edition="A", chapter="3", section="1") + doc = mistletoe.Document(markdown_text) + + renderer.collect_figures(doc) + + assert renderer.figure_map["table1"] == "A-3.1.1" + assert renderer.figure_map["table2"] == "A-3.1.2" + + result = renderer.render(doc) + + assert "Tabelle A-3.1.1:" in result + assert "Tabelle A-3.1.2:" in result + assert ">A-3.1.1" in result + + +@pytest.mark.html +def test_mixed_figure_types_share_counter(): + """Test that pictures, photos, and tables share a single unified counter""" + markdown_text = """ +[picture:1:pic1:Picture one] + +[photo:2:photo1:Photo one] + +| A | B | +| 1 | 2 | +[table:tab1:Table one] + +[picture:3:pic2:Picture two] + +References: [ref:pic1], [ref:photo1], [ref:tab1], [ref:pic2] +""" + + renderer = FiftyOhmHtmlRenderer(edition="E", chapter="5", section="2") + doc = mistletoe.Document(markdown_text) + + renderer.collect_figures(doc) + + # All types share the same counter + assert renderer.figure_map["pic1"] == "E-5.2.1" + assert renderer.figure_map["photo1"] == "E-5.2.2" + assert renderer.figure_map["tab1"] == "E-5.2.3" + assert renderer.figure_map["pic2"] == "E-5.2.4" + + result = renderer.render(doc) + + # Verify all references are resolved with correct numbers + assert "Abbildung E-5.2.1:" in result # pic1 + assert "Abbildung E-5.2.2:" in result # photo1 + assert "Tabelle E-5.2.3:" in result # tab1 + assert "Abbildung E-5.2.4:" in result # pic2 + + +@pytest.mark.slide +def test_slide_renderer_adds_s_suffix(): + """Test that slide renderer adds 'S' suffix to edition""" + markdown_text = """ +[picture:1:pic1:Slide picture] + +Reference: [ref:pic1] +""" + + renderer = FiftyOhmHtmlSlideRenderer(edition="E", chapter="2", section="1") + doc = mistletoe.Document(markdown_text) + + # Verify edition has S suffix + assert renderer.edition == "ES" + + renderer.collect_figures(doc) + + # Verify hierarchical number includes S + assert renderer.figure_map["pic1"] == "ES-2.1.1" + + result = renderer.render(doc) + + assert "Abbildung ES-2.1.1:" in result + assert ">ES-2.1.1" in result + + +@pytest.mark.html +def test_fallback_numbering_without_context(): + """Test that numbering falls back to simple counter when context is missing""" + markdown_text = """ +[picture:1:pic1:Picture] + +Reference: [ref:pic1] +""" + + # No edition/chapter/section provided + renderer = FiftyOhmHtmlRenderer() + doc = mistletoe.Document(markdown_text) + + renderer.collect_figures(doc) + + # Should fall back to simple counter + assert renderer.figure_map["pic1"] == "1" + + result = renderer.render(doc) + + assert "Abbildung 1:" in result + assert ">1" in result + + +@pytest.mark.html +def test_unresolved_reference(): + """Test that references to non-existent figures show '?'""" + markdown_text = """ +Reference to non-existent: [ref:nonexistent] +""" + + renderer = FiftyOhmHtmlRenderer(edition="E", chapter="1", section="1") + doc = mistletoe.Document(markdown_text) + + renderer.collect_figures(doc) + + result = renderer.render(doc) + + # Should show ? for unresolved reference + assert ">?" in result + + +@pytest.mark.html +def test_table_without_name_not_numbered(): + """Test that tables without names are not numbered""" + markdown_text = """ +| Header 1 | Header 2 | +| Data 1 | Data 2 | + +[picture:1:pic1:Picture one] +""" + + renderer = FiftyOhmHtmlRenderer(edition="E", chapter="1", section="1") + doc = mistletoe.Document(markdown_text) + + renderer.collect_figures(doc) + + # Only the picture should be numbered + assert renderer.figure_map["pic1"] == "E-1.1.1" + assert renderer.figure_counter == 1 diff --git a/test/test_references.py b/test/test_references.py index ab332b4..5011685 100644 --- a/test/test_references.py +++ b/test/test_references.py @@ -7,12 +7,11 @@ from test.util import paragraph @pytest.mark.html def test_references_html(): - # Right now this tests only the default values. - # The correct behavior can be tested when the information is added to the renderer class + # References without figures in the map should show "?" assertions = { - "[ref:e_oszilloskop_bildschirmfoto_sinus]": '0', # noqa: E501 - "[ref:n_rst_r]": '0', - "[ref:Uuuuuh]": '0', # Test the default parameters # noqa: E501 + "[ref:e_oszilloskop_bildschirmfoto_sinus]": '?', # noqa: E501 + "[ref:n_rst_r]": '?', + "[ref:Uuuuuh]": '?', } for key, value in assertions.items():