Skip to main content

mdsee_render/
lib.rs

1//! mdsee-render(design.md §5)。
2//!
3//! Layout TreeからANSI / plain textを生成する。
4
5mod 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
21/// ANSI reset sequence。
22const RESET: &str = "\x1b[0m";
23
24/// Render error(§66)。
25#[derive(Debug, Error)]
26pub enum RenderError {
27    #[error("failed to write output")]
28    Write(#[from] io::Error),
29}
30
31/// Render options。
32///
33/// `color_level` が `None` の場合はplain text renderingになる(§71)。
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct RenderOptions {
36    pub color_level: ColorLevel,
37    /// 行頭の左margin(§22)。layoutのmarginと同じ値を渡す。
38    pub margin: u16,
39    /// OSC 8 hyperlinkを出すか(§33)。`false` なら `text <URL>` へfallbackする。
40    pub osc8: bool,
41    /// Theme(§61, S2-9)。
42    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
56/// LayoutDocumentをtargetへ書き出す(§100 基本pipeline)。
57///
58/// blockの間に空行を1つ挟む。空行には余白を出さない。
59pub 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                    // §33: OSC 8 hyperlink
108                    (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
126/// span本文を書き出し、fallback URLがあれば末尾空白の直前に置く。
127///
128/// `docs ` なら `docs <URL> ` とすることで、
129/// `docs  <URL>`(空白の二重化)を防ぐ。
130fn 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
140/// plain fallback用のURL(§33)。
141///
142/// 同一URLのspanが行内に複数ある場合は、最後の出現箇所にのみ付ける。
143fn 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
157/// OSC 8 開始sequence(§33): `ESC ] 8 ; ; URL ST`
158fn osc8_open(url: &str) -> String {
159    format!("\x1b]8;;{url}\x1b\\")
160}
161
162/// OSC 8 終了sequence(§33): `ESC ] 8 ; ; ST`
163const OSC8_CLOSE: &str = "\x1b]8;;\x1b\\";
164
165/// 水平罫線(§11 HorizontalRule)。styleは§19の `Border`。
166fn 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
185/// コードblock(§28)。`╭─ 言語 ─` 枠で囲み、中身はhighlightして出す。
186///
187/// 本文は折り返さない(S3-1)。色が無効な場合はhighlightも行わない。
188fn write_code_block(output: &mut String, code: &mdsee_layout::CodeLayout, options: &RenderOptions) {
189    use unicode_width::UnicodeWidthStr;
190
191    // 上枠: ╭─ rust ─────(言語がなければ ╭────)
192    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                    // "╭─ " + label + " " + "─" * (width - 3 - label - 1)
214                    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    // 本文行: │ + highlight済みspan
238    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    // 下枠: ╰────
300    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
317/// code blockのhighlight。色無効またはfeature構成によりhighlightできない
318/// 場合は `None`(§29: rendererはtrait越しにのみ使う)。
319fn 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        // 上枠は ╭─ rust ─…。言語label付き
459        assert!(lines[0].starts_with("\x1b[38;5;"));
460        assert!(lines[0].contains('╭'));
461        assert!(lines[0].contains("rust"));
462        // 本文行は │ prefixを持ち、highlight色(syntect由来)で分割される。
463        // escapeを除去すると元のコード行に戻る
464        let stripped = strip_sgr(lines[1]);
465        assert_eq!(stripped, "│ fn main() {}");
466        // 下枠
467        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    /// SGR sequence(\x1b[...m)を除去する。test専用の簡易版。
479    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    // ---- Sprint 2(S2-4, S2-3) ----
524
525    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        // §33: ESC ] 8 ; ; URL ST text ESC ] 8 ; ; ST
538        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        // §33: plain fallbackは `text <URL>`
557        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        // link spanの末尾空白はURLの後に置く(空白の二重化を防ぐ)
570        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}