diff --git a/Cargo.lock b/Cargo.lock index c69c844bad05a8..d27fd02cf3a4a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9282,6 +9282,22 @@ dependencies = [ "workspace", ] +[[package]] +name = "latex_render" +version = "0.1.0" +dependencies = [ + "anyhow", + "collections", + "gpui", + "image", + "parking_lot", + "regex", + "rex", + "smallvec", + "tiny-skia", + "ttf-parser 0.20.0", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -9875,6 +9891,7 @@ dependencies = [ "gpui", "html5ever 0.27.0", "language", + "latex_render", "linkify", "log", "markup5ever_rcdom", @@ -13951,6 +13968,19 @@ dependencies = [ "usvg", ] +[[package]] +name = "rex" +version = "0.1.2" +source = "git+https://github.com/MostlyKIGuess/ReX#e6305a3e1172771efe535d3d29e89a039500555c" +dependencies = [ + "log", + "serde", + "serde_derive", + "tiny-skia", + "ttf-parser 0.20.0", + "unicode-math", +] + [[package]] name = "rfc6979" version = "0.3.1" @@ -18202,6 +18232,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-math" +version = "0.1.0" +source = "git+https://github.com/MostlyKIGuess/ReX#e6305a3e1172771efe535d3d29e89a039500555c" +dependencies = [ + "nom 7.1.3", + "regex", +] + [[package]] name = "unicode-normalization" version = "0.1.24" @@ -21035,6 +21074,7 @@ dependencies = [ "language_selector", "language_tools", "languages", + "latex_render", "line_ending_selector", "log", "markdown", diff --git a/Cargo.toml b/Cargo.toml index 054f6324629626..4ed903fa116398 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,6 +104,7 @@ members = [ "crates/language_selector", "crates/language_tools", "crates/languages", + "crates/latex_render", "crates/line_ending_selector", "crates/livekit_api", "crates/livekit_client", @@ -343,6 +344,7 @@ language_onboarding = { path = "crates/language_onboarding" } language_selector = { path = "crates/language_selector" } language_tools = { path = "crates/language_tools" } languages = { path = "crates/languages" } +latex_render = { path = "crates/latex_render" } line_ending_selector = { path = "crates/line_ending_selector" } livekit_api = { path = "crates/livekit_api" } livekit_client = { path = "crates/livekit_client" } diff --git a/crates/latex_render/Cargo.toml b/crates/latex_render/Cargo.toml new file mode 100644 index 00000000000000..2d897b731800d7 --- /dev/null +++ b/crates/latex_render/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "latex_render" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/latex_render.rs" +doctest = false + +[[example]] +name = "latex_demo" +path = "examples/latex_demo.rs" + +[features] +test-support = ["gpui/test-support"] + +[dependencies] +anyhow.workspace = true +collections.workspace = true +gpui.workspace = true +image = { workspace = true } +parking_lot.workspace = true +smallvec.workspace = true +regex.workspace = true +tiny-skia = "0.11" +ttf-parser = "0.20" + +# ReX library from the fork (with Windows compatibility fixes) +rex = { git = "https://github.com/MostlyKIGuess/ReX", features = ["tinyskia-renderer", "ttfparser-fontparser"] } + +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/latex_render/LICENSE-GPL b/crates/latex_render/LICENSE-GPL new file mode 120000 index 00000000000000..89e542f750cd38 --- /dev/null +++ b/crates/latex_render/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/latex_render/examples/latex_demo.rs b/crates/latex_render/examples/latex_demo.rs new file mode 100644 index 00000000000000..9aa2e622716bf5 --- /dev/null +++ b/crates/latex_render/examples/latex_demo.rs @@ -0,0 +1,108 @@ +use gpui::{ + App, AppContext, Application, Bounds, Context, InteractiveElement, ParentElement, Render, + StatefulInteractiveElement, Styled, Window, WindowOptions, div, img, point, px, size, +}; +use latex_render::{LatexColor, LatexRenderer}; +use std::sync::Arc; + +struct LatexDemo { + renderer: Arc, + formulas: Vec<&'static str>, +} + +impl LatexDemo { + fn new(renderer: Arc) -> Self { + Self { + renderer, + formulas: vec![ + r"x", + r"X", + r"x_{1,2}", + r"x\_{1,2}", // Prettier way of writing underscore, added a special sanitization in renderer + r"\sum n \pi", + r"x_{1,2} = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}", + r"| \vec{A}|=\sqrt{A_x^2 + A_y^2 + A_z^2}", + r"| \vec{A}|=\sqrt{A_x^2 + A_y^2 + A_z^2} \tag{1}", // tags aren't supported, sanitization test + r"E = mc^2", + r"x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}", + r"\int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi}", + r"\sum_{n=1}^{\infty} \frac{1}{n^2} = \frac{\pi^2}{6}", + r"\nabla \times \mathbf{E} = -\frac{\partial \mathbf{B}}{\partial t}", + r"\lim_{n \to \infty} \left(1 + \frac{1}{n}\right)^n = e", + r"\begin{pmatrix} a & b \\ c & d \end{pmatrix}", + ], + } + } +} + +impl Render for LatexDemo { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl gpui::IntoElement { + let font_size = 16.0; + let color = LatexColor::BLACK; + + div() + .id("latex-demo") + .flex() + .flex_col() + .gap_4() + .p_4() + .bg(gpui::white()) + .size_full() + .overflow_y_scroll() + .children(self.formulas.iter().enumerate().map(|(_i, formula)| { + let result = self.renderer.render(formula, font_size, color, true); + + div() + .flex() + .flex_row() + .gap_2() + .p_2() + .border_1() + .border_color(gpui::rgb(0xcccccc)) + .rounded_md() + .child( + div() + .w(px(300.0)) + .overflow_hidden() + .child(formula.to_string()), + ) + .child( + div().flex_1().child(match result { + Some(Ok((image, (width, height)))) => div().child( + img(gpui::ImageSource::Render(image)) + .w(px(width as f32)) + .h(px(height as f32)), + ), + Some(Err(e)) => { + div().text_color(gpui::red()).child(format!("Error: {}", e)) + } + None => div().text_color(gpui::rgb(0x888888)).child("Rendering..."), + }), + ) + })) + } +} + +fn main() { + Application::new().run(|cx: &mut App| { + let window_options = WindowOptions { + window_bounds: Some(gpui::WindowBounds::Windowed(Bounds { + origin: point(px(100.0), px(100.0)), + size: size(px(900.0), px(600.0)), + })), + titlebar: Some(gpui::TitlebarOptions { + title: Some("LaTeX Rendering Demo (ReX)".into()), + ..Default::default() + }), + focus: true, + show: true, + ..Default::default() + }; + + cx.open_window(window_options, |_, cx| { + let renderer = Arc::new(LatexRenderer::new(cx.background_executor().clone())); + cx.new(|_| LatexDemo::new(renderer)) + }) + .unwrap(); + }); +} diff --git a/crates/latex_render/fonts/LatinModernMath.otf b/crates/latex_render/fonts/LatinModernMath.otf new file mode 100644 index 00000000000000..0e4642e916c3e8 Binary files /dev/null and b/crates/latex_render/fonts/LatinModernMath.otf differ diff --git a/crates/latex_render/src/latex_render.rs b/crates/latex_render/src/latex_render.rs new file mode 100644 index 00000000000000..a9b79ceb80b3f8 --- /dev/null +++ b/crates/latex_render/src/latex_render.rs @@ -0,0 +1,244 @@ +use anyhow::Result; +use collections::HashMap; +use gpui::{BackgroundExecutor, RenderImage}; +use image::Frame; +use parking_lot::Mutex; +use regex::Regex; +use smallvec::SmallVec; +use std::sync::Arc; + +/// The bundled Latin Modern Math font for rendering LaTeX. +const MATH_FONT_DATA: &[u8] = include_bytes!("../fonts/LatinModernMath.otf"); + +/// Render scale factor for high-DPI quality (higher = better quality but more memory). +const RENDER_SCALE: f64 = 8.0; + +/// Fixed-point multiplier for font size hashing. +const FONT_SIZE_HASH_MULTIPLIER: f64 = 100.0; + +/// RGBA color for rendering. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LatexColor { + pub r: u8, + pub g: u8, + pub b: u8, + pub a: u8, +} + +impl LatexColor { + pub const WHITE: Self = Self { + r: 255, + g: 255, + b: 255, + a: 255, + }; + pub const BLACK: Self = Self { + r: 0, + g: 0, + b: 0, + a: 255, + }; + + pub fn new(r: u8, g: u8, b: u8, a: u8) -> Self { + Self { r, g, b, a } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct CacheKey { + source: String, + font_size: u32, // Stored as fixed-point to allow hashing + color: LatexColor, + display: bool, // true for display style -> $$ , false for inline/text style -> $ +} + +/// Cached result of LaTeX rendering. +#[derive(Clone)] +enum CacheEntry { + Pending, + Ready(Result<(Arc, (f64, f64)), Arc>), +} + +pub struct LatexRenderer { + cache: Arc>>, + executor: BackgroundExecutor, +} + +impl LatexRenderer { + pub fn new(executor: BackgroundExecutor) -> Self { + Self { + cache: Arc::new(Mutex::new(HashMap::default())), + executor, + } + } + + /// Returns `None` if the formula is still being rendered. + /// Returns `Some(Ok((image, (width, height))))` with the rendered image and logical dimensions on success. + /// Returns `Some(Err(...))` if rendering failed. + /// + /// The `display` parameter controls the rendering style: + /// - `true` for display math + /// - `false` for inline math + pub fn render( + &self, + source: &str, + font_size: f64, + color: LatexColor, + display: bool, + ) -> Option, (f64, f64)), String>> { + // TODO: Add more sanitization as needed + // Sanitize source: for some reason by default in Zed, prettier I believe formats underscores as \_ inside $$ + let clean_source = source.replace(r"\_", "_"); + + // Remove \tag{...} commands as they are not supported by ReX + // We need to keep adding these + let tag_regex = Regex::new(r"\\tag\{[^}]*\}").unwrap_or_else(|_| Regex::new("").unwrap()); + let clean_source = tag_regex.replace_all(&clean_source, "").to_string(); + + // Remove lines that are just whitespace (often left over after removing tag) + let clean_source = clean_source.trim().to_string(); + + let key = CacheKey { + source: clean_source.clone(), + font_size: (font_size * FONT_SIZE_HASH_MULTIPLIER) as u32, + color, + display, + }; + + { + let cache = self.cache.lock(); + if let Some(entry) = cache.get(&key) { + return match entry { + CacheEntry::Pending => None, + CacheEntry::Ready(result) => { + Some(result.clone().map_err(|e| e.as_ref().clone())) + } + }; + } + } + + { + let mut cache = self.cache.lock(); + cache.insert(key.clone(), CacheEntry::Pending); + } + + let cache = self.cache.clone(); + let source = clean_source; + self.executor + .spawn(async move { + let result = render_latex_sync(&source, font_size, color, display); + let entry = match result { + Ok(data) => CacheEntry::Ready(Ok(data)), + Err(e) => CacheEntry::Ready(Err(Arc::new(e.to_string()))), + }; + + { + let mut cache_guard = cache.lock(); + cache_guard.insert(key, entry); + } + }) + .detach(); + + None + } + + pub fn clear_cache(&self) { + self.cache.lock().clear(); + } +} + +fn render_latex_sync( + source: &str, + font_size: f64, + color: LatexColor, + display: bool, +) -> Result<(Arc, (f64, f64))> { + use rex::Renderer; + use rex::font::backend::ttf_parser::TtfMathFont; + use rex::layout::Style; + use rex::layout::engine::LayoutBuilder; + use rex::render::tinyskia::TinySkiaBackend; + + let font = ttf_parser::Face::parse(MATH_FONT_DATA, 0) + .map_err(|e| anyhow::anyhow!("Failed to parse math font: {:?}", e))?; + let math_font = + TtfMathFont::new(font).map_err(|e| anyhow::anyhow!("Font lacks MATH table: {:?}", e))?; + + let parse_nodes = + rex::parser::parse(source).map_err(|e| anyhow::anyhow!("LaTeX parse error: {:?}", e))?; + + let style = if display { Style::Display } else { Style::Text }; + let layout_builder = LayoutBuilder::new(&math_font) + .font_size(font_size) + .style(style); + let layout = layout_builder + .build() + .layout(&parse_nodes) + .map_err(|e| anyhow::anyhow!("LaTeX layout error: {:?}", e))?; + + let dims = layout.size(); + let logical_width = dims.width; + let logical_height = dims.height; + let mut backend = TinySkiaBackend::from_dims(dims, RENDER_SCALE) + .ok_or_else(|| anyhow::anyhow!("Failed to create render backend"))?; + + backend.set_color(tiny_skia::Color::from_rgba8( + color.r, color.g, color.b, color.a, + )); + + let renderer = Renderer::new(); + renderer.render(&layout, &mut backend); + + let pixmap = backend.pixmap(); + let width = pixmap.width(); + let height = pixmap.height(); + + // tiny_skia uses premultiplied RGBA, convert to BGRA for GPUI + let mut pixels = pixmap.take(); + for pixel in pixels.chunks_exact_mut(4) { + pixel.swap(0, 2); // RGBA -> BGRA + } + + let buffer = image::RgbaImage::from_raw(width, height, pixels) + .ok_or_else(|| anyhow::anyhow!("Failed to create image buffer"))?; + + let frame = Frame::new(buffer); + let render_image = RenderImage::new(SmallVec::from_elem(frame, 1)); + + Ok((Arc::new(render_image), (logical_width, logical_height))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_render_simple_latex() { + let result = render_latex_sync("x^2", 16.0, LatexColor::BLACK, true); + assert!( + result.is_ok(), + "Failed to render simple LaTeX: {:?}", + result.err() + ); + } + + #[test] + fn test_render_fraction() { + let result = render_latex_sync(r"\frac{a}{b}", 16.0, LatexColor::BLACK, true); + assert!( + result.is_ok(), + "Failed to render fraction: {:?}", + result.err() + ); + } + + #[test] + fn test_render_integral() { + let result = render_latex_sync(r"\int_0^1 x\,dx", 16.0, LatexColor::BLACK, true); + assert!( + result.is_ok(), + "Failed to render integral: {:?}", + result.err() + ); + } +} diff --git a/crates/markdown_preview/Cargo.toml b/crates/markdown_preview/Cargo.toml index c9cce94de1f10a..df3da6512597df 100644 --- a/crates/markdown_preview/Cargo.toml +++ b/crates/markdown_preview/Cargo.toml @@ -23,6 +23,7 @@ fs.workspace = true gpui.workspace = true html5ever.workspace = true language.workspace = true +latex_render.workspace = true linkify.workspace = true log.workspace = true markup5ever_rcdom.workspace = true diff --git a/crates/markdown_preview/src/markdown_elements.rs b/crates/markdown_preview/src/markdown_elements.rs index 23e0a69b6addef..314eb85a6f52dc 100644 --- a/crates/markdown_preview/src/markdown_elements.rs +++ b/crates/markdown_preview/src/markdown_elements.rs @@ -18,6 +18,7 @@ pub enum ParsedMarkdownElement { Paragraph(MarkdownParagraph), HorizontalRule(Range), Image(Image), + Math(ParsedMarkdownMath), } impl ParsedMarkdownElement { @@ -31,9 +32,11 @@ impl ParsedMarkdownElement { Self::Paragraph(text) => match text.get(0)? { MarkdownParagraphChunk::Text(t) => t.source_range.clone(), MarkdownParagraphChunk::Image(image) => image.source_range.clone(), + MarkdownParagraphChunk::Math(math) => math.source_range.clone(), }, Self::HorizontalRule(range) => range.clone(), Self::Image(image) => image.source_range.clone(), + Self::Math(math) => math.source_range.clone(), }) } @@ -49,6 +52,7 @@ pub type MarkdownParagraph = Vec; pub enum MarkdownParagraphChunk { Text(ParsedMarkdownText), Image(Image), + Math(ParsedMarkdownMath), } #[derive(Debug)] @@ -57,6 +61,14 @@ pub struct ParsedMarkdown { pub children: Vec, } +#[derive(Debug, Clone)] +#[cfg_attr(test, derive(PartialEq))] +pub struct ParsedMarkdownMath { + pub source_range: Range, + pub contents: SharedString, + pub display: bool, +} + #[derive(Debug)] #[cfg_attr(test, derive(PartialEq))] pub struct ParsedMarkdownListItem { diff --git a/crates/markdown_preview/src/markdown_parser.rs b/crates/markdown_preview/src/markdown_parser.rs index b17ee5cac45560..dff279bbe11933 100644 --- a/crates/markdown_preview/src/markdown_parser.rs +++ b/crates/markdown_preview/src/markdown_parser.rs @@ -21,6 +21,7 @@ pub async fn parse_markdown( ) -> ParsedMarkdown { let mut options = Options::all(); options.remove(pulldown_cmark::Options::ENABLE_DEFINITION_LIST); + options.insert(pulldown_cmark::Options::ENABLE_MATH); let parser = Parser::new_ext(markdown_input, options); let parser = MarkdownParser::new( @@ -223,6 +224,15 @@ impl<'a> MarkdownParser<'a> { self.cursor += 1; Some(vec![ParsedMarkdownElement::HorizontalRule(source_range)]) } + Event::DisplayMath(content) => { + let content_str = content.to_string(); + self.cursor += 1; + Some(vec![ParsedMarkdownElement::Math(ParsedMarkdownMath { + source_range, + contents: content_str.into(), + display: true, + })]) + } _ => None, } } @@ -435,6 +445,40 @@ impl<'a> MarkdownParser<'a> { break; } }, + Event::InlineMath(content) => { + if !text.is_empty() { + let parsed_regions = MarkdownParagraphChunk::Text(ParsedMarkdownText { + source_range: source_range.clone(), + contents: mem::take(&mut text).into(), + highlights: mem::take(&mut highlights), + regions: mem::take(&mut regions), + }); + markdown_text_like.push(parsed_regions); + } + let (_, range) = self.current().unwrap(); + markdown_text_like.push(MarkdownParagraphChunk::Math(ParsedMarkdownMath { + source_range: range.clone(), + contents: content.to_string().into(), + display: false, + })); + } + Event::DisplayMath(content) => { + if !text.is_empty() { + let parsed_regions = MarkdownParagraphChunk::Text(ParsedMarkdownText { + source_range: source_range.clone(), + contents: mem::take(&mut text).into(), + highlights: mem::take(&mut highlights), + regions: mem::take(&mut regions), + }); + markdown_text_like.push(parsed_regions); + } + let (_, range) = self.current().unwrap(); + markdown_text_like.push(MarkdownParagraphChunk::Math(ParsedMarkdownMath { + source_range: range.clone(), + contents: content.to_string().into(), + display: true, + })); + } _ => { break; } diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 650f369309561d..bd3ecaea0518b5 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -41,6 +41,7 @@ pub struct MarkdownPreviewView { language_registry: Arc, parsing_markdown_task: Option>>, mode: MarkdownPreviewMode, + latex_renderer: Arc, } #[derive(Clone, Copy, Debug, PartialEq)] @@ -205,6 +206,9 @@ impl MarkdownPreviewView { ) -> Entity { cx.new(|cx| { let list_state = ListState::new(0, gpui::ListAlignment::Top, px(1000.)); + let latex_renderer = Arc::new(latex_render::LatexRenderer::new( + cx.background_executor().clone(), + )); let mut this = Self { selected_block: 0, @@ -217,6 +221,7 @@ impl MarkdownPreviewView { parsing_markdown_task: None, image_cache: RetainAllImageCache::new(cx), mode, + latex_renderer, }; this.set_editor(active_editor, window, cx); @@ -571,39 +576,35 @@ impl Render for MarkdownPreviewView { return div().into_any(); }; - let mut render_cx = - RenderContext::new(Some(this.workspace.clone()), window, cx) - .with_checkbox_clicked_callback(cx.listener( - move |this, e: &CheckboxClickedEvent, window, cx| { - if let Some(editor) = this - .active_editor - .as_ref() - .map(|s| s.editor.clone()) - { - editor.update(cx, |editor, cx| { - let task_marker = - if e.checked() { "[x]" } else { "[ ]" }; - - editor.edit( - [( - MultiBufferOffset( - e.source_range().start, - ) - ..MultiBufferOffset( - e.source_range().end, - ), - task_marker, - )], - cx, - ); - }); - this.parse_markdown_from_active_editor( - false, window, cx, - ); - cx.notify(); - } - }, - )); + let mut render_cx = RenderContext::new( + Some(this.workspace.clone()), + this.latex_renderer.clone(), + window, + cx, + ) + .with_checkbox_clicked_callback(cx.listener( + move |this, e: &CheckboxClickedEvent, window, cx| { + if let Some(editor) = + this.active_editor.as_ref().map(|s| s.editor.clone()) + { + editor.update(cx, |editor, cx| { + let task_marker = + if e.checked() { "[x]" } else { "[ ]" }; + + editor.edit( + [( + MultiBufferOffset(e.source_range().start) + ..MultiBufferOffset(e.source_range().end), + task_marker, + )], + cx, + ); + }); + this.parse_markdown_from_active_editor(false, window, cx); + cx.notify(); + } + }, + )); let block = contents.children.get(ix).unwrap(); let rendered_block = render_markdown_block(block, &mut render_cx); diff --git a/crates/markdown_preview/src/markdown_renderer.rs b/crates/markdown_preview/src/markdown_renderer.rs index 9bff5276bc7a11..a75d37d4054ac1 100644 --- a/crates/markdown_preview/src/markdown_renderer.rs +++ b/crates/markdown_preview/src/markdown_renderer.rs @@ -1,8 +1,8 @@ use crate::markdown_elements::{ HeadingLevel, Image, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown, ParsedMarkdownBlockQuote, ParsedMarkdownCodeBlock, ParsedMarkdownElement, - ParsedMarkdownHeading, ParsedMarkdownListItem, ParsedMarkdownListItemType, ParsedMarkdownTable, - ParsedMarkdownTableAlignment, ParsedMarkdownTableRow, + ParsedMarkdownHeading, ParsedMarkdownListItem, ParsedMarkdownListItemType, ParsedMarkdownMath, + ParsedMarkdownTable, ParsedMarkdownTableAlignment, ParsedMarkdownTableRow, }; use fs::normalize_path; use gpui::{ @@ -11,6 +11,7 @@ use gpui::{ ParentElement, Render, Resource, SharedString, Styled, StyledText, TextStyle, WeakEntity, Window, div, img, rems, }; +use latex_render::{LatexColor, LatexRenderer}; use settings::Settings; use std::{ ops::{Mul, Range}, @@ -21,6 +22,9 @@ use theme::{ActiveTheme, SyntaxTheme, ThemeSettings}; use ui::{CopyButton, LinkPreview, ToggleState, prelude::*, tooltip_container}; use workspace::{OpenOptions, OpenVisible, Workspace}; +/// Scale factor for display math relative to base font size. +const DISPLAY_MATH_SCALE: f64 = 1.3; + pub struct CheckboxClickedEvent { pub checked: bool, pub source_range: Range, @@ -58,11 +62,13 @@ pub struct RenderContext { indent: usize, checkbox_clicked_callback: Option, is_last_child: bool, + pub latex_renderer: Arc, } impl RenderContext { pub fn new( workspace: Option>, + latex_renderer: Arc, window: &mut Window, cx: &mut App, ) -> RenderContext { @@ -95,6 +101,7 @@ impl RenderContext { code_span_background_color: theme.colors().editor_document_highlight_read_background, checkbox_clicked_callback: None, is_last_child: false, + latex_renderer, } } @@ -160,10 +167,11 @@ impl RenderContext { pub fn render_parsed_markdown( parsed: &ParsedMarkdown, workspace: Option>, + latex_renderer: Arc, window: &mut Window, cx: &mut App, ) -> Div { - let mut cx = RenderContext::new(workspace, window, cx); + let mut cx = RenderContext::new(workspace, latex_renderer, window, cx); v_flex().gap_3().children( parsed @@ -183,6 +191,61 @@ pub fn render_markdown_block(block: &ParsedMarkdownElement, cx: &mut RenderConte CodeBlock(code_block) => render_markdown_code_block(code_block, cx), HorizontalRule(_) => render_markdown_rule(cx), Image(image) => render_markdown_image(image, cx), + Math(math) => render_markdown_math(math, cx), + } +} + +fn render_markdown_math(parsed: &ParsedMarkdownMath, cx: &mut RenderContext) -> AnyElement { + let renderer = cx.latex_renderer.clone(); + let source = parsed.contents.clone(); + let font_size = cx.buffer_text_style.font_size.to_pixels(cx.window_rem_size); + let base_size = f32::from(font_size) as f64; + + let size_val = if parsed.display { + base_size * DISPLAY_MATH_SCALE + } else { + base_size + }; + + let color_hsla = cx.text_color; + let color = if color_hsla.l < 0.5 { + LatexColor::BLACK + } else { + LatexColor::WHITE + }; + + log::debug!( + "Rendering math: source={:?}, display={}, size={}", + source.as_ref(), + parsed.display, + size_val + ); + + let result = renderer.render(source.as_ref(), size_val, color, parsed.display); + + let element_id = cx.next_id(&parsed.source_range); + + let content = match result { + Some(Ok((image, (width, height)))) => img(ImageSource::Render(image)) + .w(px(width as f32)) + .h(px(height as f32)) + .into_any_element(), + Some(Err(e)) => div() + .text_color(gpui::red()) + .child(format!("Math Error: {}", e)) + .into_any_element(), + None => div().into_any_element(), + }; + + if parsed.display { + div() + .id(element_id) + .py_2() + .child(content) + .into_any_element() + } else { + // somehow need to handle the fact that based on zoom level inline math can be aligned up or down + div().id(element_id).child(content).into_any_element() } } @@ -655,7 +718,10 @@ fn render_markdown_paragraph(parsed: &MarkdownParagraph, cx: &mut RenderContext) cx.with_common_p(div()) .children(render_markdown_text(parsed, cx)) .flex() - .flex_col() + // this ensures that inline latex stays on the same line as text + .flex_row() + .flex_wrap() + .items_center() .into_any_element() } @@ -760,6 +826,9 @@ fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext) MarkdownParagraphChunk::Image(image) => { any_element.push(render_markdown_image(image, cx)); } + MarkdownParagraphChunk::Math(math) => { + any_element.push(render_markdown_math(math, cx)); + } } } diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 5494019594474f..4dc8c78be38d8c 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -148,6 +148,7 @@ language_onboarding.workspace = true language_selector.workspace = true language_tools.workspace = true languages = { workspace = true, features = ["load-grammars"] } +latex_render.workspace = true line_ending_selector.workspace = true log.workspace = true markdown.workspace = true diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 68303c30f9b127..2a14b09d04a59d 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -101,7 +101,6 @@ use zed_actions::{ OpenAccountSettings, OpenBrowser, OpenDocs, OpenServerSettings, OpenSettingsFile, OpenZedUrl, Quit, }; - actions!( zed, [ @@ -1826,11 +1825,15 @@ fn show_markdown_app_notification( let primary_button_on_click = primary_button_on_click.clone(); cx.new(move |cx| { MessageNotification::new_from_builder(cx, move |window, cx| { + let latex_renderer = std::sync::Arc::new(latex_render::LatexRenderer::new( + cx.background_executor().clone(), + )); image_cache(retain_all("notification-cache")) .child(div().text_ui(cx).child( markdown_preview::markdown_renderer::render_parsed_markdown( &parsed_markdown.clone(), Some(workspace_handle.clone()), + latex_renderer, window, cx, ),