HydraDeck / hydradeck /render.py
OpeneR
HydraDeck open-source clean snapshot
778278c
Raw
History Blame Contribute Delete
16.2 kB
from __future__ import annotations
import re
from dataclasses import dataclass
from hydradeck.core.types import ExtractedFact, Source
_LATEX_SPECIALS: dict[str, str] = {
"\\": r"\textbackslash{}",
"{": r"\{",
"}": r"\}",
"#": r"\#",
"$": r"\$",
"%": r"\%",
"&": r"\&",
"_": r"\_",
"^": r"\textasciicircum{}",
"~": r"\textasciitilde{}",
}
def latex_escape(s: str) -> str:
return "".join(_LATEX_SPECIALS.get(ch, ch) for ch in s)
def _bib_key(i: int) -> str:
return f"src{i}"
def _bib_escape(s: str) -> str:
return s.replace("\\", "\\\\").replace("{", "\\{").replace("}", "\\}")
def render_bibtex(sources: list[Source]) -> str:
lines: list[str] = []
for i, s in enumerate(sources, start=1):
key = _bib_key(i)
lines.append(f"@misc{{{key},")
lines.append(f" title = {{{_bib_escape(s.title)}}},")
lines.append(f" howpublished = {{\\url{{{_bib_escape(s.url)}}}}},")
lines.append(" note = {Accessed: 2026-03-04},")
lines.append("}")
lines.append("")
return "\n".join(lines).strip() + "\n"
def _replace_numeric_citations(text: str, max_n: int) -> str:
def repl(m: re.Match[str]) -> str:
num = int(m.group(1))
if 1 <= num <= max_n:
return f"\\cite{{{_bib_key(num)}}}"
return m.group(0)
return re.sub(r"\[(\d{1,3})\]", repl, text)
def _markdown_to_latex_paragraphs(md: str, max_n: int) -> str:
text = md.strip()
text = re.sub(r"```[\s\S]*?```", "", text)
text = re.sub(r"^\s*[-*+]\s+", "", text, flags=re.MULTILINE)
text = re.sub(r"^\s*#+\s*", "", text, flags=re.MULTILINE)
text = re.sub(r"`([^`]+)`", r"\1", text)
text = re.sub(r"\*\*(.*?)\*\*", r"\1", text)
text = re.sub(r"\*(.*?)\*", r"\1", text)
text = re.sub(r"\[(.*?)\]\((.*?)\)", r"\1", text)
text = _replace_numeric_citations(text, max_n=max_n)
text = latex_escape(text)
text = re.sub(r"\\textbackslash\{\}cite\\\{(src\d+)\\\}", r"\\cite{\1}", text)
text = text.replace("\n\n", "\n\\par\n")
return text
def render_paper(
topic: str,
outline: list[str],
body: str,
facts: list[ExtractedFact],
sources: list[Source],
) -> str:
topic_e = latex_escape(topic)
url_to_key = {s.url: _bib_key(i) for i, s in enumerate(sources, start=1)}
outline_items = "\n".join([f"\\item {latex_escape(x)}" for x in outline[:10]])
fact_sentences: list[str] = []
for f in facts[:18]:
key = url_to_key.get(f.url)
cite = f"\\cite{{{key}}}" if key else ""
sentence = latex_escape(f.claim.strip())
if sentence and sentence[-1] not in ".!?":
sentence += "."
fact_sentences.append(sentence + (cite if cite else ""))
facts_paragraph = (
" ".join(fact_sentences)
if fact_sentences
else "No extracted facts available."
)
body_latex = _markdown_to_latex_paragraphs(body, max_n=len(sources))
return (
"\\documentclass[11pt]{article}\n"
"\\usepackage{geometry}\n"
"\\usepackage{hyperref}\n"
"\\usepackage{url}\n"
"\\usepackage{booktabs}\n"
"\\usepackage{longtable}\n"
"\\geometry{margin=1in}\n"
"\\hypersetup{colorlinks=true,linkcolor=black,citecolor=blue,urlcolor=blue}\n"
f"\\title{{{topic_e}}}\n"
"\\author{hydradeck}\n"
"\\date{\\today}\n"
"\\begin{document}\n"
"\\maketitle\n"
"\\begin{abstract}\n"
"This report presents a structured analysis with explicit traceability to sources.\n"
"\\end{abstract}\n\n"
"\\section*{1. Introduction and Background}\n"
+ facts_paragraph
+ "\n\n"
"\\section*{2. Logical Outline}\n"
"\\begin{itemize}\n"
+ outline_items
+ "\n\\end{itemize}\n\n"
"\\section*{3. Evidence and Key Findings}\n"
+ body_latex
+ "\n\n"
"\\section*{4. Limitations and Discussion}\n"
"The analysis is bounded by available public evidence and may evolve as sources update.\n\n"
"\\section*{5. Conclusion}\n"
"Conclusions are presented in a source-traceable form and should be interpreted with the\n"
"reported assumptions and constraints.\n\n"
"\\bibliographystyle{plain}\n"
"\\bibliography{refs}\n"
"\\end{document}\n"
)
def render_report_structured(
topic: str,
section_blocks: list[dict[str, str]],
language: str = "en",
) -> str:
lang = language.lower()
topic_e = latex_escape(topic)
if lang == "zh":
preamble = (
"\\documentclass[11pt]{ctexart}\n"
"\\usepackage[a4paper,margin=1in]{geometry}\n"
"\\usepackage{hyperref}\n"
"\\usepackage{url}\n"
"\\usepackage{booktabs}\n"
"\\usepackage{longtable}\n"
"\\hypersetup{colorlinks=true,linkcolor=black,citecolor=blue,urlcolor=blue}\n"
f"\\title{{{topic_e}}}\n"
"\\author{hydradeck}\n"
"\\date{\\today}\n"
"\\begin{document}\n"
"\\maketitle\n"
)
else:
preamble = (
"\\documentclass[11pt]{article}\n"
"\\usepackage{geometry}\n"
"\\usepackage{hyperref}\n"
"\\usepackage{url}\n"
"\\usepackage{booktabs}\n"
"\\usepackage{longtable}\n"
"\\geometry{margin=1in}\n"
"\\hypersetup{colorlinks=true,linkcolor=black,citecolor=blue,urlcolor=blue}\n"
f"\\title{{{topic_e}}}\n"
"\\author{hydradeck}\n"
"\\date{\\today}\n"
"\\begin{document}\n"
"\\maketitle\n"
)
content_parts: list[str] = []
for block in section_blocks[:10]:
title = latex_escape(str(block.get("name", "Section")).strip() or "Section")
latex_body = str(block.get("latex", "")).strip()
latex_body = re.sub(r"\\section\*?\{[^}]*\}", "", latex_body)
latex_body = re.sub(r"\\subsection\*?\{[^}]*\}", "", latex_body)
latex_body = re.sub(r"\\cite\{[^}]*\}", "", latex_body)
latex_body = re.sub(r"\[(\d{1,3})\]", "", latex_body)
if not latex_body:
continue
content_parts.append(f"\\section*{{{title}}}\n{latex_body}\n")
return preamble + "\n".join(content_parts) + "\n\\end{document}\n"
@dataclass
class SlideFrame:
title: str
bullets: list[str]
note: str = ""
def render_beamer(topic: str, outline: list[str], bullets: list[str]) -> str:
section_blocks = [{"name": t, "latex": b} for t, b in zip(outline, bullets)]
if not section_blocks:
section_blocks = [{"name": "Summary", "latex": "Key findings and implications."}]
frames = build_slide_frames_from_sections(section_blocks, language="en")
frames = enforce_slide_density(frames, language="en")
return render_beamer_frames(topic, frames, language="en")
def render_beamer_from_report(topic: str, report_tex: str) -> str:
frames = build_slide_frames_from_report(report_tex, language="en")
frames = enforce_slide_density(frames, language="en")
return render_beamer_frames(topic, frames, language="en")
def _split_paragraph_to_bullets(text: str, language: str) -> list[str]:
lang = language.lower()
if lang == "zh":
parts = [x.strip() for x in re.split(r"[。!?]\s*", text) if x.strip()]
out: list[str] = []
for p in parts:
if len(p) < 6:
continue
out.append(_trim_chars(_clean_text_for_slide(p), 28))
return out
parts = [x.strip() for x in re.split(r"[.!?]\s+", text) if x.strip()]
out2: list[str] = []
for p in parts:
clean = _clean_text_for_slide(p)
if len(clean) < 14:
continue
out2.append(_trim_words(clean, 14))
return out2
def build_slide_frames_from_sections(
section_blocks: list[dict[str, str]],
language: str = "en",
) -> list[SlideFrame]:
lang = language.lower()
frames: list[SlideFrame] = []
for block in section_blocks[:8]:
title = str(block.get("name", "Section")).strip() or ("章节" if lang == "zh" else "Section")
body = str(block.get("latex", ""))
body = re.sub(r"\\section\*?\{[^}]*\}", "", body)
body = re.sub(r"\\subsection\*?\{[^}]*\}", "", body)
body = re.sub(r"\\cite\{[^}]*\}", "", body)
body = re.sub(r"\[(\d{1,3})\]", "", body)
bullets = _split_paragraph_to_bullets(body, lang)
if not bullets:
continue
chunk = 4 if lang == "zh" else 4
for i in range(0, len(bullets), chunk):
part = bullets[i : i + chunk]
if not part:
continue
if i == 0:
frame_title = title
else:
frame_title = f"{title}(续)" if lang == "zh" else f"{title} (cont.)"
frames.append(SlideFrame(title=frame_title, bullets=part))
if not frames:
raise RuntimeError("insufficient readable section content for slides")
return frames
def enforce_slide_density(
frames: list[SlideFrame],
language: str = "en",
max_bullets_per_frame: int = 4,
max_chars_per_bullet_zh: int = 28,
max_words_per_bullet_en: int = 14,
) -> list[SlideFrame]:
lang = language.lower()
out: list[SlideFrame] = []
for fr in frames:
normalized: list[str] = []
for b in fr.bullets:
clean = _clean_text_for_slide(b)
if not clean:
continue
if lang == "zh":
clean = _trim_chars(clean, max_chars_per_bullet_zh)
else:
clean = _trim_words(clean, max_words_per_bullet_en)
if clean:
normalized.append(clean)
if not normalized:
continue
for i in range(0, len(normalized), max_bullets_per_frame):
chunk = normalized[i : i + max_bullets_per_frame]
if not chunk:
continue
if i == 0:
title = fr.title
else:
title = f"{fr.title}(续)" if lang == "zh" else f"{fr.title} (cont.)"
out.append(SlideFrame(title=title, bullets=chunk, note=fr.note))
if not out:
raise RuntimeError("slide density guard removed all frames")
return out
def _trim_words(text: str, max_words: int) -> str:
words = text.split()
if len(words) <= max_words:
return text
return " ".join(words[:max_words]).rstrip(" ,.;") + "..."
def _trim_chars(text: str, max_chars: int) -> str:
t = text.strip()
if len(t) <= max_chars:
return t
return t[: max_chars - 1].rstrip(",。,. ") + "…"
def _clean_text_for_slide(text: str) -> str:
t = text.strip()
t = re.sub(r"\s+", " ", t)
t = re.sub(r"`([^`]+)`", r"\1", t)
t = re.sub(r"\*\*(.*?)\*\*", r"\1", t)
t = re.sub(r"\*(.*?)\*", r"\1", t)
return t
def build_slide_frames_from_report(report_tex: str, language: str = "en") -> list[SlideFrame]:
lang = language.lower()
sections = re.split(r"\\section\*\{([^}]+)\}", report_tex)
parsed: list[tuple[str, str]] = []
if len(sections) >= 3:
for i in range(1, len(sections), 2):
title = sections[i].strip()
body = sections[i + 1] if i + 1 < len(sections) else ""
parsed.append((title, body))
if not parsed:
raise RuntimeError("cannot derive slide frames from report structure")
frames: list[SlideFrame] = []
for title, body in parsed[:8]:
plain = re.sub(r"\\[a-zA-Z]+\*?(\[[^\]]*\])?(\{[^}]*\})?", " ", body)
chunks = [x.strip() for x in re.split(r"[。.!?]\s+", plain) if x.strip()]
bullets: list[str] = []
for c in chunks:
clean = _clean_text_for_slide(c)
if not clean:
continue
if lang == "zh":
if len(clean) < 8:
continue
bullets.append(_trim_chars(clean, 30))
else:
if len(clean) < 12:
continue
bullets.append(_trim_words(clean, 16))
if len(bullets) >= 5:
break
if not bullets:
raise RuntimeError(f"insufficient bullet content for slide '{title}'")
frames.append(SlideFrame(title=title, bullets=bullets))
return frames
def render_beamer_frames(topic: str, frames: list[SlideFrame], language: str = "en") -> str:
lang = language.lower()
topic_e = latex_escape(topic)
agenda_label = "目录" if lang == "zh" else "Agenda"
summary_title = "总结" if lang == "zh" else "Summary"
agenda_items = "\n".join([f"\\item {latex_escape(f.title)}" for f in frames[:8]])
frame_blocks: list[str] = []
for fr in frames[:10]:
b = "\n".join([f"\\item {latex_escape(x)}" for x in fr.bullets[:5]])
frame_blocks.append(
"\\begin{frame}[t]{"
+ latex_escape(fr.title)
+ "}\n"
+ "\\begin{itemize}\n"
+ b
+ "\n\\end{itemize}\n"
+ (f"\\vspace{{0.6em}}\\footnotesize {latex_escape(fr.note)}\n" if fr.note else "")
+ "\\end{frame}\n"
)
summary_bullets: list[str] = []
for fr in frames[:5]:
if fr.bullets:
summary_bullets.append(fr.bullets[0])
if not summary_bullets:
summary_bullets = ["关键要点见前页。" if lang == "zh" else "Key points are summarized in previous slides."]
summary_items = "\n".join([f"\\item {latex_escape(x)}" for x in summary_bullets])
if lang == "zh":
return (
"\\documentclass[aspectratio=169]{ctexbeamer}\n"
"\\usetheme{Madrid}\n"
"\\usefonttheme{professionalfonts}\n"
"\\setbeamertemplate{navigation symbols}{}\n"
"\\usepackage{hyperref}\n"
"\\usepackage{booktabs}\n"
"\\definecolor{AccentBlue}{HTML}{1F4E79}\n"
"\\setbeamercolor{title}{fg=AccentBlue}\n"
"\\setbeamercolor{frametitle}{fg=AccentBlue}\n"
"\\setbeamerfont{title}{series=\\bfseries,size=\\Large}\n"
"\\setbeamerfont{frametitle}{series=\\bfseries,size=\\large}\n"
f"\\title{{{topic_e}}}\n"
"\\author{hydradeck}\n"
"\\date{\\today}\n"
"\\begin{document}\n"
"\\frame{\\titlepage}\n"
"\\begin{frame}{"
+ latex_escape(agenda_label)
+ "}\n"
"\\begin{itemize}\n"
+ agenda_items
+ "\n\\end{itemize}\n"
"\\end{frame}\n"
+ "".join(frame_blocks)
+ "\\begin{frame}{"
+ latex_escape(summary_title)
+ "}\n"
+ "\\begin{itemize}\n"
+ summary_items
+ "\n\\end{itemize}\n"
+ "\\end{frame}\n"
+ "\\end{document}\n"
)
return (
"\\documentclass[aspectratio=169]{beamer}\n"
"\\usetheme{metropolis}\n"
"\\usefonttheme{professionalfonts}\n"
"\\setbeamertemplate{navigation symbols}{}\n"
"\\usepackage{hyperref}\n"
"\\usepackage{booktabs}\n"
"\\definecolor{AccentBlue}{HTML}{1F4E79}\n"
"\\setbeamercolor{title}{fg=AccentBlue}\n"
"\\setbeamercolor{frametitle}{fg=AccentBlue}\n"
"\\setbeamerfont{title}{series=\\bfseries,size=\\Large}\n"
"\\setbeamerfont{frametitle}{series=\\bfseries,size=\\large}\n"
f"\\title{{{topic_e}}}\n"
"\\author{hydradeck}\n"
"\\date{\\today}\n"
"\\begin{document}\n"
"\\frame{\\titlepage}\n"
"\\begin{frame}{"
+ latex_escape(agenda_label)
+ "}\n"
"\\begin{itemize}\n"
+ agenda_items
+ "\n\\end{itemize}\n"
"\\end{frame}\n"
+ "".join(frame_blocks)
+ "\\begin{frame}{"
+ latex_escape(summary_title)
+ "}\n"
+ "\\begin{itemize}\n"
+ summary_items
+ "\n\\end{itemize}\n"
+ "\\end{frame}\n"
+ "\\end{document}\n"
)