From dbe8479e98a647ce1fec32a2622168187d26c7dc Mon Sep 17 00:00:00 2001 From: user Date: Fri, 17 Jul 2026 12:21:49 +0200 Subject: [PATCH] feat: render nested card lists --- tools/render_card.py | 112 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 100 insertions(+), 12 deletions(-) diff --git a/tools/render_card.py b/tools/render_card.py index 0d2412d..dad7edf 100644 --- a/tools/render_card.py +++ b/tools/render_card.py @@ -2542,14 +2542,104 @@ def render_tabular_html(body: str, equation_numbers: dict[str, int], figure_numb return f'
{"".join(html_rows)}
' +LIST_BEGIN_RE = re.compile(r"\\begin\{(?Pitemize|enumerate)\}(?:\[[^\]]*\])?") +LIST_TOKEN_RE = re.compile( + r"\\begin\{(?Pitemize|enumerate)\}(?:\[[^\]]*\])?" + r"|\\end\{(?Pitemize|enumerate)\}" + r"|(?P\\item\b)" +) +LIST_PLACEHOLDER_RE = re.compile(r"\x00ESC_LIST_(?P\d+)\x00") + + +def list_environment_end(text: str, begin: re.Match[str]) -> tuple[int, int] | None: + stack: list[str] = [] + for token in LIST_TOKEN_RE.finditer(text, begin.start()): + if token.group("begin"): + stack.append(token.group("begin")) + elif token.group("end"): + if not stack or stack[-1] != token.group("end"): + return None + stack.pop() + if not stack: + return token.start(), token.end() + return None + + +def split_top_level_list_items(body: str) -> list[str]: + item_starts: list[tuple[int, int]] = [] + depth = 0 + for token in LIST_TOKEN_RE.finditer(body): + if token.group("begin"): + depth += 1 + elif token.group("end"): + depth = max(0, depth - 1) + elif token.group("item") and depth == 0: + item_starts.append((token.start(), token.end())) + items: list[str] = [] + for index, (_start, content_start) in enumerate(item_starts): + content_end = item_starts[index + 1][0] if index + 1 < len(item_starts) else len(body) + item = body[content_start:content_end].strip().rstrip(",") + if item: + items.append(item) + return items + + +def replace_nested_lists_html( + text: str, + equation_numbers: dict[str, int], + figure_numbers: dict[str, int], +) -> tuple[str, list[str]]: + parts: list[str] = [] + rendered: list[str] = [] + cursor = 0 + while begin := LIST_BEGIN_RE.search(text, cursor): + bounds = list_environment_end(text, begin) + if bounds is None: + break + body_end, environment_end = bounds + parts.append(text[cursor:begin.start()]) + body = text[begin.end():body_end] + renderer = render_itemize_html if begin.group("kind") == "itemize" else render_enumerate_html + placeholder = f"\x00ESC_LIST_{len(rendered)}\x00" + rendered.append(renderer(body, equation_numbers, figure_numbers)) + parts.append(placeholder) + cursor = environment_end + parts.append(text[cursor:]) + return "".join(parts), rendered + + +def render_list_item_html( + item: str, + equation_numbers: dict[str, int], + figure_numbers: dict[str, int], +) -> str: + content, nested = replace_nested_lists_html(item, equation_numbers, figure_numbers) + parts: list[str] = [] + cursor = 0 + for placeholder in LIST_PLACEHOLDER_RE.finditer(content): + inline = content[cursor:placeholder.start()].strip() + if inline: + parts.append(latex_inline_to_html(inline, equation_numbers, figure_numbers)) + parts.append(nested[int(placeholder.group("index"))]) + cursor = placeholder.end() + inline = content[cursor:].strip() + if inline: + parts.append(latex_inline_to_html(inline, equation_numbers, figure_numbers)) + return "".join(parts) + + def render_itemize_html(body: str, equation_numbers: dict[str, int], figure_numbers: dict[str, int]) -> str: - items = [item.strip().rstrip(",") for item in re.split(r"\\item", body) if item.strip()] - return "
    " + "".join(f"
  • {latex_inline_to_html(item, equation_numbers, figure_numbers)}
  • " for item in items) + "
" + items = split_top_level_list_items(body) + return "
    " + "".join( + f"
  • {render_list_item_html(item, equation_numbers, figure_numbers)}
  • " for item in items + ) + "
" def render_enumerate_html(body: str, equation_numbers: dict[str, int], figure_numbers: dict[str, int]) -> str: - items = [item.strip().rstrip(",") for item in re.split(r"\\item", body) if item.strip()] - return "
    " + "".join(f"
  1. {latex_inline_to_html(item, equation_numbers, figure_numbers)}
  2. " for item in items) + "
" + items = split_top_level_list_items(body) + return "
    " + "".join( + f"
  1. {render_list_item_html(item, equation_numbers, figure_numbers)}
  2. " for item in items + ) + "
" def render_paragraphs_html(text: str, equation_numbers: dict[str, int], figure_numbers: dict[str, int]) -> list[str]: @@ -2558,12 +2648,12 @@ def render_paragraphs_html(text: str, equation_numbers: dict[str, int], figure_n def render_latex_fragment_html(text: str, equation_numbers: dict[str, int], figure_numbers: dict[str, int]) -> str: + text, nested_lists = replace_nested_lists_html(text, equation_numbers, figure_numbers) patterns = { "equation": r"\\begin\{equation\}(.*?)\\end\{equation\}", "figure": r"\\begin\{figure\}\[H\](.*?)\\end\{figure\}", "tabularx": r"\\begin\{tabularx\}.*?\n(.*?)\\end\{tabularx\}", - "itemize": r"\\begin\{itemize\}(?:\[[^\]]+\])?(.*?)\\end\{itemize\}", - "enumerate": r"\\begin\{enumerate\}(?:\[[^\]]+\])?(.*?)\\end\{enumerate\}", + "list": r"\x00ESC_LIST_\d+\x00", "verbatim": r"\\begin\{verbatim\}(.*?)\\end\{verbatim\}", } combined = re.compile("|".join(f"(?P<{name}>{pattern})" for name, pattern in patterns.items()), re.S) @@ -2582,12 +2672,10 @@ def render_latex_fragment_html(text: str, equation_numbers: dict[str, int], figu elif kind == "tabularx": body = re.match(patterns["tabularx"], body, re.S).group(1) # type: ignore[union-attr] parts.append(render_tabular_html(body, equation_numbers, figure_numbers)) - elif kind == "itemize": - body = re.match(patterns["itemize"], body, re.S).group(1) # type: ignore[union-attr] - parts.append(render_itemize_html(body, equation_numbers, figure_numbers)) - elif kind == "enumerate": - body = re.match(patterns["enumerate"], body, re.S).group(1) # type: ignore[union-attr] - parts.append(render_enumerate_html(body, equation_numbers, figure_numbers)) + elif kind == "list": + placeholder = LIST_PLACEHOLDER_RE.fullmatch(body) + if placeholder: + parts.append(nested_lists[int(placeholder.group("index"))]) elif kind == "verbatim": body = re.match(patterns["verbatim"], body, re.S).group(1) # type: ignore[union-attr] parts.append(f"
{html_escape(body.strip())}
")