1mod highlight;
6mod style;
7mod theme;
8
9use std::io;
10
11use thiserror::Error;
12
13use mdsee_layout::{LayoutBlock, LayoutDocument, LayoutLine, LayoutSpan, SemanticStyle};
14use mdsee_terminal::ColorLevel;
15
16#[cfg(feature = "syntax")]
17pub use highlight::syntect_backend::SyntectHighlighter;
18pub use highlight::{HighlightedLine, HighlightedSpan, NoHighlight, SyntaxHighlighter};
19pub use theme::{select_auto_theme, AlertTheme, TextStyle, Theme};
20
21const RESET: &str = "\x1b[0m";
23
24#[derive(Debug, Error)]
26pub enum RenderError {
27 #[error("failed to write output")]
28 Write(#[from] io::Error),
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct RenderOptions {
36 pub color_level: ColorLevel,
37 pub margin: u16,
39 pub osc8: bool,
41 pub theme: Theme,
43}
44
45impl Default for RenderOptions {
46 fn default() -> Self {
47 Self {
48 color_level: ColorLevel::None,
49 margin: 2,
50 osc8: false,
51 theme: Theme::dark(),
52 }
53 }
54}
55
56pub fn render(
60 document: &LayoutDocument,
61 target: &mut dyn io::Write,
62 options: &RenderOptions,
63) -> Result<(), RenderError> {
64 let mut output = String::new();
65 for (index, block) in document.blocks.iter().enumerate() {
66 if index > 0 {
67 output.push('\n');
68 }
69 match block {
70 LayoutBlock::Text(text_block) => {
71 for line in &text_block.lines {
72 write_text_line(&mut output, line, options);
73 }
74 }
75 LayoutBlock::Code(code) => write_code_block(&mut output, code, options),
76 LayoutBlock::Rule(rule) => write_rule_line(&mut output, rule.width, options),
77 LayoutBlock::Table(table) => {
78 for line in &table.lines {
79 write_text_line(&mut output, line, options);
80 }
81 }
82 }
83 }
84 target.write_all(output.as_bytes())?;
85 Ok(())
86}
87
88fn write_text_line(output: &mut String, line: &LayoutLine, options: &RenderOptions) {
89 if line.spans.iter().all(|span| span.content.is_empty()) {
90 output.push('\n');
91 return;
92 }
93 push_margin(output, options.margin);
94 match options.color_level {
95 ColorLevel::None => {
96 for (position, span) in line.spans.iter().enumerate() {
97 push_span_plain(output, span, fallback_url(span, line, position));
98 }
99 }
100 level => {
101 for (position, span) in line.spans.iter().enumerate() {
102 let sequence = style::sgr_sequence(&options.theme.spec(span.style), level);
103 if !sequence.is_empty() {
104 output.push_str(&sequence);
105 }
106 match (&span.link, options.osc8) {
107 (Some(link), true) => {
109 output.push_str(&osc8_open(&link.url));
110 output.push_str(&span.content);
111 output.push_str(OSC8_CLOSE);
112 }
113 _ => {
114 push_span_plain(output, span, fallback_url(span, line, position));
115 }
116 }
117 if !sequence.is_empty() {
118 output.push_str(RESET);
119 }
120 }
121 }
122 }
123 output.push('\n');
124}
125
126fn push_span_plain(output: &mut String, span: &LayoutSpan, fallback_url: Option<&str>) {
131 let trimmed = span.content.trim_end_matches(' ');
132 let trailing = &span.content[trimmed.len()..];
133 output.push_str(trimmed);
134 if let Some(url) = fallback_url {
135 output.push_str(&format!(" <{url}>"));
136 }
137 output.push_str(trailing);
138}
139
140fn fallback_url<'a>(
144 span: &'a LayoutSpan,
145 line: &'a LayoutLine,
146 position: usize,
147) -> Option<&'a str> {
148 let url = span.link.as_ref().map(|l| l.url.as_str())?;
149 let is_last_occurrence = !line
150 .spans
151 .iter()
152 .skip(position + 1)
153 .any(|other| other.link.as_ref().map(|l| l.url.as_str()) == Some(url));
154 is_last_occurrence.then_some(url)
155}
156
157fn osc8_open(url: &str) -> String {
159 format!("\x1b]8;;{url}\x1b\\")
160}
161
162const OSC8_CLOSE: &str = "\x1b]8;;\x1b\\";
164
165fn write_rule_line(output: &mut String, width: usize, options: &RenderOptions) {
167 push_margin(output, options.margin);
168 let content: String = std::iter::repeat_n('─', width).collect();
169 match options.color_level {
170 ColorLevel::None => output.push_str(&content),
171 level => {
172 let sequence = style::sgr_sequence(&options.theme.spec(SemanticStyle::Border), level);
173 if sequence.is_empty() {
174 output.push_str(&content);
175 } else {
176 output.push_str(&sequence);
177 output.push_str(&content);
178 output.push_str(RESET);
179 }
180 }
181 }
182 output.push('\n');
183}
184
185fn write_code_block(output: &mut String, code: &mdsee_layout::CodeLayout, options: &RenderOptions) {
189 use unicode_width::UnicodeWidthStr;
190
191 push_margin(output, options.margin);
193 match options.color_level {
194 ColorLevel::None => match &code.language {
195 Some(language) => {
196 let prefix = format!("╭─ {language} ");
197 let fill = code.width.saturating_sub(prefix.width() + 1);
198 output.push_str(&prefix);
199 output.push_str(&"─".repeat(fill));
200 output.push('─');
201 }
202 None => {
203 output.push('╭');
204 output.push_str(&"─".repeat(code.width.saturating_sub(1)));
205 }
206 },
207 level => {
208 let border = style::sgr_sequence(&options.theme.spec(SemanticStyle::Border), level);
209 output.push_str(&border);
210 match &code.language {
211 Some(language) => {
212 let label_width = UnicodeWidthStr::width(language.as_str());
213 let fill = code.width.saturating_sub(3 + label_width + 1).max(1);
215 output.push_str("╭─ ");
216 output.push_str(RESET);
217 let label =
218 style::sgr_sequence(&options.theme.spec(SemanticStyle::Muted), level);
219 output.push_str(&label);
220 output.push_str(language);
221 output.push_str(RESET);
222 output.push_str(&border);
223 output.push(' ');
224 output.push_str(&"─".repeat(fill));
225 output.push_str(RESET);
226 }
227 None => {
228 output.push('╭');
229 output.push_str(&"─".repeat(code.width.saturating_sub(1)));
230 output.push_str(RESET);
231 }
232 }
233 }
234 }
235 output.push('\n');
236
237 let highlighted = highlight_code(code, options);
239 for (index, line) in code.lines.iter().enumerate() {
240 push_margin(output, options.margin);
241 match options.color_level {
242 ColorLevel::None => {
243 if line.is_empty() {
244 output.push('│');
245 } else {
246 output.push_str("│ ");
247 output.push_str(line);
248 }
249 }
250 level => {
251 let border = style::sgr_sequence(&options.theme.spec(SemanticStyle::Border), level);
252 output.push_str(&border);
253 output.push('│');
254 output.push_str(RESET);
255 if line.is_empty() {
256 output.push('\n');
257 continue;
258 }
259 output.push(' ');
260 match highlighted.as_ref().and_then(|lines| lines.get(index)) {
261 Some(hl) if !hl.spans.is_empty() => {
262 for span in &hl.spans {
263 let sequence = style::sgr_sequence(
264 &style::StyleSpec {
265 fg: Some(span.fg),
266 bold: span.bold,
267 italic: span.italic,
268 underline: span.underline,
269 strike: false,
270 },
271 level,
272 );
273 if sequence.is_empty() {
274 output.push_str(&span.text);
275 } else {
276 output.push_str(&sequence);
277 output.push_str(&span.text);
278 output.push_str(RESET);
279 }
280 }
281 }
282 _ => {
283 let sequence =
284 style::sgr_sequence(&options.theme.spec(SemanticStyle::Code), level);
285 if sequence.is_empty() {
286 output.push_str(line);
287 } else {
288 output.push_str(&sequence);
289 output.push_str(line);
290 output.push_str(RESET);
291 }
292 }
293 }
294 }
295 }
296 output.push('\n');
297 }
298
299 push_margin(output, options.margin);
301 match options.color_level {
302 ColorLevel::None => {
303 output.push('╰');
304 output.push_str(&"─".repeat(code.width.saturating_sub(1)));
305 }
306 level => {
307 let border = style::sgr_sequence(&options.theme.spec(SemanticStyle::Border), level);
308 output.push_str(&border);
309 output.push('╰');
310 output.push_str(&"─".repeat(code.width.saturating_sub(1)));
311 output.push_str(RESET);
312 }
313 }
314 output.push('\n');
315}
316
317fn highlight_code(
320 code: &mdsee_layout::CodeLayout,
321 options: &RenderOptions,
322) -> Option<Vec<HighlightedLine>> {
323 if options.color_level == ColorLevel::None {
324 return None;
325 }
326 #[cfg(feature = "syntax")]
327 {
328 let highlighter: &dyn SyntaxHighlighter =
329 &SyntectHighlighter::new(options.theme.syntax_theme.clone());
330 let mut source = code.lines.join("\n");
331 if !source.is_empty() {
332 source.push('\n');
333 }
334 let lines = highlighter.highlight(&source, code.language.as_deref());
335 Some(lines)
336 }
337 #[cfg(not(feature = "syntax"))]
338 {
339 let highlighter: &dyn SyntaxHighlighter = &NoHighlight;
340 let lines = highlighter.highlight(&code.lines.join("\n"), code.language.as_deref());
341 Some(lines)
342 }
343}
344
345fn push_margin(output: &mut String, margin: u16) {
346 for _ in 0..margin {
347 output.push(' ');
348 }
349}
350
351#[cfg(test)]
352mod tests {
353 use mdsee_layout::{LayoutLine, LayoutSpan, LinkTarget, RuleLayout, SemanticStyle, TextBlock};
354
355 use super::*;
356
357 fn span(content: &str, style: SemanticStyle) -> LayoutSpan {
358 LayoutSpan {
359 content: content.to_string(),
360 style,
361 link: None,
362 }
363 }
364
365 fn document_with(lines: Vec<LayoutLine>) -> LayoutDocument {
366 LayoutDocument {
367 blocks: vec![LayoutBlock::Text(TextBlock { lines })],
368 }
369 }
370
371 fn render_to(document: &LayoutDocument, options: &RenderOptions) -> String {
372 let mut buffer: Vec<u8> = Vec::new();
373 render(document, &mut buffer, options).unwrap();
374 String::from_utf8(buffer).unwrap()
375 }
376
377 #[test]
378 fn plain_rendering_has_no_escape_sequences() {
379 let document = document_with(vec![LayoutLine {
380 spans: vec![
381 span("hello ", SemanticStyle::Body),
382 span("world", SemanticStyle::Strong),
383 ],
384 }]);
385 let output = render_to(&document, &RenderOptions::default());
386 assert_eq!(output, " hello world\n");
387 assert!(!output.contains('\x1b'));
388 }
389
390 #[test]
391 fn truecolor_rendering_emits_ansi() {
392 let document = document_with(vec![LayoutLine {
393 spans: vec![span("hi", SemanticStyle::InlineCode)],
394 }]);
395 let options = RenderOptions {
396 color_level: ColorLevel::TrueColor,
397 margin: 0,
398 osc8: false,
399 theme: Theme::dark(),
400 };
401 let output = render_to(&document, &options);
402 assert_eq!(output, "\x1b[38;2;79;193;233mhi\x1b[0m\n");
403 }
404
405 #[test]
406 fn blocks_are_separated_by_blank_line() {
407 let document = LayoutDocument {
408 blocks: vec![
409 LayoutBlock::Text(TextBlock {
410 lines: vec![LayoutLine {
411 spans: vec![span("one", SemanticStyle::Body)],
412 }],
413 }),
414 LayoutBlock::Text(TextBlock {
415 lines: vec![LayoutLine {
416 spans: vec![span("two", SemanticStyle::Body)],
417 }],
418 }),
419 ],
420 };
421 let output = render_to(&document, &RenderOptions::default());
422 assert_eq!(output, " one\n\n two\n");
423 }
424
425 #[test]
426 fn blank_line_has_no_trailing_margin() {
427 let document = document_with(vec![
428 LayoutLine {
429 spans: vec![span("a", SemanticStyle::Body)],
430 },
431 LayoutLine { spans: vec![] },
432 LayoutLine {
433 spans: vec![span("b", SemanticStyle::Body)],
434 },
435 ]);
436 let output = render_to(&document, &RenderOptions::default());
437 assert_eq!(output, " a\n\n b\n");
438 }
439
440 #[test]
441 fn code_block_renders_frame_and_highlighted_lines() {
442 let document = LayoutDocument {
443 blocks: vec![LayoutBlock::Code(mdsee_layout::CodeLayout {
444 language: Some("rust".to_string()),
445 lines: vec!["fn main() {}".to_string()],
446 width: 20,
447 })],
448 };
449 let options = RenderOptions {
450 color_level: ColorLevel::Ansi256,
451 margin: 0,
452 osc8: false,
453 theme: Theme::dark(),
454 };
455 let output = render_to(&document, &options);
456 let lines: Vec<&str> = output.lines().collect();
457 assert_eq!(lines.len(), 3);
458 assert!(lines[0].starts_with("\x1b[38;5;"));
460 assert!(lines[0].contains('╭'));
461 assert!(lines[0].contains("rust"));
462 let stripped = strip_sgr(lines[1]);
465 assert_eq!(stripped, "│ fn main() {}");
466 assert!(lines[2].contains('╰'));
468 assert_eq!(
469 lines[2],
470 format!(
471 "\x1b[38;5;{}m╰{}─\x1b[0m",
472 style::rgb_to_256(style::Rgb(48, 54, 61)),
473 "─".repeat(18)
474 )
475 );
476 }
477
478 fn strip_sgr(input: &str) -> String {
480 let mut out = String::new();
481 let mut chars = input.chars().peekable();
482 while let Some(c) = chars.next() {
483 if c == '\x1b' && chars.peek() == Some(&'[') {
484 for c in chars.by_ref() {
485 if c == 'm' {
486 break;
487 }
488 }
489 } else {
490 out.push(c);
491 }
492 }
493 out
494 }
495
496 #[test]
497 fn plain_code_block_renders_frame_without_ansi() {
498 let document = LayoutDocument {
499 blocks: vec![LayoutBlock::Code(mdsee_layout::CodeLayout {
500 language: Some("sh".to_string()),
501 lines: vec!["ls -la".to_string(), String::new()],
502 width: 12,
503 })],
504 };
505 let output = render_to(&document, &RenderOptions::default());
506 assert_eq!(output, " ╭─ sh ──────\n │ ls -la\n │\n ╰───────────\n");
507 assert!(!output.contains('\x1b'));
508 }
509
510 #[test]
511 fn no_language_code_block_has_plain_frame() {
512 let document = LayoutDocument {
513 blocks: vec![LayoutBlock::Code(mdsee_layout::CodeLayout {
514 language: None,
515 lines: vec!["x".to_string()],
516 width: 6,
517 })],
518 };
519 let output = render_to(&document, &RenderOptions::default());
520 assert_eq!(output, " ╭─────\n │ x\n ╰─────\n");
521 }
522
523 fn link_span(content: &str, url: &str) -> LayoutSpan {
526 LayoutSpan {
527 content: content.to_string(),
528 style: SemanticStyle::Link,
529 link: Some(LinkTarget {
530 url: url.to_string(),
531 }),
532 }
533 }
534
535 #[test]
536 fn osc8_wraps_link_text() {
537 let document = document_with(vec![LayoutLine {
539 spans: vec![link_span("site", "https://example.com")],
540 }]);
541 let options = RenderOptions {
542 color_level: ColorLevel::TrueColor,
543 margin: 0,
544 osc8: true,
545 theme: Theme::dark(),
546 };
547 let output = render_to(&document, &options);
548 assert_eq!(
549 output,
550 "\x1b[4;38;2;88;166;255m\x1b]8;;https://example.com\x1b\\site\x1b]8;;\x1b\\\x1b[0m\n"
551 );
552 }
553
554 #[test]
555 fn plain_fallback_appends_url_in_angle_brackets() {
556 let document = document_with(vec![LayoutLine {
558 spans: vec![
559 span("see ", SemanticStyle::Body),
560 link_span("docs", "https://example.com/docs"),
561 ],
562 }]);
563 let output = render_to(&document, &RenderOptions::default());
564 assert_eq!(output, " see docs <https://example.com/docs>\n");
565 }
566
567 #[test]
568 fn plain_fallback_keeps_trailing_space_after_url() {
569 let document = document_with(vec![LayoutLine {
571 spans: vec![
572 span("see ", SemanticStyle::Body),
573 link_span("docs ", "https://example.com"),
574 span("here", SemanticStyle::Body),
575 ],
576 }]);
577 let output = render_to(&document, &RenderOptions::default());
578 assert_eq!(output, " see docs <https://example.com> here\n");
579 }
580
581 #[test]
582 fn same_url_repeated_in_line_prints_fallback_once() {
583 let document = document_with(vec![LayoutLine {
584 spans: vec![
585 link_span("a", "https://x"),
586 span(" ", SemanticStyle::Body),
587 link_span("b", "https://x"),
588 ],
589 }]);
590 let output = render_to(&document, &RenderOptions::default());
591 assert_eq!(output, " a b <https://x>\n");
592 }
593
594 #[test]
595 fn different_urls_each_get_fallback() {
596 let document = document_with(vec![LayoutLine {
597 spans: vec![
598 link_span("a", "https://x"),
599 span(" ", SemanticStyle::Body),
600 link_span("b", "https://y"),
601 ],
602 }]);
603 let output = render_to(&document, &RenderOptions::default());
604 assert_eq!(output, " a <https://x> b <https://y>\n");
605 }
606
607 #[test]
608 fn rule_block_renders_full_width_line_with_border_style() {
609 let document = LayoutDocument {
610 blocks: vec![LayoutBlock::Rule(RuleLayout { width: 5 })],
611 };
612 let options = RenderOptions {
613 color_level: ColorLevel::TrueColor,
614 margin: 0,
615 osc8: false,
616 theme: Theme::dark(),
617 };
618 let output = render_to(&document, &options);
619 assert_eq!(output, "\x1b[38;2;48;54;61m─────\x1b[0m\n");
620 }
621}