1use ratatui_core::text::Line;
61use tuika::Theme;
62use tuika::components::{MarkdownBlock, MarkdownBlockContext, MarkdownBlockRenderer};
63use tuika::style::StyleSheet;
64
65mod block;
66mod dom;
67mod inline;
68mod table;
69mod view;
70
71pub use view::Html;
72
73#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78pub struct Limits {
79 pub max_input_bytes: usize,
82 pub max_lines: usize,
84 pub max_depth: usize,
90}
91
92impl Default for Limits {
93 fn default() -> Self {
94 Self {
95 max_input_bytes: 256 * 1024,
96 max_lines: 4096,
97 max_depth: 64,
98 }
99 }
100}
101
102#[derive(Clone, Copy, Debug, Default)]
121pub struct HtmlRenderer {
122 limits: Limits,
123}
124
125impl HtmlRenderer {
126 pub fn new() -> Self {
128 Self::default()
129 }
130
131 pub fn with_limits(limits: Limits) -> Self {
133 Self { limits }
134 }
135
136 pub fn limits(&self) -> Limits {
138 self.limits
139 }
140}
141
142impl MarkdownBlockRenderer for HtmlRenderer {
143 fn render(
144 &self,
145 block: MarkdownBlock<'_>,
146 context: MarkdownBlockContext<'_>,
147 ) -> Option<Vec<Line<'static>>> {
148 let source = match block {
149 MarkdownBlock::Html { source } => source,
150 MarkdownBlock::Fenced { language, source }
151 if matches!(
152 language.to_ascii_lowercase().as_str(),
153 "html" | "htm" | "xhtml"
154 ) =>
155 {
156 source
157 }
158 _ => return None,
159 };
160 let lines = to_lines_with_limits(
161 source,
162 context.width,
163 context.theme,
164 context.sheet,
165 self.limits,
166 )?;
167 (!lines.is_empty()).then_some(lines)
169 }
170}
171
172pub fn to_lines(html: &str, width: u16, theme: &Theme, sheet: &StyleSheet) -> Vec<Line<'static>> {
189 to_lines_with_limits(html, width, theme, sheet, Limits::default()).unwrap_or_default()
190}
191
192pub fn to_lines_with_limits(
195 html: &str,
196 width: u16,
197 theme: &Theme,
198 sheet: &StyleSheet,
199 limits: Limits,
200) -> Option<Vec<Line<'static>>> {
201 if html.len() > limits.max_input_bytes {
202 return None;
203 }
204 if dom::max_depth(html) > limits.max_depth {
206 return None;
207 }
208 let root = dom::parse(html);
209 Some(block::Layout::new(theme, sheet, limits).render(&root, width))
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use ratatui_core::style::Modifier;
216
217 fn plain(html: &str, width: u16) -> Vec<String> {
219 let theme = Theme::default();
220 to_lines(html, width, &theme, &StyleSheet::from_theme(&theme))
221 .iter()
222 .map(|l| {
223 l.spans
224 .iter()
225 .map(|s| s.content.as_ref())
226 .collect::<String>()
227 .trim_end()
228 .to_string()
229 })
230 .collect()
231 }
232
233 fn styled(html: &str, needle: &str) -> ratatui_core::style::Style {
234 let theme = Theme::default();
235 to_lines(html, 60, &theme, &StyleSheet::from_theme(&theme))
236 .iter()
237 .flat_map(|l| l.spans.clone())
238 .find(|s| s.content.contains(needle))
239 .unwrap_or_else(|| panic!("no span containing {needle:?}"))
240 .style
241 }
242
243 #[test]
244 fn headings_and_paragraphs_are_separated() {
245 let out = plain("<h1>Title</h1><p>Some prose.</p>", 40);
246 assert_eq!(out, vec!["Title", "", "Some prose."]);
247 assert!(
248 styled("<h1>Title</h1>", "Title")
249 .add_modifier
250 .contains(Modifier::BOLD)
251 );
252 }
253
254 #[test]
255 fn prose_wraps_to_the_width() {
256 let out = plain("<p>one two three four five six seven</p>", 12);
257 assert!(out.len() > 1, "{out:?}");
258 assert!(out.iter().all(|l| l.chars().count() <= 12), "{out:?}");
259 }
260
261 #[test]
262 fn lists_number_nest_and_hang() {
263 let out = plain(
264 "<ol start=3><li>first</li><li>second<ul><li>inner</li></ul></li></ol>",
265 40,
266 );
267 assert!(out.iter().any(|l| l == "3. first"), "{out:?}");
268 assert!(out.iter().any(|l| l == "4. second"), "{out:?}");
269 assert!(out.iter().any(|l| l.trim() == "• inner"), "{out:?}");
270 let inner = out.iter().find(|l| l.contains("inner")).unwrap();
271 assert!(inner.starts_with(" "), "nested under its item: {inner:?}");
272 }
273
274 #[test]
275 fn a_long_list_item_hangs_under_its_marker() {
276 let out = plain("<ul><li>one two three four five six</li></ul>", 14);
277 assert!(out[0].starts_with("• "), "{out:?}");
278 for line in &out[1..] {
279 assert!(line.starts_with(" "), "continuation hangs: {out:?}");
280 }
281 }
282
283 #[test]
284 fn block_quotes_indent_their_content() {
285 let out = plain("<blockquote><p>quoted</p></blockquote>", 40);
286 assert!(out.iter().any(|l| l == " quoted"), "{out:?}");
287 }
288
289 #[test]
290 fn pre_is_verbatim_and_never_wrapped() {
291 let out = plain("<pre> keep spacing\nand lines</pre>", 40);
292 assert!(out.iter().any(|l| l == " keep spacing"), "{out:?}");
293 assert!(out.iter().any(|l| l == "and lines"), "{out:?}");
294
295 let long = format!("<pre>{}</pre>", "x".repeat(60));
296 let wide = plain(&long, 20);
297 assert_eq!(wide.len(), 1, "code must not wrap: {wide:?}");
298 }
299
300 #[test]
301 fn details_shows_its_summary_and_indents_the_body() {
302 let out = plain("<details><summary>More</summary><p>Body</p></details>", 40);
303 assert!(out.iter().any(|l| l == "▸ More"), "{out:?}");
304 assert!(out.iter().any(|l| l == " Body"), "{out:?}");
305 let open = plain("<details open><summary>More</summary>x</details>", 40);
307 assert!(open.iter().any(|l| l == "▾ More"), "{open:?}");
308 }
309
310 #[test]
311 fn tables_are_boxed_and_fitted() {
312 let out = plain(
313 "<table><tr><th>Name</th><th>Role</th></tr>\
314 <tr><td>Ada</td><td>Author</td></tr></table>",
315 40,
316 );
317 assert!(out[0].starts_with('╭'), "{out:?}");
318 assert!(out.iter().any(|l| l.contains("Name") && l.contains("Role")));
319 assert!(
320 out.iter()
321 .any(|l| l.contains("Ada") && l.contains("Author"))
322 );
323 assert!(out.last().unwrap().starts_with('╰'), "{out:?}");
324 for line in &out {
325 assert!(line.chars().count() <= 40, "over width: {line:?}");
326 }
327 }
328
329 #[test]
330 fn a_table_too_narrow_for_a_grid_keeps_its_content() {
331 let out = plain(
332 "<table><tr><th>Name</th><th>Role</th></tr>\
333 <tr><td>Ada</td><td>Author</td></tr></table>",
334 12,
335 );
336 let joined = out.join(" ");
337 assert!(joined.contains("Ada"), "{out:?}");
338 assert!(joined.contains("Author"), "{out:?}");
339 for line in &out {
340 assert!(line.chars().count() <= 12, "over width: {line:?}");
341 }
342 }
343
344 #[test]
345 fn a_headerless_table_still_renders() {
346 let out = plain("<table><tr><td>a</td><td>b</td></tr></table>", 40);
347 assert!(
348 out.iter().any(|l| l.contains('a') && l.contains('b')),
349 "{out:?}"
350 );
351 }
352
353 #[test]
354 fn horizontal_rules_are_themed() {
355 let out = plain("<p>a</p><hr><p>b</p>", 40);
356 assert!(out.iter().any(|l| l.starts_with("───")), "{out:?}");
357 }
358
359 #[test]
360 fn definition_lists_render_terms_and_definitions() {
361 let out = plain("<dl><dt>Term</dt><dd>Meaning</dd></dl>", 40);
362 assert!(out.iter().any(|l| l == "Term"), "{out:?}");
363 assert!(out.iter().any(|l| l == " Meaning"), "{out:?}");
364 }
365
366 #[test]
367 fn malformed_markup_degrades_instead_of_failing() {
368 for html in [
369 "<p>unclosed",
370 "</p>stray close",
371 "<ul><li>a<li>b",
372 "<table><td>lonely cell",
373 "<b><i>crossed</b></i>",
374 "<div".repeat(50).as_str(),
375 "¬anentity; & A",
376 ] {
377 let out = plain(html, 30);
378 for line in &out {
379 assert!(line.chars().count() <= 30, "{html:?} -> {line:?}");
380 }
381 }
382 assert!(plain("<p>unclosed", 30).iter().any(|l| l == "unclosed"));
383 assert!(plain("& A", 30).iter().any(|l| l == "& A"));
384 }
385
386 #[test]
387 fn deep_nesting_is_bounded() {
388 let deep = format!("{}deep{}", "<div>".repeat(500), "</div>".repeat(500));
389 let out = plain(&deep, 30);
392 assert!(out.len() <= 2, "{out:?}");
393 }
394
395 #[test]
396 fn oversized_input_is_refused_rather_than_rendered() {
397 let theme = Theme::default();
398 let sheet = StyleSheet::from_theme(&theme);
399 let limits = Limits {
400 max_input_bytes: 16,
401 ..Limits::default()
402 };
403 assert!(
404 to_lines_with_limits("<p>much too long for this</p>", 40, &theme, &sheet, limits)
405 .is_none()
406 );
407 assert!(to_lines_with_limits("<p>ok</p>", 40, &theme, &sheet, limits).is_some());
408 }
409
410 #[test]
411 fn output_is_capped() {
412 let theme = Theme::default();
413 let sheet = StyleSheet::from_theme(&theme);
414 let limits = Limits {
415 max_lines: 5,
416 ..Limits::default()
417 };
418 let many = "<p>x</p>".repeat(100);
419 let lines = to_lines_with_limits(&many, 40, &theme, &sheet, limits).expect("rendered");
420 assert!(lines.len() <= 5, "{}", lines.len());
421 }
422
423 #[test]
424 fn styling_follows_the_stylesheet() {
425 use ratatui_core::style::Color;
426 let theme = Theme::default();
427 let sheet = StyleSheet {
428 strong: tuika::style::StyleBundle::new().fg(Color::Green),
429 ..StyleSheet::from_theme(&theme)
430 };
431 let lines = to_lines("<p><b>bold</b></p>", 40, &theme, &sheet);
432 let span = lines[0]
433 .spans
434 .iter()
435 .find(|s| s.content.contains("bold"))
436 .expect("bold span");
437 assert_eq!(span.style.fg, Some(Color::Green));
438 }
439
440 #[test]
441 fn one_renderer_serves_both_markdown_block_kinds() {
442 let theme = Theme::default();
443 let sheet = StyleSheet::from_theme(&theme);
444 let renderer = HtmlRenderer::new();
445 let context = MarkdownBlockContext::new(20, &theme, &sheet);
446
447 let block = renderer.render(
448 MarkdownBlock::Html {
449 source: "<p>hi</p>",
450 },
451 context,
452 );
453 assert!(block.is_some());
454 let fence = renderer.render(
455 MarkdownBlock::Fenced {
456 language: "html",
457 source: "<p>hi</p>",
458 },
459 context,
460 );
461 assert!(fence.is_some());
462 assert!(
464 renderer
465 .render(
466 MarkdownBlock::Fenced {
467 language: "rust",
468 source: "fn main() {}",
469 },
470 context,
471 )
472 .is_none()
473 );
474 assert!(
476 renderer
477 .render(
478 MarkdownBlock::Html {
479 source: "<!-- note -->",
480 },
481 context,
482 )
483 .is_none()
484 );
485 }
486}