From d5ecc5a69e63eb9fd7842f0988b6c2c903526209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 11 Oct 2026 23:38:22 -0400 Subject: [PATCH] Markdown: display math through RaTeX, Mermaid through mermaid-rs-renderer, pictures from the owner's folder Latex typesets with RaTeX to outlined SVG drawn by gpui's SvgRenderer at the window's scale, loaded as an asset off the main thread. Diagram draws a mermaid block in Mermaid's theme for the mode. MarkdownRenderer::base names the folder pictures load from; data addresses decode, web ones stay unloaded. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 3 +- Cargo.lock | 242 ++++++++++- Cargo.toml | 7 + examples/gallery/pages/typography/values.rs | 2 +- src/documents/diagram.rs | 175 ++++++++ src/documents/mod.rs | 3 + src/documents/picture.rs | 272 ++++++++++++ src/documents/render.rs | 45 +- src/documents/tests.rs | 57 +++ src/i18n/builtin.rs | 4 + src/typography/math.rs | 445 +++++++------------- 11 files changed, 927 insertions(+), 328 deletions(-) create mode 100644 src/documents/diagram.rs create mode 100644 src/documents/picture.rs diff --git a/AGENTS.md b/AGENTS.md index 44df423..18d713c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,8 +13,9 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Web views: `wry` 0.57 (Apache-2.0 OR MIT, default features off, macOS only) lays a child WKWebView over the window. - Terminal: `alacritty_terminal` 0.26 (Apache-2.0, default features off, native only) for the grid, its parser and the pseudo-terminal; `futures` carries its events. `vte` 0.15 (Apache-2.0 OR MIT), the parser it re-exports, reads `AnsiText`'s codes alone, so colored output builds for wasm32. - Diffs: `similar` 3.2 (Apache-2.0) for line and word diffs and three-way merges; its `unicode` feature splits words at punctuation. -- Markdown: `markdown` 1.0.0 (markdown-rs, MIT) reads GitHub's flavor and math into a syntax tree with byte positions for `MarkdownRenderer`, `MarkdownEditor`, the highlights, block ranges and outline. `BlockData::from_markdown` and its export still read with `pulldown-cmark` 0.13 (MIT, default features off); it adds GitHub's alerts, a callout's form, and reads each field's inline Markdown from the source by offset, as written. +- Markdown: `markdown` 1.0.0 (markdown-rs, MIT) reads GitHub's flavor and math into a syntax tree with byte positions for `MarkdownRenderer`, `MarkdownEditor`, the highlights, block ranges and outline. `MarkdownRenderer` draws display math through `typography::Latex`, a ```` ```mermaid ```` block through `documents::Diagram`, and pictures from the owner's folder (`base`) or `data:` addresses (`base64` 0.22, MIT OR Apache-2.0); web pictures stay unloaded. Inline math shows as TeX: gpui text carries no inline pictures. `BlockData::from_markdown` and its export still read with `pulldown-cmark` 0.13 (MIT, default features off); it adds GitHub's alerts, a callout's form, and reads each field's inline Markdown from the source by offset, as written. - Pictures: `image` 0.25 (MIT OR Apache-2.0), the crate gpui decodes with, default features off; it turns decoded frames and rims map tiles. +- Math and diagrams: RaTeX 0.1.14 (`ratex-parser`, `ratex-layout`, `ratex-svg` with `embed-fonts`, `ratex-types`; MIT, KaTeX's fonts OFL) typesets `Latex` to SVG with outlined glyphs; `mermaid-rs-renderer` 0.3.1 (MIT, default features off) draws `Diagram`. Both go through gpui's `SvgRenderer` at the window's scale, loaded as gpui assets off the main thread; `usvg` 0.46, gpui's own, reads a diagram's size. A RaTeX or Mermaid panic on parsed input becomes the element's error. - Maps: Natural Earth's 110m countries (public domain). `assets/maps/countries.geojson`, trimmed by `scripts/countries.py` to an ISO code, a name and positions to 0.01°, backs `maps::WorldMap`; the gallery's tiles are cut from the same file, z0 to z3, by `scripts/tiles.py` with Pillow. - Web: on wasm32, `web-time` 1.1 (MIT OR Apache-2.0) gives the `Instant` gpui's executor returns, std's own natively; jiff takes `js`, rust-embed `debug-embed`, similar `wasm32_web_time`. The gallery's web entry takes `gpui_web`, `wasm-bindgen`, `js-sys` and `web-sys` as wasm-only dev-dependencies, and a Hebrew cut of Noto Sans Hebrew 2.003 (OFL, `examples/gallery/fonts`): gpui_web asks the browser for emoji and CJK alone. The `wasm-bindgen` CLI at Cargo.lock's version and binaryen's `wasm-opt` finish the bundle. - Gallery: `examples/gallery`. Website: `frontend/` (Vite 8.3, pnpm 11, TypeScript, React 19.3, TanStack Router 1.170 in code-based routes, Lenis 1.3), in serro.ai's language: heavy titles over thin faint text, hairlines, one accent, full-screen sections, isometric line miniatures on a canvas. Pages name a story by its title's first part (`src/data.ts`'s `name`); scroll bars stay hidden; layouts step at 1024 and 768. Each story runs live from the gallery built for wasm32 through gpui_web, in a same-origin iframe, or, where a browser cannot run it, as a native capture. The Docs tab is Markdown in `frontend/src/docs/`, read in its `ORDER` and rendered by `marked` 18 (MIT) inside `vite.config.ts`'s `docs` plugin, so a bad guide fails `pnpm build` and the browser gets HTML alone; a fenced block tagged `example=NAME` shows `examples/docs/NAME.rs`, an example `scripts/check.sh` compiles as `docs_NAME`, so every full Rust sample on the site builds. The Showcase tab lists apps built with Ely from the public repo `ZacharyZhang-NY/Ely-GPUI-Showcases` on git.zacharyzhang.com (Gitea): manifests, PNG previews and Markdown READMEs, checked by its `scripts/catalog.mjs` (`marked`, `pngjs`, `twitter-text`), its stars refreshed by its CI. The mark is the letter E in iso blocks, its middle arm in the accent: one geometry in `src/iso/mark.ts`, drawn by the engine the miniatures use, worn by the header in the page's iso colors and by the favicon. Below 768px the header shows the mark without its word, which stays the home link's name; below 414px it drops the sound switch. Geist and Geist Mono (OFL, `@fontsource-variable`), Reicon icons (MIT, based on Solar Icons, CC BY 4.0, credited in the footer), simple-icons' Gitea mark (CC0). It deploys to Cloudflare Workers static assets through `npx wrangler` (4.143). diff --git a/Cargo.lock b/Cargo.lock index 29338b9..13194e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,22 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + [[package]] name = "accesskit" version = "0.24.1" @@ -1632,6 +1648,7 @@ dependencies = [ "alacritty_terminal", "anyhow", "barcoders", + "base64", "block", "cocoa 0.26.0", "core-foundation 0.10.0", @@ -1650,9 +1667,14 @@ dependencies = [ "js-sys", "log", "markdown", + "mermaid-rs-renderer", "objc", "pulldown-cmark", "qrcode", + "ratex-layout", + "ratex-parser", + "ratex-svg", + "ratex-types", "raw-window-handle", "regex", "rqrr", @@ -1663,6 +1685,7 @@ dependencies = [ "similar", "smallvec", "unicode-segmentation", + "usvg", "vte", "wasm-bindgen", "web-sys", @@ -1857,6 +1880,17 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -3352,6 +3386,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json5" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "733a844dbd6fef128e98cb4487b887cb55454d92cd9994b1bafe004fabbe670c" +dependencies = [ + "serde", + "ucd-trie", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -3676,6 +3720,23 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mermaid-rs-renderer" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb5b469222ce6b9896edf0a969cf68464f5a028ef394fd6643f00bd943df117" +dependencies = [ + "anyhow", + "fontdb", + "json5", + "once_cell", + "regex", + "serde", + "serde_json", + "thiserror 2.0.21", + "ttf-parser", +] + [[package]] name = "metal" version = "0.33.0" @@ -4460,6 +4521,15 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + [[package]] name = "parking" version = "2.2.1" @@ -4542,6 +4612,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ + "phf_macros 0.11.3", "phf_shared 0.11.3", ] @@ -4551,7 +4622,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros", + "phf_macros 0.13.1", "phf_shared 0.13.1", "serde", ] @@ -4562,10 +4633,20 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator", + "phf_generator 0.13.1", "phf_shared 0.13.1", ] +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.8", +] + [[package]] name = "phf_generator" version = "0.13.1" @@ -4576,13 +4657,26 @@ dependencies = [ "phf_shared 0.13.1", ] +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "phf_macros" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", + "phf_generator 0.13.1", "phf_shared 0.13.1", "proc-macro2", "quote", @@ -5051,6 +5145,114 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" +[[package]] +name = "ratex-font" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a7b73531dabe934a3e725ed5c90d18dacf43778b7a208381390943a9a8c24db" +dependencies = [ + "phf 0.11.3", + "ratex-types", +] + +[[package]] +name = "ratex-font-loader" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6542b55c6273b2ca3eb66021da061dfc84bc8303b434e1332218b63728742b7e" +dependencies = [ + "ab_glyph", + "ratex-font", + "ratex-katex-fonts", + "ratex-types", + "ratex-unicode-font", +] + +[[package]] +name = "ratex-katex-fonts" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c915a17fd71c41374d1ec19651a26ca352e757e1342391c2cbc5b825dacaa64b" +dependencies = [ + "rust-embed", +] + +[[package]] +name = "ratex-layout" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1792738e2e36683cd20de742296a5233759565efc8cd03831064a0ccfb1fe0f7" +dependencies = [ + "ratex-font", + "ratex-parser", + "ratex-types", + "serde_json", +] + +[[package]] +name = "ratex-lexer" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f704add86d7bf4c4ecc7767f45ab38a44f1bbfa4b49725b2ae53c32e83b8a61e" +dependencies = [ + "ratex-types", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "ratex-parser" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "079ec2e7ac6bc76137a6de093a173d1f901139a51318589ab9ce17139c21a6f0" +dependencies = [ + "fancy-regex", + "ratex-font", + "ratex-lexer", + "ratex-types", + "regex", + "regex-lite", + "serde", + "serde_json", + "thiserror 1.0.69", + "unicode-normalization", +] + +[[package]] +name = "ratex-svg" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a0948b4d4926858332fe04471e861f54103e655a64f74cc1b3eac0c59bf67bb" +dependencies = [ + "ab_glyph", + "base64", + "ratex-font", + "ratex-font-loader", + "ratex-katex-fonts", + "ratex-types", + "ratex-unicode-font", +] + +[[package]] +name = "ratex-types" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cf0be3d050525f0f7d37af55a443d7648c222ccc617639950e3de1f8e6f9900" +dependencies = [ + "serde", +] + +[[package]] +name = "ratex-unicode-font" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c460464b7fba27343b2b391179ac6c86fcffac5116455158e2b08ce80866fd8" +dependencies = [ + "fontdb", + "system-fonts", + "ttf-parser", +] + [[package]] name = "rav1e" version = "0.8.1" @@ -5249,6 +5451,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.11" @@ -5946,7 +6154,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ - "phf_generator", + "phf_generator 0.13.1", "phf_shared 0.13.1", "proc-macro2", "quote", @@ -6162,6 +6370,17 @@ dependencies = [ "windows 0.57.0", ] +[[package]] +name = "system-fonts" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46ba78ea149d4bb0626dacad10aa789eedc83395889890a0e6e7ec69a4577442" +dependencies = [ + "fontdb", + "sys-locale", + "web-sys", +] + [[package]] name = "taffy" version = "0.13.0" @@ -6545,6 +6764,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uds_windows" version = "1.2.1" @@ -6604,6 +6829,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-properties" version = "0.1.3" diff --git a/Cargo.toml b/Cargo.toml index 2f3af8b..e9cbf6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ test-support = ["gpui/test-support", "gpui/leak-detection"] [dependencies] anyhow = "1" +base64 = "0.22" barcoders = { version = "2", default-features = false, features = ["std"] } emojis = "0.9" futures = "0.3" @@ -27,8 +28,13 @@ isolang = { version = "2.4", features = ["local_names", "list_languages"] } jiff = "0.2" log = "0.4" markdown = "=1.0.0" +mermaid-rs-renderer = { version = "=0.3.1", default-features = false } pulldown-cmark = { version = "0.13", default-features = false } qrcode = { version = "0.14", default-features = false } +ratex-layout = "=0.1.14" +ratex-parser = "=0.1.14" +ratex-svg = { version = "=0.1.14", features = ["embed-fonts"] } +ratex-types = "=0.1.14" regex = "1" rqrr = { version = "0.11", default-features = false } rust-embed = "8.12" @@ -37,6 +43,7 @@ serde_json = { version = "1", features = ["preserve_order"] } similar = { version = "3.2", default-features = false, features = ["std", "text", "inline", "unicode"] } smallvec = "1" unicode-segmentation = "1" +usvg = "0.46" vte = { version = "0.15", default-features = false, features = ["std", "ansi"] } web-time = "1.1" diff --git a/examples/gallery/pages/typography/values.rs b/examples/gallery/pages/typography/values.rs index 3d1f2bf..3860b4f 100644 --- a/examples/gallery/pages/typography/values.rs +++ b/examples/gallery/pages/typography/values.rs @@ -204,7 +204,7 @@ pub fn latex(cx: &App) -> impl IntoElement + use<> { let math = |source: &str| Latex::parse(source).expect("gallery formula parses"); section( "Latex / MathInline", - "A TeX subset: scripts, fractions, roots, Greek.", + "TeX typeset by RaTeX, in the text's color.", cx, ) .child( diff --git a/src/documents/diagram.rs b/src/documents/diagram.rs new file mode 100644 index 0000000..feca4ed --- /dev/null +++ b/src/documents/diagram.rs @@ -0,0 +1,175 @@ +use std::{ + panic::{AssertUnwindSafe, catch_unwind}, + sync::Arc, +}; + +use gpui::{ + App, Asset, Hsla, IntoElement, ParentElement, Pixels, RenderImage, RenderOnce, SharedString, + Size, Styled, Window, div, img, px, size, +}; +use mermaid_rs_renderer::{LayoutConfig, RenderOptions, Theme, render_strict}; + +use crate::theme::{ActiveTheme, Radius, TextSize}; + +/// A Mermaid diagram; a failure shows why and the source. +#[derive(IntoElement)] +pub struct Diagram { + source: SharedString, +} + +impl Diagram { + pub fn new(source: impl Into) -> Self { + Self { + source: source.into(), + } + } +} + +/// What a drawn diagram depends on. +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +struct Chart { + source: SharedString, + dark: bool, + /// Background and text, as `#rrggbb`. + ground: SharedString, + ink: SharedString, + /// Window scale, in hundredths. + scale: u32, +} + +type Drawn = Result<(Arc, Size), SharedString>; + +enum Drawer {} + +impl Asset for Drawer { + type Source = Chart; + type Output = Drawn; + + fn load(chart: Chart, cx: &mut App) -> impl Future + Send + 'static { + let renderer = cx.svg_renderer(); + async move { + let svg = svg(&chart)?; + let tree = usvg::Tree::from_data(svg.as_bytes(), &usvg::Options::default()) + .map_err(|error| SharedString::from(format!("{error}")))?; + let shape = size(px(tree.size().width()), px(tree.size().height())); + let picture = renderer + .render_single_frame(svg.as_bytes(), chart.scale as f32 / 100.0) + .map_err(|error| SharedString::from(format!("{error}")))?; + Ok((picture, shape)) + } + } +} + +/// The diagram as SVG in Mermaid's theme for the mode. +fn svg(chart: &Chart) -> Result { + let mut theme = match chart.dark { + true => Theme::dark(), + false => Theme::modern(), + }; + theme.background = chart.ground.to_string(); + theme.edge_label_background = chart.ground.to_string(); + theme.text_color = chart.ink.to_string(); + let options = RenderOptions { + theme, + layout: LayoutConfig::default(), + }; + // The renderer may panic on input it parsed. + let drawn = catch_unwind(AssertUnwindSafe(|| render_strict(&chart.source, options))); + match drawn { + Ok(Ok(svg)) => Ok(svg), + Ok(Err(error)) => Err(error.to_string().into()), + Err(_) => { + log::error!("diagram: the renderer failed on a parsed diagram"); + Err("the Mermaid renderer could not draw this diagram".into()) + } + } +} + +/// `#rrggbb` of a theme color. +fn hex(color: Hsla) -> SharedString { + let rgb = color.to_rgb(); + let channel = |value: f32| (value.clamp(0.0, 1.0) * 255.0).round() as u8; + format!( + "#{:02x}{:02x}{:02x}", + channel(rgb.r), + channel(rgb.g), + channel(rgb.b) + ) + .into() +} + +impl RenderOnce for Diagram { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let chart = Chart { + source: self.source.clone(), + dark: theme.is_dark(), + ground: hex(colors.surface), + ink: hex(colors.fg), + scale: (window.scale_factor() * 100.0).round() as u32, + }; + let (radius, small, mono) = ( + theme.radius(Radius::Md), + theme.text_size(TextSize::Sm), + theme.mono_family.clone(), + ); + match window.use_asset::(&chart, cx) { + Some(Ok((picture, shape))) => div() + .flex() + .justify_center() + .child(img(picture).w(shape.width).h(shape.height).max_w_full()) + .into_any_element(), + Some(Err(why)) => div() + .flex() + .flex_col() + .gap_1() + .p_3() + .rounded(radius) + .bg(colors.sunken) + .text_size(small) + .child(div().text_color(colors.danger).child(why)) + .child( + div() + .font_family(mono) + .text_color(colors.fg_muted) + .child(self.source), + ) + .into_any_element(), + None => div().h_12().into_any_element(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn chart(source: &str, dark: bool) -> Chart { + Chart { + source: source.to_string().into(), + dark, + ground: "#2c2c2c".into(), + ink: "#eeeeee".into(), + scale: 100, + } + } + + #[test] + fn a_flowchart_draws_with_the_ground_and_ink() { + let svg = svg(&chart("flowchart LR\n A --> B", true)).expect("draws"); + assert!(svg.contains("", false)).expect_err("an error"); + assert!(!why.is_empty()); + } + + #[test] + fn colors_write_as_hex() { + assert_eq!(hex(gpui::rgb(0x2c2c2c).into()), "#2c2c2c"); + } +} diff --git a/src/documents/mod.rs b/src/documents/mod.rs index 1f8f8df..f6958ce 100644 --- a/src/documents/mod.rs +++ b/src/documents/mod.rs @@ -1,5 +1,6 @@ mod blocks; mod counts; +mod diagram; mod epub; mod find; mod format; @@ -9,6 +10,7 @@ mod link; mod markdown; mod modes; mod pages; +mod picture; mod print; mod reading; mod render; @@ -28,6 +30,7 @@ use gpui::App; pub(crate) use blocks::source; pub use blocks::{Align, BlockData, BlockEditor, BlockEvent, BlockKind, Media}; pub use counts::{ReadingTime, WordCount}; +pub use diagram::Diagram; pub use epub::{Chapter, EpubReader}; pub use format::{Format, format}; pub(crate) use history::Change; diff --git a/src/documents/picture.rs b/src/documents/picture.rs new file mode 100644 index 0000000..799e8e0 --- /dev/null +++ b/src/documents/picture.rs @@ -0,0 +1,272 @@ +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; + +use base64::Engine as _; +use gpui::{ + AnyElement, App, Asset, Hsla, Image, ImageAssetLoader, ImageFormat, InteractiveElement, + IntoElement, ParentElement, RenderImage, RenderOnce, Resource, SMOOTH_SVG_SCALE_FACTOR, + SharedString, Styled, Window, div, img, px, +}; + +use crate::{ + i18n, + primitives::{Icon, IconName}, + theme::{ActiveTheme, IconSize, Radius}, +}; + +/// Where a picture's bytes come from. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Origin { + File(PathBuf), + /// Bytes inline in the address, and their type. + Data(ImageFormat, Arc<[u8]>), + Web, + /// A relative path with no folder to read it in. + Loose, + /// An address that names no picture, and why. + Bad(SharedString), +} + +/// `%XX` decoded; a broken escape stays as written. +fn decoded(text: &str) -> Vec { + let bytes = text.as_bytes(); + let (mut out, mut at) = (Vec::with_capacity(bytes.len()), 0); + while at < bytes.len() { + let pair = bytes.get(at + 1..at + 3); + let hex = + pair.and_then(|pair| u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()); + match (bytes[at], hex) { + (b'%', Some(byte)) => (out.push(byte), at += 3), + (byte, _) => (out.push(byte), at += 1), + }; + } + out +} + +/// A path written in a link, `%XX` decoded. +fn path(written: &str) -> Result { + String::from_utf8(decoded(written)) + .map(PathBuf::from) + .map_err(|_| "the path is not UTF-8".into()) +} + +/// Where `url` points, read from the folder `base`. +pub(crate) fn origin(url: &str, base: Option<&Path>) -> Origin { + let url = url.trim(); + let lower = url.to_ascii_lowercase(); + if lower.starts_with("http://") || lower.starts_with("https://") || url.starts_with("//") { + return Origin::Web; + } + if lower.starts_with("data:") { + return inline(url); + } + if let Some(rest) = url.strip_prefix("file://") { + let rest = rest.strip_prefix("localhost").unwrap_or(rest); + // Windows writes file:///C:/… + let rest = match rest.as_bytes() { + [b'/', _, b':', ..] if cfg!(windows) => &rest[1..], + _ => rest, + }; + return path(rest).map_or_else(Origin::Bad, Origin::File); + } + let scheme = url.split_once(':').is_some_and(|(scheme, _)| { + scheme.len() > 1 + && scheme + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || "+-.".contains(ch)) + }); + if scheme { + return Origin::Bad(format!("{url} is no picture address").into()); + } + let written = url.split(['?', '#']).next().unwrap_or_default(); + match path(written) { + Ok(path) if path.is_absolute() => Origin::File(path), + Ok(path) => base.map_or(Origin::Loose, |base| Origin::File(base.join(path))), + Err(why) => Origin::Bad(why), + } +} + +/// A `data:` address: base64 bytes of an image type. +fn inline(url: &str) -> Origin { + let Some((head, payload)) = url[5..].split_once(',') else { + return Origin::Bad("the data address has no comma".into()); + }; + let Some(mime) = head.strip_suffix(";base64") else { + return Origin::Bad("only base64 data addresses draw".into()); + }; + let Some(format) = ImageFormat::from_mime_type(&mime.to_ascii_lowercase()) else { + return Origin::Bad(format!("{mime} is no image type").into()); + }; + match base64::engine::general_purpose::STANDARD.decode(payload.trim()) { + Ok(bytes) => Origin::Data(format, bytes.into()), + Err(error) => Origin::Bad(format!("{error}").into()), + } +} + +/// Inline bytes decoded as gpui decodes files. +enum Unpacker {} + +impl Asset for Unpacker { + type Source = (ImageFormat, Arc<[u8]>); + type Output = Result, SharedString>; + + fn load( + (format, bytes): Self::Source, + cx: &mut App, + ) -> impl Future + Send + 'static { + let renderer = cx.svg_renderer(); + async move { + Image::from_bytes(format, bytes.to_vec()) + .to_image_data(renderer) + .map_err(|error| format!("{error:#}").into()) + } + } +} + +/// The picture at `url`, natural size up to the column. +#[derive(IntoElement)] +pub(crate) struct Picture { + pub alt: String, + pub url: String, + /// The folder relative addresses start from. + pub base: Option>, +} + +impl RenderOnce for Picture { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + picture(&self.alt, &self.url, self.base.as_deref(), window, cx) + } +} + +fn picture( + alt: &str, + url: &str, + base: Option<&Path>, + window: &mut Window, + cx: &mut App, +) -> AnyElement { + let colors = cx.theme().colors.clone(); + let (loaded, svg) = match origin(url, base) { + Origin::File(path) => { + let svg = path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("svg")); + let resource = Resource::Path(path.into()); + let loaded = window + .use_asset::(&resource, cx) + .map(|loaded| loaded.map_err(|error| SharedString::from(error.to_string()))); + (loaded, svg) + } + Origin::Data(format, bytes) => { + let svg = format == ImageFormat::Svg; + (window.use_asset::(&(format, bytes), cx), svg) + } + Origin::Web => { + let said = i18n::text(cx, "markdown.picture.web", &[]); + return note(alt, said, colors.fg_subtle, cx); + } + Origin::Loose => { + let said = i18n::text(cx, "markdown.picture.folder", &[]); + return note(alt, said, colors.fg_subtle, cx); + } + Origin::Bad(why) => return note(alt, why, colors.danger, cx), + }; + match loaded { + Some(Ok(image)) => { + let natural = image.size(0); + // The picture's own height follows its ratio. + let smooth = if svg { SMOOTH_SVG_SCALE_FACTOR } else { 1.0 }; + let width = i32::from(natural.width) as f32 / smooth; + div() + .debug_selector(|| "markdown-picture".into()) + .w_full() + .max_w(px(width)) + .child(img(image).size_full()) + .into_any_element() + } + Some(Err(why)) => { + log::warn!("markdown: a picture did not load: {why}"); + note(alt, why, colors.danger, cx) + } + None => note(alt, url.to_string().into(), colors.fg_subtle, cx), + } +} + +/// A picture not drawn: its alt text and why. +fn note(alt: &str, said: SharedString, tone: Hsla, cx: &App) -> AnyElement { + let theme = cx.theme(); + let colors = &theme.colors; + div() + .flex() + .items_center() + .gap_2() + .p_3() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(colors.border) + .text_color(colors.fg_muted) + .child( + Icon::new(IconName::Image) + .size(IconSize::Sm) + .color(colors.fg_subtle), + ) + .child(div().flex_1().child(alt.to_string())) + .child(div().text_color(tone).child(said)) + .into_any_element() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn addresses_resolve_against_the_folder() { + let base = Path::new("/notes"); + assert_eq!( + origin("assets/a%20b.png?x#y", Some(base)), + Origin::File(base.join("assets/a b.png")) + ); + assert_eq!(origin("assets/a.png", None), Origin::Loose); + assert_eq!(origin("https://e.x/a.png", Some(base)), Origin::Web); + assert_eq!(origin("//e.x/a.png", Some(base)), Origin::Web); + assert!(matches!(origin("mailto:a@b", Some(base)), Origin::Bad(_))); + let absolute = std::env::current_dir().expect("a dir").join("a.png"); + assert_eq!( + origin(&absolute.display().to_string(), Some(base)), + Origin::File(absolute) + ); + } + + #[test] + fn file_addresses_decode() { + assert_eq!( + origin("file:///tmp/a%23b.png", None), + Origin::File(PathBuf::from("/tmp/a#b.png")) + ); + if cfg!(windows) { + assert_eq!( + origin("file:///C:/notes/a.png", None), + Origin::File(PathBuf::from("C:/notes/a.png")) + ); + } + } + + #[test] + fn data_addresses_carry_their_bytes() { + assert_eq!( + origin("data:image/png;base64,cG5n", None), + Origin::Data(ImageFormat::Png, Arc::from(&b"png"[..])) + ); + assert!(matches!(origin("data:image/png,raw", None), Origin::Bad(_))); + assert!(matches!( + origin("data:text/plain;base64,eA==", None), + Origin::Bad(_) + )); + assert!(matches!( + origin("data:image/png;base64,***", None), + Origin::Bad(_) + )); + } +} diff --git a/src/documents/render.rs b/src/documents/render.rs index ac5b4d6..9418508 100644 --- a/src/documents/render.rs +++ b/src/documents/render.rs @@ -1,4 +1,4 @@ -use std::rc::Rc; +use std::{path::Path, rc::Rc, sync::Arc}; use gpui::{ AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText, @@ -7,7 +7,11 @@ use gpui::{ }; use markdown::mdast::AlignKind; -use super::tree::{Inline, Node, Style, parse}; +use super::{ + diagram::Diagram, + picture::Picture, + tree::{Inline, Node, Style, parse}, +}; use crate::{ editor::code_colors, navigation::{Anchor, Sections}, @@ -28,6 +32,7 @@ pub struct MarkdownRenderer { size: TextSize, sections: Option>, code: Option, + base: Option>, } impl MarkdownRenderer { @@ -39,9 +44,16 @@ impl MarkdownRenderer { size: TextSize::Base, sections: None, code: None, + base: None, } } + /// The folder relative picture addresses start from. + pub fn base(mut self, folder: impl Into>) -> Self { + self.base = Some(folder.into()); + self + } + /// The body's size; headings keep theirs. pub fn text_size(mut self, size: TextSize) -> Self { self.size = size; @@ -78,6 +90,7 @@ struct Draw<'a> { sections: &'a Option>, headings: std::cell::Cell, code: &'a Option, + base: &'a Option>, } impl Draw<'_> { @@ -200,6 +213,9 @@ impl Draw<'_> { .children(self.nodes(inner, cx)) .into_any_element(), Node::Code { language, text } => { + if language.as_deref() == Some("mermaid") { + return Diagram::new(text.clone()).into_any_element(); + } if let Some(draw) = self.code { return draw( self.key("code"), @@ -336,7 +352,7 @@ impl Draw<'_> { .justify_center() .py_2() .child(match Latex::parse(tex) { - Ok(math) => math.into_any_element(), + Ok(math) => math.display().into_any_element(), Err(error) => div() .text_color(colors.danger) .child(error.to_string()) @@ -344,23 +360,11 @@ impl Draw<'_> { }) .into_any_element(), Node::Image { alt, url, link } => { - let picture = div() - .id(self.key("image")) - .flex() - .items_center() - .gap_2() - .p_3() - .rounded(theme.radius(Radius::Md)) - .border_1() - .border_color(colors.border) - .text_color(colors.fg_muted) - .child( - Icon::new(IconName::Image) - .size(IconSize::Sm) - .color(colors.fg_subtle), - ) - .child(div().flex_1().child(alt.clone())) - .child(div().text_color(colors.fg_subtle).child(url.clone())); + let picture = div().id(self.key("image")).child(Picture { + alt: alt.clone(), + url: url.clone(), + base: self.base.clone(), + }); let Some(link) = link.clone() else { return picture.into_any_element(); }; @@ -443,6 +447,7 @@ impl RenderOnce for MarkdownRenderer { sections: &self.sections, headings: std::cell::Cell::new(0), code: &self.code, + base: &self.base, }; div() .flex() diff --git a/src/documents/tests.rs b/src/documents/tests.rs index ecf9368..f5e88e8 100644 --- a/src/documents/tests.rs +++ b/src/documents/tests.rs @@ -301,3 +301,60 @@ fn a_selected_version_past_the_history_selects_none(cx: &mut TestAppContext) { }); settle(cx); } + +/// Markdown drawn `width` points wide, its pictures read in `base`. +struct Page { + source: &'static str, + base: std::path::PathBuf, + width: f32, +} + +impl Render for Page { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .w(px(self.width)) + .child(super::MarkdownRenderer::new("page", self.source).base(self.base.as_path())) + } +} + +/// A 200×100 picture drawn in a `width` column. +fn drawn_picture(width: f32, cx: &mut TestAppContext) -> gpui::Size { + let folder = std::env::temp_dir().join(format!("ely-picture-{}-{width}", std::process::id())); + std::fs::create_dir_all(folder.join("assets")).expect("a folder"); + let mut png = Vec::new(); + image::RgbaImage::new(200, 100) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .expect("a png"); + std::fs::write(folder.join("assets/wide shot.png"), png).expect("the picture"); + cx.update(Theme::init); + let (_, cx) = cx.add_window_view(|_, _| Page { + source: "Before + +![shot](assets/wide%20shot.png) +", + base: folder.clone(), + width, + }); + let mut drawn = None; + // The decode runs on the background executor. + for _ in 0..50 { + settle(cx); + drawn = cx.debug_bounds("markdown-picture"); + if drawn.is_some() { + break; + } + } + std::fs::remove_dir_all(&folder).expect("cleaned"); + drawn.expect("the picture drew").size +} + +#[gpui::test] +fn a_picture_beside_the_file_draws_at_its_ratio_within_the_column(cx: &mut TestAppContext) { + let shrunk = drawn_picture(120.0, cx); + assert_eq!(shrunk, size(px(120.0), px(60.0)), "shrunk at 2:1"); +} + +#[gpui::test] +fn a_picture_narrower_than_the_column_keeps_its_size(cx: &mut TestAppContext) { + assert_eq!(drawn_picture(400.0, cx), size(px(200.0), px(100.0))); +} diff --git a/src/i18n/builtin.rs b/src/i18n/builtin.rs index a81ab97..5ac5760 100644 --- a/src/i18n/builtin.rs +++ b/src/i18n/builtin.rs @@ -40,6 +40,8 @@ const EN: &[(&str, &str)] = &[ ("templates.list", "Templates"), ("templates.use", "Use template"), ("templates.none", "No template matches"), + ("markdown.picture.web", "Not loaded from the web"), + ("markdown.picture.folder", "No folder to find it in"), ("comments.reply", "Reply"), ("update.title", "Software Update"), ("update.available", "Version {version} is available."), @@ -151,6 +153,8 @@ const ZH_CN: &[(&str, &str)] = &[ ("templates.list", "模板"), ("templates.use", "使用模板"), ("templates.none", "没有匹配的模板"), + ("markdown.picture.web", "未从网络载入"), + ("markdown.picture.folder", "没有可找的文件夹"), ("comments.reply", "回复"), ("update.title", "软件更新"), ("update.available", "{version} 版可以更新。"), diff --git a/src/typography/math.rs b/src/typography/math.rs index 4c86d34..16b82de 100644 --- a/src/typography/math.rs +++ b/src/typography/math.rs @@ -1,282 +1,46 @@ -use std::fmt; +use std::{ + fmt, + panic::{AssertUnwindSafe, catch_unwind}, + sync::Arc, +}; use gpui::{ - AnyElement, App, IntoElement, ParentElement, Rems, RenderOnce, Styled, Window, div, prelude::*, + App, Asset, IntoElement, ParentElement, Pixels, RenderImage, RenderOnce, SharedString, Size, + Styled, Window, div, img, px, size, }; +use ratex_layout::{LayoutOptions, layout, to_display_list}; +use ratex_svg::{SvgOptions, render_to_svg}; +use ratex_types::{color::Color, math_style::MathStyle}; use crate::theme::{ActiveTheme, TextSize}; -#[derive(Clone, Debug, PartialEq)] -enum Node { - Var(char), - Sym(String), - Op(String), - Word(String), - Row(Vec), - Script { - base: Box, - sup: Option>, - sub: Option>, - }, - Frac(Box, Box), - Sqrt(Box), -} - -#[derive(Debug, PartialEq, Eq)] -pub enum MathError { - Unknown(String), - Unbalanced, - MissingArgument(&'static str), -} +/// Why a formula did not typeset: RaTeX's own words. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MathError(SharedString); impl fmt::Display for MathError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - MathError::Unknown(name) => write!(f, "unknown command \\{name}"), - MathError::Unbalanced => write!(f, "unbalanced braces"), - MathError::MissingArgument(name) => write!(f, "\\{name} is missing an argument"), - } + f.write_str(&self.0) } } impl std::error::Error for MathError {} -fn symbol(name: &str) -> Option { - let greek = "alpha α beta β gamma γ delta δ epsilon ε zeta ζ eta η theta θ iota ι kappa κ \ - lambda λ mu μ nu ν xi ξ pi π rho ρ sigma σ tau τ upsilon υ phi φ chi χ psi ψ omega ω"; - let upper = "Gamma Γ Delta Δ Theta Θ Lambda Λ Xi Ξ Pi Π Sigma Σ Phi Φ Psi Ψ Omega Ω"; - let ops = "pm ± mp ∓ times × cdot · div ÷ leq ≤ geq ≥ neq ≠ approx ≈ equiv ≡ to → \ - rightarrow → leftarrow ← in ∈ subset ⊂ cup ∪ cap ∩"; - let plain = "infty ∞ partial ∂ nabla ∇ sum ∑ prod ∏ int ∫ forall ∀ exists ∃ ldots … cdots ⋯"; - let find = |table: &str| { - let words: Vec<&str> = table.split_whitespace().collect(); - words - .chunks(2) - .find(|pair| pair[0] == name) - .map(|pair| pair[1].to_string()) - }; - if let Some(glyph) = find(greek) { - return glyph.chars().next().map(Node::Var); - } - if let Some(glyph) = find(ops) { - return Some(Node::Op(glyph)); - } - find(upper).or_else(|| find(plain)).map(Node::Sym) -} - -const FUNCTIONS: [&str; 11] = [ - "sin", "cos", "tan", "log", "ln", "exp", "lim", "max", "min", "det", "gcd", -]; - -struct Parser { - chars: Vec, - at: usize, -} - -impl Parser { - fn peek(&self) -> Option { - self.chars.get(self.at).copied() - } - - fn bump(&mut self) -> Option { - let next = self.peek(); - self.at += 1; - next - } - - fn row(&mut self, closed: bool) -> Result, MathError> { - let mut row = Vec::new(); - loop { - match self.peek() { - None if closed => return Err(MathError::Unbalanced), - None => return Ok(row), - Some('}') if closed => { - self.bump(); - return Ok(row); - } - Some('}') => return Err(MathError::Unbalanced), - Some(ch) if ch.is_whitespace() => { - self.bump(); - } - Some(mark @ ('^' | '_')) => { - self.bump(); - let script = Box::new(self.argument(if mark == '^' { "^" } else { "_" })?); - let base = row.pop().unwrap_or(Node::Sym(String::new())); - row.push(attach(base, mark == '^', script)); - } - Some(_) => { - let atom = self.atom()?; - row.push(atom); - } - } - } - } - - fn atom(&mut self) -> Result { - match self.bump() { - Some('{') => Ok(Node::Row(self.row(true)?)), - Some('\\') => self.command(), - Some('-') => Ok(Node::Op("−".into())), - Some(ch @ ('+' | '=' | '<' | '>')) => Ok(Node::Op(ch.to_string())), - Some(ch) if ch.is_alphabetic() => Ok(Node::Var(ch)), - Some(ch) => Ok(Node::Sym(ch.to_string())), - None => Err(MathError::Unbalanced), - } - } - - fn argument(&mut self, name: &'static str) -> Result { - while self.peek().is_some_and(char::is_whitespace) { - self.bump(); - } - match self.peek() { - None | Some('}') => Err(MathError::MissingArgument(name)), - Some(_) => self.atom(), - } - } - - /// `{...}` read as plain text, spaces kept. - fn raw_group(&mut self) -> Result { - while self.peek().is_some_and(char::is_whitespace) { - self.bump(); - } - if self.bump() != Some('{') { - return Err(MathError::MissingArgument("text")); - } - let mut depth = 1; - let mut text = String::new(); - loop { - match self.bump().ok_or(MathError::Unbalanced)? { - '{' => depth += 1, - '}' if depth == 1 => return Ok(text), - '}' => depth -= 1, - _ => {} - } - text.push(self.chars[self.at - 1]); - } - } - - fn command(&mut self) -> Result { - let start = self.at; - while self.peek().is_some_and(|ch| ch.is_ascii_alphabetic()) { - self.bump(); - } - let name: String = self.chars[start..self.at].iter().collect(); - if name.is_empty() { - let escaped = self.bump().ok_or(MathError::Unbalanced)?; - return Ok(Node::Sym(if escaped == ',' { - " ".into() - } else { - escaped.to_string() - })); - } - match name.as_str() { - "frac" => { - let top = self.argument("frac")?; - let bottom = self.argument("frac")?; - Ok(Node::Frac(Box::new(top), Box::new(bottom))) - } - "sqrt" => Ok(Node::Sqrt(Box::new(self.argument("sqrt")?))), - "text" => Ok(Node::Word(self.raw_group()?)), - "left" | "right" => self.argument("left"), - name if FUNCTIONS.contains(&name) => Ok(Node::Word(name.to_string())), - name => symbol(name).ok_or_else(|| MathError::Unknown(name.to_string())), - } - } -} - -fn attach(base: Node, upper: bool, script: Box) -> Node { - match base { - Node::Script { base, sup, sub } if upper && sup.is_none() => Node::Script { - base, - sup: Some(script), - sub, - }, - Node::Script { base, sup, sub } if !upper && sub.is_none() => Node::Script { - base, - sup, - sub: Some(script), - }, - base => { - let (sup, sub) = if upper { - (Some(script), None) - } else { - (None, Some(script)) - }; - Node::Script { - base: Box::new(base), - sup, - sub, - } - } - } -} - -fn draw(node: &Node, size: Rems, cx: &App) -> AnyElement { - let rule = cx.theme().colors.fg; - let script = size * 0.72; - let text = |content: String| div().text_size(size).child(content); - match node { - Node::Var(ch) => text(ch.to_string()).italic().into_any_element(), - Node::Sym(content) | Node::Word(content) => text(content.clone()).into_any_element(), - Node::Op(content) => text(content.clone()).px(size * 0.22).into_any_element(), - Node::Row(nodes) => div() - .flex() - .items_center() - .children(nodes.iter().map(|node| draw(node, size, cx))) - .into_any_element(), - Node::Script { base, sup, sub } => div() - .flex() - .items_center() - .child(draw(base, size, cx)) - .child( - div() - .flex() - .flex_col() - .when(sub.is_none(), |col| col.mb(size * 0.55)) - .when(sup.is_none(), |col| col.mt(size * 0.45)) - .children(sup.iter().map(|node| draw(node, script, cx))) - .children(sub.iter().map(|node| draw(node, script, cx))), - ) - .into_any_element(), - Node::Frac(top, bottom) => div() - .flex() - .flex_col() - .items_center() - .px(size * 0.12) - .child(draw(top, size * 0.85, cx)) - .child(div().w_full().border_t_1().border_color(rule)) - .child(draw(bottom, size * 0.85, cx)) - .into_any_element(), - Node::Sqrt(body) => div() - .flex() - .items_center() - .child(text("√".into())) - .child( - div() - .border_t_1() - .border_color(rule) - .child(draw(body, size, cx)), - ) - .into_any_element(), - } -} - -/// Inline math from a TeX subset: scripts, `\frac`, `\sqrt`, Greek, operators. +/// TeX typeset by RaTeX, in the text's color. #[derive(IntoElement)] pub struct Latex { - root: Node, + source: SharedString, size: TextSize, + display: bool, } impl Latex { pub fn parse(source: &str) -> Result { - let mut parser = Parser { - chars: source.chars().collect(), - at: 0, - }; + ratex_parser::parse(source).map_err(|error| MathError(error.message.into()))?; Ok(Self { - root: Node::Row(parser.row(false)?), + source: source.to_string().into(), size: TextSize::Lg, + display: false, }) } @@ -284,15 +48,107 @@ impl Latex { self.size = size; self } + + /// Display style: limits above and below, larger operators. + pub fn display(mut self) -> Self { + self.display = true; + self + } +} + +/// What a typeset picture depends on. +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +struct Formula { + source: SharedString, + display: bool, + rgba: u32, + /// Font size and window scale, in hundredths. + font: u32, + scale: u32, +} + +/// A typeset formula: its picture and logical size. +type Typeset = Result<(Arc, Size), MathError>; + +enum Typesetter {} + +impl Asset for Typesetter { + type Source = Formula; + type Output = Typeset; + + fn load(formula: Formula, cx: &mut App) -> impl Future + Send + 'static { + let renderer = cx.svg_renderer(); + async move { + let (svg, shape) = svg(&formula)?; + let picture = renderer + .render_single_frame(svg.as_bytes(), formula.scale as f32 / 100.0) + .map_err(|error| MathError(format!("{error}").into()))?; + Ok((picture, shape)) + } + } +} + +/// The formula as SVG, glyphs outlined, and its size. +fn svg(formula: &Formula) -> Result<(String, Size), MathError> { + let ast = + ratex_parser::parse(&formula.source).map_err(|error| MathError(error.message.into()))?; + let [r, g, b, a] = formula + .rgba + .to_be_bytes() + .map(|channel| f32::from(channel) / 255.0); + let options = LayoutOptions::default() + .with_style(match formula.display { + true => MathStyle::Display, + false => MathStyle::Text, + }) + .with_color(Color::new(r, g, b, a)); + let font = f64::from(formula.font) / 100.0; + // RaTeX may panic on input it parsed. + let laid = catch_unwind(AssertUnwindSafe(|| { + let list = to_display_list(&layout(&ast, &options)); + let svg = render_to_svg( + &list, + &SvgOptions { + font_size: font, + padding: 0.0, + stroke_width: (font / 26.0).max(0.5), + embed_glyphs: true, + font_dir: String::new(), + }, + ); + (list.width, list.height + list.depth, svg) + })); + let (width, height, svg) = laid.map_err(|_| { + log::error!("latex: RaTeX failed to lay out {:?}", formula.source); + MathError("RaTeX could not lay out this formula".into()) + })?; + let shape = size(px((width * font) as f32), px((height * font) as f32)); + Ok((svg, shape)) } impl RenderOnce for Latex { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); - let size = theme.text_size(self.size); - div() - .text_color(theme.colors.fg) - .child(draw(&self.root, size, cx)) + let (color, danger) = (theme.colors.fg, theme.colors.danger); + let font = theme.text_size(self.size).to_pixels(window.rem_size()); + let formula = Formula { + source: self.source.clone(), + display: self.display, + rgba: u32::from(color.to_rgb()), + font: (font.as_f32() * 100.0).round() as u32, + scale: (window.scale_factor() * 100.0).round() as u32, + }; + match window.use_asset::(&formula, cx) { + Some(Ok((picture, shape))) => img(picture) + .w(shape.width) + .h(shape.height) + .into_any_element(), + Some(Err(error)) => div() + .text_color(danger) + .child(error.to_string()) + .into_any_element(), + None => div().h(font).into_any_element(), + } } } @@ -300,55 +156,40 @@ impl RenderOnce for Latex { mod tests { use super::*; - fn parse(source: &str) -> Result { - Latex::parse(source).map(|latex| latex.root) + fn formula(source: &str, display: bool) -> Formula { + Formula { + source: source.to_string().into(), + display, + rgba: 0x112233ff, + font: 1600, + scale: 200, + } } #[test] - fn scripts_attach_to_the_previous_atom() { - let Node::Row(nodes) = parse("x_i^2 + y").unwrap() else { - panic!("row") - }; - assert_eq!(nodes.len(), 3); - let Node::Script { base, sup, sub } = &nodes[0] else { - panic!("script") - }; - assert_eq!(**base, Node::Var('x')); - assert_eq!(sup.as_deref(), Some(&Node::Sym("2".into()))); - assert_eq!(sub.as_deref(), Some(&Node::Var('i'))); - assert_eq!(nodes[1], Node::Op("+".into())); - } - - #[test] - fn commands_build_structure() { - let Node::Row(nodes) = parse(r"\frac{\alpha}{2} \sqrt{x} \sin").unwrap() else { - panic!("row") - }; + fn a_formula_typesets_to_outlined_svg_in_its_color() { + let (svg, shape) = svg(&formula(r"\frac{a}{b} + \sqrt{x}", false)).expect("typesets"); + assert!(svg.starts_with(" px(16.) && shape.height > px(16.), "{shape:?}"); } #[test] - fn text_keeps_its_spaces() { - let Node::Row(nodes) = parse(r"\text{hello world} x").unwrap() else { - panic!("row") + fn display_style_sets_limits_larger() { + let (_, text) = svg(&formula(r"\sum_{k=1}^{n} k", false)).expect("typesets"); + let (_, display) = svg(&formula(r"\sum_{k=1}^{n} k", true)).expect("typesets"); + assert!(display.height > text.height, "{display:?} over {text:?}"); + } + + #[test] + fn bad_tex_says_why() { + let Err(error) = Latex::parse(r"\frac{a}") else { + panic!("a missing argument parses") }; - assert_eq!(nodes[0], Node::Word("hello world".into())); - } - - #[test] - fn errors_name_the_fault() { - assert_eq!(parse(r"\foo"), Err(MathError::Unknown("foo".into()))); - assert_eq!(parse("{x"), Err(MathError::Unbalanced)); - assert_eq!(parse("x}"), Err(MathError::Unbalanced)); - assert_eq!(parse(r"\frac{a}"), Err(MathError::MissingArgument("frac"))); - assert_eq!( - parse(r"\mathrm{x}"), - Err(MathError::Unknown("mathrm".into())) - ); - assert_eq!(parse(r"\text{a"), Err(MathError::Unbalanced)); + assert!(!error.to_string().is_empty()); + assert!(Latex::parse(r"x^{2").is_err(), "an open brace"); } }