feat: render nested card lists

This commit is contained in:
user
2026-07-17 12:21:49 +02:00
parent 71949fae8d
commit dbe8479e98
+100 -12
View File
@@ -2542,14 +2542,104 @@ def render_tabular_html(body: str, equation_numbers: dict[str, int], figure_numb
return f'<div class="table-wrap"><table>{"".join(html_rows)}</table></div>'
LIST_BEGIN_RE = re.compile(r"\\begin\{(?P<kind>itemize|enumerate)\}(?:\[[^\]]*\])?")
LIST_TOKEN_RE = re.compile(
r"\\begin\{(?P<begin>itemize|enumerate)\}(?:\[[^\]]*\])?"
r"|\\end\{(?P<end>itemize|enumerate)\}"
r"|(?P<item>\\item\b)"
)
LIST_PLACEHOLDER_RE = re.compile(r"\x00ESC_LIST_(?P<index>\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 "<ul>" + "".join(f"<li>{latex_inline_to_html(item, equation_numbers, figure_numbers)}</li>" for item in items) + "</ul>"
items = split_top_level_list_items(body)
return "<ul>" + "".join(
f"<li>{render_list_item_html(item, equation_numbers, figure_numbers)}</li>" for item in items
) + "</ul>"
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 "<ol>" + "".join(f"<li>{latex_inline_to_html(item, equation_numbers, figure_numbers)}</li>" for item in items) + "</ol>"
items = split_top_level_list_items(body)
return "<ol>" + "".join(
f"<li>{render_list_item_html(item, equation_numbers, figure_numbers)}</li>" for item in items
) + "</ol>"
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"<pre><code>{html_escape(body.strip())}</code></pre>")