From f6d43fe15891431fff01b4893901f28d7bf0ceab Mon Sep 17 00:00:00 2001 From: Matthias Jung Date: Mon, 6 Apr 2026 22:17:14 +0200 Subject: [PATCH 1/2] feature: render invisible anchor for index --- renderer/fifty_ohm_html_renderer.py | 27 ++++++++++++++++++++-- test/test_index.py | 35 ++++++++++++++++++++++++----- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/renderer/fifty_ohm_html_renderer.py b/renderer/fifty_ohm_html_renderer.py index 2e316bd..8817d14 100644 --- a/renderer/fifty_ohm_html_renderer.py +++ b/renderer/fifty_ohm_html_renderer.py @@ -1,3 +1,5 @@ +import re + from jinja2 import Environment, FileSystemLoader from mistletoe import Document, HtmlRenderer @@ -90,6 +92,9 @@ class FiftyOhmHtmlRenderer(HtmlRenderer): # Single unified counter for all figure types (pictures, photos, tables) self.figure_counter = 0 + # Keep track of index ids already emitted in this document. + self.index_anchor_ids = set() + def render_dash(self, token): return " – " @@ -335,6 +340,24 @@ class FiftyOhmHtmlRenderer(HtmlRenderer): def render_formula(self, token): return f"\n$${token.formula}$$\n" + @staticmethod + def _normalize_index_part(value: str) -> str: + normalized = value.strip().lower() + normalized = re.sub(r"\s+", "_", normalized) + normalized = re.sub(r"[^\w]", "_", normalized) + normalized = re.sub(r"_+", "_", normalized).strip("_") + return normalized or "index" + def render_index(self, token): - # For HTML just ignore the index - return "" + first = self._normalize_index_part(token.first) + if token.second: + second = self._normalize_index_part(token.second) + span_id = f"index_{first}__{second}" + else: + span_id = f"index_{first}" + + if span_id in self.index_anchor_ids: + return "" + + self.index_anchor_ids.add(span_id) + return f'' diff --git a/test/test_index.py b/test/test_index.py index 2c2e74b..ec538fd 100644 --- a/test/test_index.py +++ b/test/test_index.py @@ -9,11 +9,36 @@ from test.util import paragraph @pytest.mark.html def test_index_html(): assertions = { - "Im Betrieb werden Yagi-Antennen [index:Antenne:Yagi-Antenne] oft gedreht": "Im Betrieb werden Yagi-Antennen oft gedreht", # noqa: E501 - "Im Betrieb werden Yagi-Antennen[index:Antenne:Yagi-Antenne] oft gedreht": "Im Betrieb werden Yagi-Antennen oft gedreht", # noqa: E501 - "Im Betrieb werden Yagi-Antennen [index:Antenne] oft gedreht": "Im Betrieb werden Yagi-Antennen oft gedreht", - "Im Betrieb werden Yagi-Antennen[index:Antenne] oft gedreht": "Im Betrieb werden Yagi-Antennen oft gedreht", - "Im Betrieb werden Yagi-Antennen[index:Test Antenne] oft gedreht": "Im Betrieb werden Yagi-Antennen oft gedreht", # noqa: E501 + "Im Betrieb werden Yagi-Antennen [index:Antenne:Yagi-Antenne] oft gedreht": ( + "Im Betrieb werden Yagi-Antennen" + + '' + + " oft gedreht" + ), + "Im Betrieb werden Yagi-Antennen[index:Antenne:Yagi-Antenne] oft gedreht": ( + "Im Betrieb werden Yagi-Antennen" + + '' + + " oft gedreht" + ), + "Im Betrieb werden Yagi-Antennen [index:Antenne] oft gedreht": ( + "Im Betrieb werden Yagi-Antennen" + + '' + + " oft gedreht" + ), + "Im Betrieb werden Yagi-Antennen[index:Antenne] oft gedreht": ( + "Im Betrieb werden Yagi-Antennen" + + '' + + " oft gedreht" + ), + "Im Betrieb werden Yagi-Antennen[index:Test Antenne] oft gedreht": ( + "Im Betrieb werden Yagi-Antennen" + + '' + + " oft gedreht" + ), + "Im Betrieb [index:Antenne] und nochmals [index:Antenne] oft gedreht": ( + "Im Betrieb" + + '' + + " und nochmals oft gedreht" + ), } for key, value in assertions.items(): From 5e051b7cbd5cde9c26c621df26537e06a2144804 Mon Sep 17 00:00:00 2001 From: Matthias Jung Date: Mon, 6 Apr 2026 22:20:47 +0200 Subject: [PATCH 2/2] feature: index build --- build.py | 1 + main.py | 1 + src/build.py | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/build.py b/build.py index bfb2ae0..a8f4c68 100644 --- a/build.py +++ b/build.py @@ -15,4 +15,5 @@ bd.build_edition("NEA") bd.build_assets() bd.build_solutions() bd.build_question_index() +bd.build_index() bd.build_zip() diff --git a/main.py b/main.py index 5b83092..51bf833 100644 --- a/main.py +++ b/main.py @@ -43,6 +43,7 @@ def main( # Copy assets to build folder bd.build_assets() bd.build_question_index() + bd.build_index() if build_zip: # Build zip file of output diff --git a/src/build.py b/src/build.py index b3edc00..7803885 100644 --- a/src/build.py +++ b/src/build.py @@ -13,6 +13,7 @@ from tqdm import tqdm from renderer.fifty_ohm_html_renderer import FiftyOhmHtmlRenderer from renderer.fifty_ohm_html_slide_renderer import FiftyOhmHtmlSlideRenderer +from renderer.index import Index from .config import Config @@ -28,6 +29,9 @@ class Build: self.question_index = {} self.question_token_pattern = re.compile(r"^\s*\[question:([\w\d]+)\]", re.MULTILINE) + self.index_entries = {} + self.index_token_pattern = Index.pattern + def __parse_katalog(self): with self.config.p_data_fragenkatalog.open() as file: fragenkatalog = json.load(file) @@ -402,6 +406,13 @@ class Build: section["title"], section["content"], ) + self.__collect_index_occurrences( + edition, + chapter["title"], + ident, + section["title"], + section["content"], + ) next_section = ( chapter["sections"][section_number] if section_number < len(chapter["sections"]) else None @@ -556,7 +567,43 @@ class Build: def __has_solution(self, question_number: str) -> bool: return (self.config.p_data_solutions / f"{question_number}.md").exists() + def __collect_index_occurrences( + self, + edition: str, + chapter_title: str, + section_ident: str, + section_title: str, + section_content: str, + ): + for match in self.index_token_pattern.finditer(section_content or ""): + first = match.group(1).strip() + second = match.group(2).strip() if match.group(2) else None + + normalized_first = FiftyOhmHtmlRenderer._normalize_index_part(first) + if second: + normalized_second = FiftyOhmHtmlRenderer._normalize_index_part(second) + anchor_id = f"index_{normalized_first}__{normalized_second}" + else: + anchor_id = f"index_{normalized_first}" + + entry_key = f"{section_ident}#{anchor_id}" + index_entry = self.index_entries.setdefault( + entry_key, + { + "term": first, + "subterm": second, + "anchor_id": anchor_id, + "section": section_ident, + "chapter_title": chapter_title, + "section_title": section_title, + "editions": [], + }, + ) + if edition not in index_entry["editions"]: + index_entry["editions"].append(edition) + def build_question_index(self): + tqdm.write("Creating question index") for question_data in self.question_index.values(): question_data["editions"] = sorted(question_data["editions"]) @@ -565,6 +612,26 @@ class Build: json.dump(self.question_index, file, ensure_ascii=False, indent=2, sort_keys=True) file.write("\n") + def build_index(self): + tqdm.write("Creating index") + for index_data in self.index_entries.values(): + index_data["editions"] = sorted(index_data["editions"]) + + entries = sorted( + self.index_entries.values(), + key=lambda item: ( + item["term"].casefold(), + (item["subterm"] or "").casefold(), + item["section"], + item["anchor_id"], + ), + ) + + path = self.config.p_build_assets / "index.json" + with (path).open("w", encoding="utf-8") as file: + json.dump(entries, file, ensure_ascii=False, indent=2) + file.write("\n") + def build_zip(self, zip_name: str | None = None) -> Path: """Create a zip archive of the complete build output directory.