182 lines
6.3 KiB
Rust
182 lines
6.3 KiB
Rust
//! Render the LLM's Markdown document to safe HTML.
|
|
//!
|
|
//! The LLM produces Markdown with a non-standard `==text==` highlight
|
|
//! convention for stretches the doctor should double-check (suspected
|
|
//! transcription errors, incomplete info, numbers without context). We
|
|
//! convert those to `<mark>` tags — the only raw HTML we inject — after
|
|
//! HTML-escaping the entire LLM output. That way even if the LLM writes
|
|
//! `<script>`, it becomes visible text, not executable markup.
|
|
//!
|
|
//! Pipeline:
|
|
//! raw markdown → html-escape → ==X== → <mark>X</mark> → pulldown-cmark → html
|
|
|
|
use pulldown_cmark::{Options, Parser, html};
|
|
|
|
/// Render analysis Markdown to HTML. Safe to inject into an Askama template
|
|
/// with `{{ var|safe }}` because every `<`/`>` from the LLM is pre-escaped
|
|
/// and only our whitelisted `<mark>` tags remain as live HTML.
|
|
pub fn md_to_html(md: &str) -> String {
|
|
let prepared = escape_and_mark(md);
|
|
let parser = Parser::new_ext(&prepared, Options::empty());
|
|
let mut out = String::with_capacity(prepared.len());
|
|
html::push_html(&mut out, parser);
|
|
out
|
|
}
|
|
|
|
/// HTML-escape every `<`, `>`, `&`, `"`, `'` in the input, then convert
|
|
/// `==text==` highlight markers to `<mark>text</mark>`. Because the escape
|
|
/// step runs first, the substitution output is the only live HTML in the
|
|
/// resulting string — LLM-emitted angle brackets can never execute.
|
|
///
|
|
/// Pure, unit-testable. Order matters: escape before substitution, because
|
|
/// otherwise `<mark>` itself would be escaped.
|
|
pub(crate) fn escape_and_mark(md: &str) -> String {
|
|
let escaped = html_escape(md);
|
|
substitute_marks(&escaped)
|
|
}
|
|
|
|
fn html_escape(s: &str) -> String {
|
|
let mut out = String::with_capacity(s.len());
|
|
for c in s.chars() {
|
|
match c {
|
|
'&' => out.push_str("&"),
|
|
'<' => out.push_str("<"),
|
|
'>' => out.push_str(">"),
|
|
'"' => out.push_str("""),
|
|
'\'' => out.push_str("'"),
|
|
other => out.push(other),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Replace `==text==` with `<mark>text</mark>`. Unmatched openers are left
|
|
/// as literal text. No nesting (once we enter a mark, the next `==` closes).
|
|
/// `text` may not contain newlines — a paragraph break cancels the mark
|
|
/// (prevents runaway highlighting if the LLM forgets the closer).
|
|
fn substitute_marks(s: &str) -> String {
|
|
let mut out = String::with_capacity(s.len());
|
|
let bytes = s.as_bytes();
|
|
let mut i = 0;
|
|
while i < bytes.len() {
|
|
if i + 1 < bytes.len()
|
|
&& bytes[i] == b'='
|
|
&& bytes[i + 1] == b'='
|
|
&& let Some(end) = find_closer(bytes, i + 2)
|
|
{
|
|
out.push_str("<mark>");
|
|
out.push_str(&s[i + 2..end]);
|
|
out.push_str("</mark>");
|
|
i = end + 2;
|
|
continue;
|
|
}
|
|
// Not a mark opener — push one byte. Safe because we only peek at
|
|
// ASCII `=`; any multi-byte char is pushed whole by this single-byte
|
|
// copy sequence as long as the loop visits each of its bytes. But
|
|
// string slicing requires char boundaries, so we push by char.
|
|
let ch_end = next_char_boundary(s, i);
|
|
out.push_str(&s[i..ch_end]);
|
|
i = ch_end;
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Find the byte index of the opening `=` of the next `==` closer in
|
|
/// `bytes[start..]`, or `None` if none before a newline or end-of-input.
|
|
fn find_closer(bytes: &[u8], start: usize) -> Option<usize> {
|
|
let mut j = start;
|
|
while j + 1 < bytes.len() {
|
|
match bytes[j] {
|
|
b'\n' => return None,
|
|
b'=' if bytes[j + 1] == b'=' => return Some(j),
|
|
_ => j += 1,
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn next_char_boundary(s: &str, i: usize) -> usize {
|
|
let mut j = i + 1;
|
|
while j < s.len() && !s.is_char_boundary(j) {
|
|
j += 1;
|
|
}
|
|
j
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn escape_and_mark_converts_highlight() {
|
|
let out = escape_and_mark("Patient klagt über ==Tastanalyse== am Po.");
|
|
assert_eq!(out, "Patient klagt über <mark>Tastanalyse</mark> am Po.");
|
|
}
|
|
|
|
#[test]
|
|
fn escape_and_mark_escapes_llm_html_injection() {
|
|
let out = escape_and_mark("<script>alert('x')</script>");
|
|
assert_eq!(out, "<script>alert('x')</script>");
|
|
}
|
|
|
|
#[test]
|
|
fn escape_and_mark_allows_only_our_mark_tags() {
|
|
// LLM emits both raw HTML and a ==mark==; only the mark becomes live HTML.
|
|
let out = escape_and_mark("<b>fett</b> und ==wichtig==");
|
|
assert_eq!(out, "<b>fett</b> und <mark>wichtig</mark>");
|
|
}
|
|
|
|
#[test]
|
|
fn escape_and_mark_preserves_unmatched_opener_as_text() {
|
|
let out = escape_and_mark("==offen ohne Ende");
|
|
assert_eq!(out, "==offen ohne Ende");
|
|
}
|
|
|
|
#[test]
|
|
fn escape_and_mark_does_not_cross_newlines() {
|
|
let out = escape_and_mark("==kein\nmark==");
|
|
// A newline between the openers cancels the highlight.
|
|
assert_eq!(out, "==kein\nmark==");
|
|
}
|
|
|
|
#[test]
|
|
fn escape_and_mark_handles_multiple_marks_in_one_line() {
|
|
let out = escape_and_mark("==A== und ==B==");
|
|
assert_eq!(out, "<mark>A</mark> und <mark>B</mark>");
|
|
}
|
|
|
|
#[test]
|
|
fn escape_and_mark_handles_unicode_content_in_mark() {
|
|
let out = escape_and_mark("==Blutdruck 140/90 über Norm==");
|
|
assert_eq!(out, "<mark>Blutdruck 140/90 über Norm</mark>");
|
|
}
|
|
|
|
#[test]
|
|
fn md_to_html_wraps_plain_text_in_paragraph() {
|
|
let out = md_to_html("Fließtext ohne Auszeichnung.");
|
|
assert_eq!(out.trim(), "<p>Fließtext ohne Auszeichnung.</p>");
|
|
}
|
|
|
|
#[test]
|
|
fn md_to_html_renders_mark_inline() {
|
|
let out = md_to_html("Patient klagt über ==Tastanalyse==.");
|
|
assert!(out.contains("<mark>Tastanalyse</mark>"));
|
|
assert!(out.contains("<p>"));
|
|
}
|
|
|
|
#[test]
|
|
fn md_to_html_blocks_raw_html_from_llm() {
|
|
// Even if the LLM embeds a <script>, it's rendered as escaped text.
|
|
let out = md_to_html("Vorsicht: <script>alert(1)</script>");
|
|
assert!(!out.contains("<script>"));
|
|
assert!(out.contains("<script>"));
|
|
}
|
|
|
|
#[test]
|
|
fn md_to_html_renders_paragraphs_separated_by_blank_line() {
|
|
let out = md_to_html("Erster Absatz.\n\nZweiter Absatz.");
|
|
assert!(out.contains("<p>Erster Absatz.</p>"));
|
|
assert!(out.contains("<p>Zweiter Absatz.</p>"));
|
|
}
|
|
}
|