From 63f725e8d6b2bb5ad1651523b689d54b45349574 Mon Sep 17 00:00:00 2001 From: Cameron Mcloughlin Date: Wed, 27 May 2026 17:27:18 +0100 Subject: [PATCH] markdown: Merman (#57644) Big PR that replaces `mermaid-rs` with `merman`. Adds a new crate `mermaid_render` that exposes a simple API for rendering a mermaid diagram to an SVG string. ## Why is it so big? Some of this is explained in the crate-level docs for `mermaid_render`, but the short version is: - `mermaid-rs` emits "good enough" SVGs for most use cases. It also ships with a reasonable default theme - `merman` emits *very accurate* SVGs, but with borderline unusable CSS - Most of the new code in this PR are a series of passes to clean up the output SVG by: - Injecting good CSS - Fixing issues in the `merman`-generated SVGs - Tweaking the final result to avoid issues with `usvg` and `resvg`, which are what will eventually be used to rasterize the SVG - This code *could* be much smaller, but the following design decisions made it take a lot more code: - Using a real XML parser instead of basic string manipulation - Avoiding allocating strings in as many places as possible Because of this, the design is as follows: - First, construct a `merman` theme from the user's theme, and render the mermaid to an SVG string - Post-process - each step is roughly a `fn(Iterator>) -> Iterator>`, where `Event` is the type for events produced by `quick-xml` (a pull-based XML parser) ## Note for reviewers It's a big diff, sorry :sweat_smile: happy to pair review. The new crate is essentially a leaf crate - it does technically depend on `gpui`, but only for the `Hsla` and `Rgba` types. Extracting a new `gpui_color` crate felt like overkill for this already-very-big PR. Each post-process pass is in its own submodule, and has a doc comment explaining the before/after. Note that bugs in this code are perhaps less serious than bugs in other parts of the code: - The code has been thoroughly audited for potentially-panicking code paths - as far as I know, there are none (excluding some `.expect()`s on calls to `write!` with `String`, which is [cannot return `Err`][string write]) - A bug in this code (given that it will not cause a panic) will, at worst, result in an invalid diagram being rendered, or simply falling back to showing the code. - The current `mermaid-rs` renderer *already* does this in quite a lot of cases, sometimes showing outright misleading information. --- Some eye candy: | Before (`mermaid-rs`) | After (`merman`) | | - | - | | image | image | | image | image | | Failed to render | image | | image | image | | image | image | | image | image | | image | image | | image | image | | image | image | | image | image | | image | image | | image | image | | image | image | | image | image | Release Notes: - Improved: Mermaid diagrams now render faster and more accurately [string write]: https://doc.rust-lang.org/src/alloc/string.rs.html#3342-3354 --------- Co-authored-by: Danilo Leal Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com> --- Cargo.lock | 788 +++++++++++++++++- Cargo.toml | 3 +- crates/agent/src/templates/system_prompt.hbs | 8 +- crates/markdown/Cargo.toml | 2 +- crates/markdown/src/markdown.rs | 28 +- crates/markdown/src/mermaid.rs | 281 +++---- crates/mermaid_render/Cargo.toml | 27 + crates/mermaid_render/LICENSE-GPL | 1 + crates/mermaid_render/src/mermaid_render.rs | 181 ++++ crates/mermaid_render/src/postprocess.rs | 136 +++ .../src/postprocess/accent_colors.rs | 375 +++++++++ .../accent_colors/class_diagram.rs | 112 +++ .../src/postprocess/accent_colors/mindmap.rs | 127 +++ .../accent_colors/sequence_diagram.rs | 98 +++ .../src/postprocess/element_fixup.rs | 305 +++++++ .../src/postprocess/fallback_fixup.rs | 223 +++++ .../src/postprocess/foreignobject_wrap.rs | 96 +++ .../src/postprocess/inject_css.rs | 517 ++++++++++++ .../src/postprocess/strip_foreignobject.rs | 114 +++ .../src/postprocess/strip_invalid_css.rs | 161 ++++ crates/mermaid_render/src/postprocess/util.rs | 148 ++++ crates/mermaid_render/src/render.rs | 122 +++ .../tests/check_invalid_attrs.rs | 394 +++++++++ typos.toml | 6 +- 24 files changed, 4068 insertions(+), 185 deletions(-) create mode 100644 crates/mermaid_render/Cargo.toml create mode 120000 crates/mermaid_render/LICENSE-GPL create mode 100644 crates/mermaid_render/src/mermaid_render.rs create mode 100644 crates/mermaid_render/src/postprocess.rs create mode 100644 crates/mermaid_render/src/postprocess/accent_colors.rs create mode 100644 crates/mermaid_render/src/postprocess/accent_colors/class_diagram.rs create mode 100644 crates/mermaid_render/src/postprocess/accent_colors/mindmap.rs create mode 100644 crates/mermaid_render/src/postprocess/accent_colors/sequence_diagram.rs create mode 100644 crates/mermaid_render/src/postprocess/element_fixup.rs create mode 100644 crates/mermaid_render/src/postprocess/fallback_fixup.rs create mode 100644 crates/mermaid_render/src/postprocess/foreignobject_wrap.rs create mode 100644 crates/mermaid_render/src/postprocess/inject_css.rs create mode 100644 crates/mermaid_render/src/postprocess/strip_foreignobject.rs create mode 100644 crates/mermaid_render/src/postprocess/strip_invalid_css.rs create mode 100644 crates/mermaid_render/src/postprocess/util.rs create mode 100644 crates/mermaid_render/src/render.rs create mode 100644 crates/mermaid_render/tests/check_invalid_attrs.rs diff --git a/Cargo.lock b/Cargo.lock index 5514585ffc3..ccddd99f6b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -203,7 +203,7 @@ dependencies = [ "smallvec", "sqlez", "streaming_diff", - "strsim", + "strsim 0.11.1", "task", "telemetry", "tempfile", @@ -603,7 +603,7 @@ version = "4.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6" dependencies = [ - "cssparser", + "cssparser 0.35.0", "html5ever 0.35.0", "maplit", "tendril", @@ -716,6 +716,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "approx" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f2a05fd1bd10b2527e20a2cd32d8873d115b8b39fe219ee25f42a8aca6ba278" +dependencies = [ + "num-traits", +] + [[package]] name = "approx" version = "0.5.1" @@ -802,6 +811,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" +[[package]] +name = "ascii-canvas" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6" +dependencies = [ + "term", +] + [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -1987,6 +2005,12 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + [[package]] name = "bigdecimal" version = "0.4.8" @@ -2048,6 +2072,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -2066,6 +2099,12 @@ dependencies = [ "bit-vec 0.9.1", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" @@ -2804,6 +2843,16 @@ dependencies = [ "libc", ] +[[package]] +name = "cgmath" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a98d30140e3296250832bbaaff83b27dcd6fa3cc70fb6f1f3e5c9c0023b5317" +dependencies = [ + "approx 0.4.0", + "num-traits", +] + [[package]] name = "channel" version = "0.1.0" @@ -2932,7 +2981,7 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", "terminal_size", ] @@ -4354,6 +4403,19 @@ dependencies = [ "smallvec", ] +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + [[package]] name = "cssparser-macros" version = "0.6.1" @@ -4538,6 +4600,16 @@ dependencies = [ "util", ] +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + [[package]] name = "darling" version = "0.20.11" @@ -4568,6 +4640,20 @@ dependencies = [ "darling_macro 0.23.0", ] +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", +] + [[package]] name = "darling_core" version = "0.20.11" @@ -4578,7 +4664,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.117", ] @@ -4592,7 +4678,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.117", ] @@ -4605,10 +4691,21 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core 0.14.4", + "quote", + "syn 1.0.109", +] + [[package]] name = "darling_macro" version = "0.20.11" @@ -4876,6 +4973,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "derive_builder" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d67778784b508018359cbc8696edb3db78160bab2c2a28ba7f56ef6932997f8" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c11bdc11a0c47bc7d37d582b5285da6849c96681023680b906673c5707af7b0f" +dependencies = [ + "darling 0.14.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_macro" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcda35c7a396850a55ffeac740804b40ffec779b98fffbb1738f4033f0ee79e" +dependencies = [ + "derive_builder_core", + "syn 1.0.109", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -5035,6 +5163,16 @@ dependencies = [ "dirs-sys", ] +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + [[package]] name = "dirs-sys" version = "0.5.0" @@ -5043,10 +5181,21 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -5175,6 +5324,28 @@ dependencies = [ "dtoa", ] +[[package]] +name = "dugong" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f5b0a9f36306eb29685e6e27b82df4d0bb5af64261324f7d5f7716d7c39ba1b" +dependencies = [ + "dugong-graphlib", + "rustc-hash 2.1.1", + "serde", + "serde_json", +] + +[[package]] +name = "dugong-graphlib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75aca4df30a85b3ba8cead498f4e38e9de4aff630155ce47515d11dfd729c6ea" +dependencies = [ + "hashbrown 0.16.1", + "rustc-hash 2.1.1", +] + [[package]] name = "dunce" version = "1.0.5" @@ -5660,6 +5831,15 @@ dependencies = [ "phf 0.11.3", ] +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -7176,6 +7356,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -7474,6 +7663,114 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glam" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333928d5eb103c5d4050533cec0384302db6be8ef7d3cebd30ec6a35350353da" + +[[package]] +name = "glam" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3abb554f8ee44336b72d522e0a7fe86a29e09f839a36022fa869a7dfe941a54b" + +[[package]] +name = "glam" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4126c0479ccf7e8664c36a2d719f5f2c140fbb4f9090008098d2c291fa5b3f16" + +[[package]] +name = "glam" +version = "0.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01732b97afd8508eee3333a541b9f7610f454bb818669e66e90f5f57c93a776" + +[[package]] +name = "glam" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525a3e490ba77b8e326fb67d4b44b4bd2f920f44d4cc73ccec50adc68e3bee34" + +[[package]] +name = "glam" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8509e6791516e81c1a630d0bd7fbac36d2fa8712a9da8662e716b52d5051ca" + +[[package]] +name = "glam" +version = "0.20.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43e957e744be03f5801a55472f593d43fabdebf25a4585db250f04d86b1675f" + +[[package]] +name = "glam" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "518faa5064866338b013ff9b2350dc318e14cc4fcd6cb8206d7e7c9886c98815" + +[[package]] +name = "glam" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f597d56c1bd55a811a1be189459e8fad2bbc272616375602443bdfb37fa774" + +[[package]] +name = "glam" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e4afd9ad95555081e109fe1d21f2a30c691b5f0919c67dfa690a2e1eb6bd51c" + +[[package]] +name = "glam" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5418c17512bdf42730f9032c74e1ae39afc408745ebb2acf72fbc4691c17945" + +[[package]] +name = "glam" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" + +[[package]] +name = "glam" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e05e7e6723e3455f4818c7b26e855439f7546cf617ef669d1adedb8669e5cb9" + +[[package]] +name = "glam" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "779ae4bf7e8421cf91c0b3b64e7e8b40b862fba4d393f59150042de7c4965a94" + +[[package]] +name = "glam" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee" + +[[package]] +name = "glam" +version = "0.30.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9" + +[[package]] +name = "glam" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556f6b2ea90b8d15a74e0e7bb41671c9bdf38cd9f78c284d750b9ce58a2b5be7" + +[[package]] +name = "glam" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0" + [[package]] name = "glib" version = "0.21.5" @@ -8378,6 +8675,19 @@ dependencies = [ "regex", ] +[[package]] +name = "htmlize" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e815d50d9e411ba2690d730e6ec139c08260dddb756df315dbd16d01a587226" +dependencies = [ + "memchr", + "pastey 0.1.1", + "phf 0.13.1", + "phf_codegen 0.13.1", + "serde_json", +] + [[package]] name = "http" version = "0.2.12" @@ -9262,12 +9572,13 @@ dependencies = [ [[package]] name = "json5" -version = "1.3.1" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "733a844dbd6fef128e98cb4487b887cb55454d92cd9994b1bafe004fabbe670c" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" dependencies = [ + "pest", + "pest_derive", "serde", - "ucd-trie", ] [[package]] @@ -9471,6 +9782,15 @@ dependencies = [ "libc", ] +[[package]] +name = "kurbo" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd85a5776cd9500c2e2059c8c76c3b01528566b7fcbaf8098b55a33fc298849b" +dependencies = [ + "arrayvec", +] + [[package]] name = "kurbo" version = "0.11.3" @@ -9491,6 +9811,37 @@ dependencies = [ "log", ] +[[package]] +name = "lalrpop" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cb077ad656299f160924eb2912aa147d7339ea7d69e1b5517326fdcec3c1ca" +dependencies = [ + "ascii-canvas", + "bit-set 0.5.3", + "ena", + "itertools 0.11.0", + "lalrpop-util", + "petgraph", + "pico-args", + "regex", + "regex-syntax", + "string_cache", + "term", + "tiny-keccak", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" +dependencies = [ + "regex-automata", +] + [[package]] name = "language" version = "0.1.0" @@ -9530,7 +9881,7 @@ dependencies = [ "shellexpand", "smallvec", "streaming-iterator", - "strsim", + "strsim 0.11.1", "sum_tree", "task", "text", @@ -10275,6 +10626,58 @@ dependencies = [ "value-bag", ] +[[package]] +name = "logos" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7251356ef8cb7aec833ddf598c6cb24d17b689d20b993f9d11a3d764e34e6458" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59f80069600c0d66734f5ff52cc42f2dabd6b29d205f333d61fd7832e9e9963f" +dependencies = [ + "beef", + "fnv", + "lazy_static", + "proc-macro2", + "quote", + "regex-syntax", + "syn 2.0.117", +] + +[[package]] +name = "logos-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24fb722b06a9dc12adb0963ed585f19fc61dc5413e6a9be9422ef92c091e731d" +dependencies = [ + "logos-codegen", +] + +[[package]] +name = "lol_html" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6888e8653f6e49cb2924c660fc367a8beeb6239b71e117fa082153c6ea44d427" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cssparser 0.36.0", + "encoding_rs", + "foldhash 0.2.0", + "hashbrown 0.16.1", + "memchr", + "mime", + "precomputed-hash", + "selectors", + "thiserror 2.0.17", +] + [[package]] name = "loom" version = "0.7.2" @@ -10444,6 +10847,18 @@ dependencies = [ "libc", ] +[[package]] +name = "manatee" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5ed3cc0bf5f911d242bed4b4cdf12c00186e471e9fdf57d9dd0a033bbbdc87" +dependencies = [ + "indexmap 2.11.4", + "nalgebra", + "rustc-hash 2.1.1", + "thiserror 2.0.17", +] + [[package]] name = "maplit" version = "1.0.2" @@ -10469,7 +10884,7 @@ dependencies = [ "linkify", "log", "markup5ever_rcdom", - "mermaid-rs-renderer", + "mermaid_render", "node_runtime", "pulldown-cmark 0.13.0", "settings", @@ -10513,7 +10928,7 @@ checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" dependencies = [ "log", "phf 0.11.3", - "phf_codegen", + "phf_codegen 0.11.3", "string_cache", "string_cache_codegen", "tendril", @@ -10568,6 +10983,16 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "maybe-owned" version = "0.3.4" @@ -10708,19 +11133,78 @@ dependencies = [ ] [[package]] -name = "mermaid-rs-renderer" -version = "0.2.2" -source = "git+https://github.com/zed-industries/mermaid-rs-renderer?rev=782b89a7da3f0e91e51f98d00a93acba679be6fb#782b89a7da3f0e91e51f98d00a93acba679be6fb" +name = "mermaid_render" +version = "0.1.0" dependencies = [ "anyhow", - "fontdb", + "gpui", + "mermaid_render", + "merman", + "quick-xml 0.38.3", + "serde_json", +] + +[[package]] +name = "merman" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3209bcfe9c8e9787a7534f8d97f1d27f7d2fdd54d3c49d9f59bb693aedabbf95" +dependencies = [ + "merman-core", + "merman-render", + "thiserror 2.0.17", +] + +[[package]] +name = "merman-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72fc438439ca428b449486f8eaf9ce2f8ab76cea159181d0b1b454e1192e4649" +dependencies = [ + "chrono", + "euclid", + "htmlize", + "indexmap 2.11.4", "json5", - "once_cell", + "lalrpop", + "lalrpop-util", + "logos", + "lol_html", "regex", + "rustc-hash 2.1.1", + "ryu-js", "serde", "serde_json", + "serde_yaml", "thiserror 2.0.17", - "ttf-parser", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "merman-render" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5698e2681196051479ae8bc5153ed7eda6490f56240fd8d92da7c3932a00735" +dependencies = [ + "base64 0.22.1", + "chrono", + "dugong", + "indexmap 2.11.4", + "manatee", + "merman-core", + "pulldown-cmark 0.12.2", + "regex", + "roughr-merman", + "rustc-hash 2.1.1", + "ryu-js", + "serde", + "serde_json", + "svgtypes 0.11.0", + "thiserror 2.0.17", + "unicode-width", + "url", ] [[package]] @@ -11022,6 +11506,39 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "nalgebra" +version = "0.34.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df76ea0ff5c7e6b88689085804d6132ded0ddb9de5ca5b8aeb9eeadc0508a70a" +dependencies = [ + "approx 0.5.1", + "glam 0.14.0", + "glam 0.15.2", + "glam 0.16.0", + "glam 0.17.3", + "glam 0.18.0", + "glam 0.19.0", + "glam 0.20.5", + "glam 0.21.3", + "glam 0.22.0", + "glam 0.23.0", + "glam 0.24.2", + "glam 0.25.0", + "glam 0.27.0", + "glam 0.28.0", + "glam 0.29.3", + "glam 0.30.10", + "glam 0.31.1", + "glam 0.32.1", + "matrixmultiply", + "num-complex", + "num-rational", + "num-traits", + "simba", + "typenum", +] + [[package]] name = "nanoid" version = "0.4.0" @@ -12173,9 +12690,10 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" dependencies = [ - "approx", + "approx 0.5.1", "fast-srgb8", "palette_derive", + "phf 0.11.3", ] [[package]] @@ -12925,6 +13443,17 @@ dependencies = [ "phf_shared 0.12.1", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + [[package]] name = "phf_codegen" version = "0.11.3" @@ -12935,6 +13464,16 @@ dependencies = [ "phf_shared 0.11.3", ] +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + [[package]] name = "phf_generator" version = "0.11.3" @@ -12955,6 +13494,16 @@ dependencies = [ "phf_shared 0.12.1", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand 2.3.0", + "phf_shared 0.13.1", +] + [[package]] name = "phf_macros" version = "0.11.3" @@ -12981,6 +13530,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "phf_shared" version = "0.11.3" @@ -12999,6 +13561,15 @@ dependencies = [ "siphasher 1.0.1", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher 1.0.1", +] + [[package]] name = "picker" version = "0.1.0" @@ -13193,6 +13764,16 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "points_on_curve" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca77ae128f56aad518f82cf0af3dcda13b874e59a608dbb287c7887fec97b505" +dependencies = [ + "euclid", + "num-traits", +] + [[package]] name = "polling" version = "3.11.0" @@ -13915,7 +14496,20 @@ checksum = "76979bea66e7875e7509c4ec5300112b316af87fa7a252ca91c448b32dfe3993" dependencies = [ "bitflags 2.10.0", "memchr", - "pulldown-cmark-escape", + "pulldown-cmark-escape 0.10.1", + "unicase", +] + +[[package]] +name = "pulldown-cmark" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" +dependencies = [ + "bitflags 2.10.0", + "getopts", + "memchr", + "pulldown-cmark-escape 0.11.0", "unicase", ] @@ -13936,6 +14530,12 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd348ff538bc9caeda7ee8cad2d1d48236a1f443c1fa3913c6a02fe0043b1dd3" +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "pulley-interpreter" version = "36.0.9" @@ -14359,6 +14959,12 @@ dependencies = [ "objc2-quartz-core", ] +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "rayon" version = "1.11.0" @@ -14488,6 +15094,17 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -14908,7 +15525,7 @@ dependencies = [ "log", "pico-args", "rgb", - "svgtypes", + "svgtypes 0.15.3", "tiny-skia", "usvg", "zune-jpeg 0.4.21", @@ -15068,6 +15685,22 @@ dependencies = [ "ztracing", ] +[[package]] +name = "roughr-merman" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34fd013a888d8b2119b3ed57cd3df0f1f9dd2db3fa94fdb98f58d60f9855ef0" +dependencies = [ + "derive_builder", + "euclid", + "num-traits", + "palette", + "points_on_curve", + "rand 0.8.6", + "svg_path_ops", + "svgtypes 0.11.0", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -15508,12 +16141,27 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd29631678d6fb0903b69223673e122c32e9ae559d0960a38d574695ebc0ea15" + [[package]] name = "saa" version = "5.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da0ba8adb63e0deebd0744d8fc5bea394c08029159deaf680513fec1a3949144" +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + [[package]] name = "safetensors" version = "0.4.5" @@ -15885,6 +16533,25 @@ dependencies = [ "libc", ] +[[package]] +name = "selectors" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fdfed56cd634f04fe8b9ddf947ae3dc493483e819593d2ba17df9ad05db8b2" +dependencies = [ + "bitflags 2.10.0", + "cssparser 0.36.0", + "derive_more", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash 2.1.1", + "servo_arc", + "smallvec", +] + [[package]] name = "self_cell" version = "1.2.2" @@ -16115,6 +16782,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "session" version = "0.1.0" @@ -16456,6 +17132,19 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simba" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95" +dependencies = [ + "approx 0.5.1", + "num-complex", + "num-traits", + "paste", + "wide", +] + [[package]] name = "simd-adler32" version = "0.3.7" @@ -17137,6 +17826,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strsim" version = "0.11.1" @@ -17296,6 +17991,16 @@ version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" +[[package]] +name = "svg_path_ops" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2ed183bad71dff813db12a317785a8565c9b44732cca3c2effd40a06eb9cd28" +dependencies = [ + "cgmath", + "svgtypes 0.11.0", +] + [[package]] name = "svg_preview" version = "0.1.0" @@ -17309,13 +18014,23 @@ dependencies = [ "zed_actions", ] +[[package]] +name = "svgtypes" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4b0611e7f3277f68c0fa18e385d9e2d26923691379690039548f867cef02a7" +dependencies = [ + "kurbo 0.9.5", + "siphasher 0.3.11", +] + [[package]] name = "svgtypes" version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" dependencies = [ - "kurbo", + "kurbo 0.11.3", "siphasher 1.0.1", ] @@ -17857,6 +18572,17 @@ dependencies = [ "utf-8", ] +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -19389,7 +20115,7 @@ dependencies = [ "flate2", "fontdb", "imagesize", - "kurbo", + "kurbo 0.11.3", "log", "pico-args", "roxmltree", @@ -19397,7 +20123,7 @@ dependencies = [ "simplecss", "siphasher 1.0.1", "strict-num", - "svgtypes", + "svgtypes 0.15.3", "tiny-skia-path", "unicode-bidi", "unicode-script", @@ -20495,7 +21221,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" dependencies = [ "phf 0.11.3", - "phf_codegen", + "phf_codegen 0.11.3", "string_cache", "string_cache_codegen", ] @@ -20789,6 +21515,16 @@ dependencies = [ "wasite", ] +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "wiggle" version = "36.0.9" diff --git a/Cargo.toml b/Cargo.toml index 9880a3f517f..4441be03839 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -130,6 +130,7 @@ members = [ "crates/lsp", "crates/markdown", "crates/markdown_preview", + "crates/mermaid_render", "crates/media", "crates/menu", "crates/migrator", @@ -389,10 +390,10 @@ lmstudio = { path = "crates/lmstudio" } lsp = { path = "crates/lsp" } markdown = { path = "crates/markdown" } markdown_preview = { path = "crates/markdown_preview" } +mermaid_render = { path = "crates/mermaid_render" } svg_preview = { path = "crates/svg_preview" } media = { path = "crates/media" } menu = { path = "crates/menu" } -mermaid-rs-renderer = { git = "https://github.com/zed-industries/mermaid-rs-renderer", rev = "782b89a7da3f0e91e51f98d00a93acba679be6fb", default-features = false } migrator = { path = "crates/migrator" } mistral = { path = "crates/mistral" } multi_buffer = { path = "crates/multi_buffer" } diff --git a/crates/agent/src/templates/system_prompt.hbs b/crates/agent/src/templates/system_prompt.hbs index c7dffcdfe7e..8e90d0970eb 100644 --- a/crates/agent/src/templates/system_prompt.hbs +++ b/crates/agent/src/templates/system_prompt.hbs @@ -23,15 +23,13 @@ graph TD A[Start] --> B[End] ``` +The renderer supports the following diagram types: flowchart, sequence, class, state, ER, gantt, pie, gitgraph, mindmap, timeline, quadrant chart, xy chart, and journey. Other diagram types will only show as code. + Mermaid diagrams are automatically themed to match the user's editor theme. Do not include `%%{init}%%` directives or define your own `classDef` styles. Do *NOT* include inline HTML elements in mermaid diagrams, as they cannot be rendered. It is better to simply skip formatting (e.g. bold/italic/etc.). -When you need accent colors for emphasis (e.g. color-coding layers, categories, or states), use the pre-defined classes `accent0` through `accent7` with the `:::` syntax: - - A:::accent0 --> B:::accent1 --> C:::accent2 - -These classes automatically match the user's theme. Do not hardcode hex color values unless an exact color match is specifically required. Note that the rendered view may be narrow, so try to prioritize generating taller diagrams over wider ones. +Mermaid diagrams are automatically color-coded using the user's theme accent palette. Do not hardcode hex color values unless an exact color match is specifically required. Note that the rendered view may be narrow, so try to prioritize generating taller diagrams over wider ones. {{#if (gt (len available_tools) 0)}} ## Tool Use diff --git a/crates/markdown/Cargo.toml b/crates/markdown/Cargo.toml index be12bf2fe7f..3982cf9506a 100644 --- a/crates/markdown/Cargo.toml +++ b/crates/markdown/Cargo.toml @@ -29,7 +29,7 @@ language.workspace = true linkify.workspace = true log.workspace = true markup5ever_rcdom.workspace = true -mermaid-rs-renderer.workspace = true +mermaid_render = { path = "../mermaid_render" } pulldown-cmark.workspace = true settings.workspace = true stacksafe.workspace = true diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index f1b0154b454..4cbcbe85678 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -34,8 +34,8 @@ use gpui::{ FocusHandle, Focusable, FontStyle, FontWeight, GlobalElementId, Hitbox, Hsla, Image, ImageFormat, ImageSource, KeyContext, Length, MouseButton, MouseDownEvent, MouseEvent, MouseMoveEvent, MouseUpEvent, Point, ScrollHandle, Stateful, StrikethroughStyle, - StyleRefinement, StyledImage, StyledText, Task, TextAlign, TextLayout, TextRun, TextStyle, - TextStyleRefinement, actions, img, point, quad, + StyleRefinement, StyledImage, StyledText, Subscription, Task, TextAlign, TextLayout, TextRun, + TextStyle, TextStyleRefinement, actions, img, point, quad, }; use language::{CharClassifier, Language, LanguageRegistry, Rope}; use parser::CodeBlockMetadata; @@ -333,6 +333,7 @@ pub struct Markdown { fallback_code_block_language: Option, options: MarkdownOptions, mermaid_state: MermaidState, + _mermaid_theme_subscription: Option, mermaid_showing_code: HashSet, copied_code_blocks: HashSet, wrapped_code_blocks: HashSet, @@ -497,6 +498,16 @@ impl Markdown { cx: &mut Context, ) -> Self { let focus_handle = cx.focus_handle(); + + let theme_subscription = if options.render_mermaid_diagrams { + Some( + cx.observe_global::(|this: &mut Self, cx| { + this.invalidate_mermaid_cache(cx); + }), + ) + } else { + None + }; let mut this = Self { source, selection: Selection::default(), @@ -513,6 +524,7 @@ impl Markdown { fallback_code_block_language, options, mermaid_state: MermaidState::default(), + _mermaid_theme_subscription: theme_subscription, mermaid_showing_code: HashSet::default(), copied_code_blocks: HashSet::default(), wrapped_code_blocks: HashSet::default(), @@ -561,15 +573,15 @@ impl Markdown { .retain(|id, _| ids.contains(id)); } - /// Used in the agent panel to force a re-render when the theme changes pub fn invalidate_mermaid_cache(&mut self, cx: &mut Context) { - if self.options.render_mermaid_diagrams && !self.parsed_markdown.mermaid_diagrams.is_empty() + if !self.options.render_mermaid_diagrams || self.parsed_markdown.mermaid_diagrams.is_empty() { - self.mermaid_state.clear(); - let parsed_markdown = self.parsed_markdown.clone(); - self.mermaid_state.update(&parsed_markdown, cx); - cx.notify(); + return; } + + self.mermaid_state.clear(); + self.mermaid_state.update(&self.parsed_markdown, cx); + cx.notify(); } pub(crate) fn is_mermaid_showing_code(&self, source_offset: usize) -> bool { diff --git a/crates/markdown/src/mermaid.rs b/crates/markdown/src/mermaid.rs index 019cb6d78ad..8730c318f0c 100644 --- a/crates/markdown/src/mermaid.rs +++ b/crates/markdown/src/mermaid.rs @@ -1,7 +1,7 @@ use collections::HashMap; use gpui::{ - Animation, AnimationExt, AnyElement, ClickEvent, ClipboardItem, Context, Entity, Hsla, - ImageSource, RenderImage, Rgba, StyledText, Task, img, pulsating_between, + Animation, AnimationExt, AnyElement, ClickEvent, ClipboardItem, Context, Entity, ImageSource, + RenderImage, StyledText, Task, img, pulsating_between, }; use std::collections::BTreeMap; use std::ops::Range; @@ -104,18 +104,12 @@ impl CachedMermaidDiagram { let render_image_clone = render_image.clone(); let svg_renderer = cx.svg_renderer(); let mermaid_theme = build_mermaid_theme(cx); - let accent_classdefs = build_accent_classdefs(cx); let task = cx.spawn(async move |this, cx| { let value = cx .background_spawn(async move { - let options = mermaid_rs_renderer::RenderOptions { - theme: mermaid_theme, - layout: mermaid_rs_renderer::LayoutConfig::default(), - }; - let full_source = format!("{}\n{}", contents.contents, accent_classdefs); let svg_string = - mermaid_rs_renderer::render_with_options(&full_source, options)?; + mermaid_render::render_to_svg(&contents.contents, &mermaid_theme)?; let scale = contents.scale as f32 / 100.0; svg_renderer .render_single_frame(svg_string.as_bytes(), scale) @@ -153,128 +147,71 @@ impl CachedMermaidDiagram { } } -/// Converts an HSLA color to a CSS hex string (e.g. `#1a2b3c`). -fn hsla_to_hex(color: Hsla) -> String { - let rgba: Rgba = color.to_rgb(); - let r = (rgba.r * 255.0).round() as u8; - let g = (rgba.g * 255.0).round() as u8; - let b = (rgba.b * 255.0).round() as u8; - format!("#{r:02x}{g:02x}{b:02x}") +/// Merman has somewhat limited text measurement capabilities. +/// +/// When it doesn't have metrics for any of the specified fonts, it chooses a +/// fairly narrow width, which causes visible overflow. Adding `sans-serif` +/// allows it to fall back to a more conservative (i.e. wider) measurement. +/// +/// This isn't perfect - very wide fonts will likely still cause overflow. A +/// proper fix would involve somehow piping `resvg`'s actual measurements into +/// `merman`, but that is a lot of work for a fairly uncommon edge case. +fn mermaid_font_family(font_family: &str) -> String { + let font_family = gpui::font_name_with_fallbacks(font_family, "system-ui"); + if font_family + .split(',') + .any(|family| family.trim().eq_ignore_ascii_case("sans-serif")) + { + font_family.to_string() + } else { + format!("{font_family}, sans-serif") + } } -fn mermaid_font_family(font_family: &str) -> &str { - gpui::font_name_with_fallbacks(font_family, "system-ui") -} - -fn build_mermaid_theme(cx: &Context) -> mermaid_rs_renderer::Theme { +fn build_mermaid_theme(cx: &Context) -> mermaid_render::MermaidTheme { let colors = cx.theme().colors(); let theme_settings = ThemeSettings::get_global(cx); - let mut theme = mermaid_rs_renderer::Theme::modern(); - - theme.font_family = mermaid_font_family(theme_settings.ui_font.family.as_ref()).to_string(); - theme.background = hsla_to_hex(colors.editor_background); - theme.primary_color = hsla_to_hex(colors.surface_background); - theme.primary_text_color = hsla_to_hex(colors.text); - theme.primary_border_color = hsla_to_hex(colors.border); - theme.line_color = hsla_to_hex(colors.border); - theme.secondary_color = hsla_to_hex(colors.element_background); - theme.tertiary_color = hsla_to_hex(colors.ghost_element_hover); - theme.edge_label_background = hsla_to_hex(colors.editor_background); - theme.cluster_background = hsla_to_hex(colors.panel_background); - theme.cluster_border = hsla_to_hex(colors.border_variant); - theme.text_color = hsla_to_hex(colors.text); - let accents = cx.theme().accents(); - let pie_colors: [String; 12] = - std::array::from_fn(|i| hsla_to_hex(accents.color_for_index(i as u32))); - theme.pie_colors = pie_colors; - theme.pie_title_text_color = hsla_to_hex(colors.text); - theme.pie_section_text_color = "#fff".to_string(); - theme.pie_legend_text_color = hsla_to_hex(colors.text); - theme.pie_stroke_color = hsla_to_hex(colors.border); - theme.pie_outer_stroke_color = hsla_to_hex(colors.border); - - theme.sequence_actor_fill = hsla_to_hex(colors.element_background); - theme.sequence_actor_border = hsla_to_hex(colors.border); - theme.sequence_actor_line = hsla_to_hex(colors.border); - theme.sequence_note_fill = hsla_to_hex(colors.surface_background); - theme.sequence_note_border = hsla_to_hex(colors.border_variant); - theme.sequence_activation_fill = hsla_to_hex(colors.ghost_element_hover); - theme.sequence_activation_border = hsla_to_hex(colors.border); + let is_dark = !cx.theme().appearance.is_light(); let players = cx.theme().players(); - theme.git_colors = std::array::from_fn(|i| hsla_to_hex(players.0[i % players.0.len()].cursor)); - theme.git_inv_colors = - std::array::from_fn(|i| hsla_to_hex(players.0[i % players.0.len()].background)); - theme.git_branch_label_colors = std::array::from_fn(|_| "#fff".to_string()); - theme.git_commit_label_color = hsla_to_hex(colors.text); - theme.git_commit_label_background = hsla_to_hex(colors.element_background); - theme.git_tag_label_color = hsla_to_hex(colors.text); - theme.git_tag_label_background = hsla_to_hex(colors.element_background); - theme.git_tag_label_border = hsla_to_hex(colors.border); + let git_branch_colors = std::array::from_fn(|i| players.0[i % players.0.len()].cursor); + let git_branch_label_colors = git_branch_colors.map(mermaid_render::text_color_for_background); - theme -} - -fn build_accent_classdefs(cx: &Context) -> String { - use std::fmt::Write; - let players = &cx.theme().players(); - let is_light = cx.theme().appearance.is_light(); - let mut defs = String::new(); - for (i, player) in players.0.iter().enumerate() { - let (fill, text_color) = accent_fill_and_text(player.background, is_light); - let fill = hsla_to_hex(fill); - let stroke = hsla_to_hex(player.cursor); - let text_color = hsla_to_hex(text_color); - writeln!( - defs, - "classDef accent{i} fill:{fill},stroke:{stroke},color:{text_color}" - ) - .ok(); + mermaid_render::MermaidTheme { + dark_mode: is_dark, + font_family: mermaid_font_family(theme_settings.ui_font.family.as_ref()), + background: colors.editor_background, + primary_color: colors.surface_background, + primary_text_color: colors.text, + primary_border_color: colors.border, + secondary_color: colors.element_background, + tertiary_color: colors.ghost_element_hover, + line_color: colors.border, + text_color: colors.text, + edge_label_background: colors.editor_background, + cluster_background: colors.panel_background, + cluster_border: colors.border_variant, + note_background: colors.surface_background, + note_border: colors.border_variant, + actor_background: colors.element_background, + actor_border: colors.border, + activation_background: colors.ghost_element_hover, + activation_border: colors.border, + git_branch_colors, + git_branch_label_colors, + er_attr_bg_odd: colors.surface_background, + er_attr_bg_even: colors.element_background, + error_color: cx.theme().status().error, + warning_color: cx.theme().status().warning, + accent_colors: players + .0 + .iter() + .map(|player| mermaid_render::AccentColor { + foreground: player.cursor, + background: player.background, + }) + .collect(), } - defs -} - -/// Adjusts an accent fill color to ensure readable text contrast. -/// -/// On dark themes, darkens the fill and uses white text. -/// On light themes, lightens the fill and uses black text. -/// The fill is adjusted until it meets a minimum WCAG contrast ratio -/// of ~4.5:1 against the chosen text color. -fn accent_fill_and_text(color: Hsla, is_light: bool) -> (Hsla, Hsla) { - let mut fill = color; - if is_light { - // Lighten fill until luminance is high enough for black text. - // Target: relative luminance >= 0.35 → contrast ratio ~8:1 with black. - for _ in 0..50 { - if relative_luminance(fill) >= 0.35 { - break; - } - fill.l = (fill.l + 0.02).min(1.0); - } - (fill, gpui::black()) - } else { - // Darken fill until luminance is low enough for white text. - // Target: relative luminance <= 0.18 → contrast ratio ~4.6:1 with white. - for _ in 0..50 { - if relative_luminance(fill) <= 0.18 { - break; - } - fill.l = (fill.l - 0.02).max(0.0); - } - (fill, gpui::white()) - } -} - -fn relative_luminance(color: Hsla) -> f32 { - let rgba: Rgba = color.to_rgb(); - fn linearize(c: f32) -> f32 { - if c <= 0.04045 { - c / 12.92 - } else { - ((c + 0.055) / 1.055).powf(2.4) - } - } - 0.2126 * linearize(rgba.r) + 0.7152 * linearize(rgba.g) + 0.0722 * linearize(rgba.b) } fn parse_mermaid_info(info: &str) -> Option { @@ -292,6 +229,38 @@ fn parse_mermaid_info(info: &str) -> Option { ) } +/// We deliberately block rendering of some diagram types, even though `merman` +/// supports them, because we have not yet written custom CSS to ensure text is +/// readable. +fn is_supported_diagram_type(source: &str) -> bool { + /// If updating this list, also update the system prompt! + const SUPPORTED_PREFIXES: &[&str] = &[ + "flowchart", + "graph", + "sequenceDiagram", + "classDiagram", + "stateDiagram", + "stateDiagram-v2", + "erDiagram", + "gantt", + "pie", + "gitGraph", + "mindmap", + "timeline", + "quadrantChart", + "xychart-beta", + "journey", + ]; + let first_token = source + .trim_start() + .split(|c: char| c.is_whitespace() || c == '\n') + .next() + .unwrap_or(""); + SUPPORTED_PREFIXES + .iter() + .any(|prefix| first_token.eq_ignore_ascii_case(prefix)) +} + pub(crate) fn extract_mermaid_diagrams( source: &str, events: &[(Range, MarkdownEvent)], @@ -324,6 +293,9 @@ pub(crate) fn extract_mermaid_diagrams( .strip_suffix('\n') .unwrap_or(&source[metadata.content_range.clone()]) .to_string(); + if !is_supported_diagram_type(&contents) { + continue; + } mermaid_diagrams.insert( source_range.start, ParsedMarkdownMermaidDiagram { @@ -588,24 +560,10 @@ mod tests { MarkdownStyle, WrapButtonVisibility, }; use collections::HashMap; - use gpui::{Context, Hsla, IntoElement, Render, RenderImage, TestAppContext, Window, size}; + use gpui::{Context, IntoElement, Render, RenderImage, TestAppContext, Window, size}; use std::sync::Arc; use ui::prelude::*; - #[gpui::property_test] - fn accent_fill_and_text_sufficient_contrast( - #[strategy = Hsla::opaque_strategy()] color: Hsla, - light_mode: bool, - ) { - let (fill, text) = super::accent_fill_and_text(color, light_mode); - let fill_luminance = super::relative_luminance(fill); - let text_luminance = super::relative_luminance(text); - let lighter = fill_luminance.max(text_luminance); - let darker = fill_luminance.min(text_luminance); - let contrast_ratio = (lighter + 0.05) / (darker + 0.05); - assert!(contrast_ratio >= 4.5,); - } - fn ensure_theme_initialized(cx: &mut TestAppContext) { cx.update(|cx| { if !cx.has_global::() { @@ -693,11 +651,27 @@ mod tests { #[test] fn test_mermaid_font_family_resolves_zed_virtual_fonts() { - assert_eq!(super::mermaid_font_family(".ZedSans"), "IBM Plex Sans"); - assert_eq!(super::mermaid_font_family("Zed Plex Sans"), "IBM Plex Sans"); - assert_eq!(super::mermaid_font_family(".ZedMono"), "Lilex"); - assert_eq!(super::mermaid_font_family(".SystemUIFont"), "system-ui"); - assert_eq!(super::mermaid_font_family("Custom Font"), "Custom Font"); + assert_eq!( + super::mermaid_font_family(".ZedSans"), + "IBM Plex Sans, sans-serif" + ); + assert_eq!( + super::mermaid_font_family("Zed Plex Sans"), + "IBM Plex Sans, sans-serif" + ); + assert_eq!(super::mermaid_font_family(".ZedMono"), "Lilex, sans-serif"); + assert_eq!( + super::mermaid_font_family(".SystemUIFont"), + "system-ui, sans-serif" + ); + assert_eq!( + super::mermaid_font_family("Custom Font"), + "Custom Font, sans-serif" + ); + assert_eq!( + super::mermaid_font_family("Custom Font, sans-serif"), + "Custom Font, sans-serif" + ); } #[test] @@ -721,6 +695,27 @@ mod tests { assert_eq!(diagram.contents.scale, 150); } + #[test] + fn test_unsupported_diagram_types_are_skipped() { + let markdown = concat!( + "```mermaid\nsankey-beta\n```\n\n", + "```mermaid\nblock-beta\n```\n\n", + "```mermaid\nflowchart TD\n A --> B\n```", + ); + let events = crate::parser::parse_markdown_with_options(markdown, false, false).events; + let diagrams = extract_mermaid_diagrams(markdown, &events); + assert_eq!( + diagrams.len(), + 1, + "Only the flowchart should be extracted; sankey and block should be skipped" + ); + let diagram = diagrams.values().next().unwrap(); + assert!( + diagram.contents.contents.contains("flowchart"), + "The extracted diagram should be the flowchart" + ); + } + #[gpui::test] fn test_mermaid_fallback_on_edit(cx: &mut TestAppContext) { let old_full_order = mermaid_sequence(&["graph A", "graph B", "graph C"]); diff --git a/crates/mermaid_render/Cargo.toml b/crates/mermaid_render/Cargo.toml new file mode 100644 index 00000000000..73a32ba81fa --- /dev/null +++ b/crates/mermaid_render/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "mermaid_render" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/mermaid_render.rs" +doctest = false + +[features] +test-support = [] + +[dependencies] +anyhow.workspace = true +gpui.workspace = true +merman = { version = "0.4", features = ["render"] } +quick-xml.workspace = true +serde_json.workspace = true + +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } +mermaid_render = { path = ".", features = ["test-support"] } diff --git a/crates/mermaid_render/LICENSE-GPL b/crates/mermaid_render/LICENSE-GPL new file mode 120000 index 00000000000..89e542f750c --- /dev/null +++ b/crates/mermaid_render/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/mermaid_render/src/mermaid_render.rs b/crates/mermaid_render/src/mermaid_render.rs new file mode 100644 index 00000000000..1e17d8d780b --- /dev/null +++ b/crates/mermaid_render/src/mermaid_render.rs @@ -0,0 +1,181 @@ +// for a very big json! macro +#![recursion_limit = "256"] + +//! Crate for rendering Mermaid diagram strings to SVG strings. +//! +//! The entrypoint to this crate is [`render_to_svg`]. +//! +//! It takes a `&str` and a [`MermaidTheme`]. The output is an SVG with the +//! following properties: +//! - The style matches the provided theme +//! - Nodes are given accent colors, even if none are provided in the mermaid +//! source. +//! - The SVG has been tweaked based on the assumption that it will be rasterized +//! using `usvg`/`resvg`. Some bugs/quirks of `usvg`/`resvg` are accounted for +//! in this crate. +//! +//! This module uses the [`merman`] crate for rendering, rather than +//! `mermaid-rs`, which was used in the previous implementation of mermaid +//! rendering in Zed. Merman provides significantly more accurate rendering, and +//! seems to be somewhat faster, but by default has poor CSS, making diagrams +//! look weird without significant cleanup. This is made worse by the fact that +//! `usvg`/`resvg` doesn't support some features that [`merman`] relies on. +//! +//! As such, this crate is quite large. But the code is very self-contained, and +//! has few dependencies. In fact, the [`gpui`] dependency is only needed for +//! the [`Hsla`] and [`Rgba`] color types. +//! +//! The [`render_to_svg`] function operates in two stages: +//! - [`render`] the mermaid text to SVG using [`merman`]. +//! - [`postprocess`] the SVG to clean incorrect output and add styling. +//! +//! The postprocessing is also split up into stages. We parse the generated SVG +//! using [`quick_xml`], which produces an iterator of +//! [`Event<'_>`](quick_xml::events::Event)s. This iterator is then repeatedly +//! transformed, and finally collected back into an SVG string. +//! +//! This approach: +//! - Avoids doing multiple expensive string insertions. +//! - Avoids parsing the SVG multiple times (without needing to put all the +//! logic in one huge function). +//! - But is quite a bit more complex. +//! +//! I think this complexity is justified because of the drastic performance +//! impact, as well as the low-risk nature; this code cannot panic, and errors +//! in the output just produce weird-looking diagrams. +//! +//! ## Color handling +//! +//! We try to match the users theme, and also apply accent colors to diagrams to +//! make them more visually interesting. Accent colors are derived from the +//! `player_colors` in the Zed theme. +//! +//! There are three parts to color handling: +//! +//! 1. A [`merman::MermaidConfig`] is passed when initially rendering the +//! diagram. This sets most "normal" colors (background, text, etc.). However, +//! it's not possible to color nodes individually, and not all parts of the +//! diagrams are correctly themed. +//! 2. `postprocess::accent_colors` injects custom CSS classes (e.g. +//! `zed-accent-0`) to specific elements, based on the diagram type and +//! node. +//! 3. `postprocess::inject_css` injects CSS rules for the classes applied by +//! `accent_colors` + +mod postprocess; +mod render; + +use anyhow::Result; +use gpui::{Hsla, Rgba}; + +#[derive(Debug, Clone, Copy)] +pub struct AccentColor { + pub foreground: Hsla, + pub background: Hsla, +} + +#[derive(Debug, Clone)] +pub struct MermaidTheme { + pub dark_mode: bool, + pub font_family: String, + pub background: Hsla, + pub primary_color: Hsla, + pub primary_text_color: Hsla, + pub primary_border_color: Hsla, + pub secondary_color: Hsla, + pub tertiary_color: Hsla, + pub line_color: Hsla, + pub text_color: Hsla, + pub edge_label_background: Hsla, + pub cluster_background: Hsla, + pub cluster_border: Hsla, + pub note_background: Hsla, + pub note_border: Hsla, + pub actor_background: Hsla, + pub actor_border: Hsla, + pub activation_background: Hsla, + pub activation_border: Hsla, + pub git_branch_colors: [Hsla; 8], + pub git_branch_label_colors: [Hsla; 8], + pub er_attr_bg_odd: Hsla, + pub er_attr_bg_even: Hsla, + pub error_color: Hsla, + pub warning_color: Hsla, + pub accent_colors: Vec, +} + +/// Default theme for testing. +#[cfg(any(test, feature = "test-support"))] +impl Default for MermaidTheme { + fn default() -> Self { + use gpui::{hsla, rgb}; + let git_branch_colors: [Hsla; 8] = [ + hsla(240.0 / 360.0, 1.0, 0.462_745_1, 1.0), + hsla(60.0 / 360.0, 1.0, 0.435_294_12, 1.0), + hsla(80.0 / 360.0, 1.0, 0.462_745_1, 1.0), + hsla(210.0 / 360.0, 1.0, 0.462_745_1, 1.0), + hsla(180.0 / 360.0, 1.0, 0.462_745_1, 1.0), + hsla(150.0 / 360.0, 1.0, 0.462_745_1, 1.0), + hsla(300.0 / 360.0, 1.0, 0.462_745_1, 1.0), + hsla(0.0, 1.0, 0.462_745_1, 1.0), + ]; + let git_branch_label_colors: [Hsla; 8] = + git_branch_colors.map(crate::text_color_for_background); + + Self { + dark_mode: false, + font_family: "Inter, ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", \"DejaVu Sans\", \"Liberation Sans\", sans-serif, \"Noto Color Emoji\", \"Apple Color Emoji\", \"Segoe UI Emoji\"".to_string(), + background: rgb(0xFFFFFF).into(), + primary_color: rgb(0xF8FAFC).into(), + primary_text_color: rgb(0x0F172A).into(), + primary_border_color: rgb(0x94A3B8).into(), + secondary_color: rgb(0xE2E8F0).into(), + tertiary_color: rgb(0xFFFFFF).into(), + line_color: rgb(0x64748B).into(), + text_color: rgb(0x0F172A).into(), + edge_label_background: rgb(0xFFFFFF).into(), + cluster_background: rgb(0xF1F5F9).into(), + cluster_border: rgb(0xCBD5E1).into(), + note_background: rgb(0xFFF7ED).into(), + note_border: rgb(0xFDBA74).into(), + actor_background: rgb(0xF8FAFC).into(), + actor_border: rgb(0x94A3B8).into(), + activation_background: rgb(0xE2E8F0).into(), + activation_border: rgb(0x94A3B8).into(), + git_branch_colors, + git_branch_label_colors, + er_attr_bg_odd: rgb(0x94A3B8).into(), + er_attr_bg_even: rgb(0x0F172A).into(), + error_color: rgb(0xDC2626).into(), + warning_color: rgb(0xD97706).into(), + accent_colors: Vec::new(), + } + } +} + +/// Formats a color as a CSS hex color for embedding in SVG/CSS. +/// +/// Emits `#rrggbb` for fully opaque colors and `#rrggbbaa` when the input +/// has any transparency, so translucent theme colors (e.g. `ghost_element_hover` +/// from Zed's UI palette) round-trip without silently losing their alpha. +pub(crate) fn css_color(color: Hsla) -> String { + let rgba = Rgba::from(color); + let r = (rgba.r.clamp(0.0, 1.0) * 255.0).round() as u8; + let g = (rgba.g.clamp(0.0, 1.0) * 255.0).round() as u8; + let b = (rgba.b.clamp(0.0, 1.0) * 255.0).round() as u8; + let a = (rgba.a.clamp(0.0, 1.0) * 255.0).round() as u8; + if a == 0xff { + format!("#{r:02x}{g:02x}{b:02x}") + } else { + format!("#{r:02x}{g:02x}{b:02x}{a:02x}") + } +} + +pub use postprocess::util::text_color_for_background; + +/// See the [module-level docs][crate] for more info. +pub fn render_to_svg(source: &str, theme: &MermaidTheme) -> Result { + let svg = render::render_mermaid(source, theme)?; + let svg = postprocess::postprocess(&svg, theme)?; + Ok(svg) +} diff --git a/crates/mermaid_render/src/postprocess.rs b/crates/mermaid_render/src/postprocess.rs new file mode 100644 index 00000000000..af1e61f3367 --- /dev/null +++ b/crates/mermaid_render/src/postprocess.rs @@ -0,0 +1,136 @@ +//! Post-processing of [`merman`]-produced SVGs for rasterization with `usvg`/`resvg`. +//! +//! Each submodule is a specific pass that tweaks the SVG event iterator in a particular way. +//! +//! We always produce and consume [`Event`]s with a short lifetime. +//! [`Event<'a>`] is backed internally by a [`Cow<'a, [u8]>`](std::borrow::Cow), +//! so we don't have lifetime issues when we need to mutate the text in an +//! [`Event`], but also don't force allocating a new [`String`] each time. +//! +//! Many modules contain internal structs that implement [`Iterator`] to make +//! reasoning about lifetimes simpler, but these are private implementation +//! details. + +mod accent_colors; +mod element_fixup; +mod fallback_fixup; +mod foreignobject_wrap; +mod inject_css; +mod strip_foreignobject; +mod strip_invalid_css; +pub(crate) mod util; + +use anyhow::{Context as _, Result}; +use quick_xml::Reader; +use quick_xml::events::Event; + +use crate::MermaidTheme; + +pub(super) fn postprocess(svg: &str, theme: &MermaidTheme) -> Result { + // Pass 1: foreignObject preparation (\n fix + word wrapping) + let svg = foreignobject_wrap::process(svg)?; + + // Add fallbacks alongside elements + let svg = merman::render::foreign_object_label_fallback_svg_text(&svg); + + // Extract SVG id for CSS scoping (quick scan of the first element) + let svg_id = extract_svg_id(&svg); + + // Pass 2: themed post-processing pipeline. + // Each adapter takes an iterator of events and returns an iterator of events. + // Events borrow from the `svg` string — no .into_owned() per event. + let mut reader = Reader::from_str(&svg); + reader.config_mut().check_end_names = false; + let events = ReaderIter::new(reader); + let events = strip_foreignobject::process(events); + let events = fallback_fixup::process(events, theme); + let events = element_fixup::process(events, theme); + + let events = accent_colors::process(events, theme); + let events = strip_invalid_css::process(events); + let events = inject_css::process(events, theme, &svg_id); + + let mut writer = quick_xml::Writer::new(Vec::with_capacity(svg.len())); + for event in events { + writer.write_event(event?)?; + } + String::from_utf8(writer.into_inner()).context("SVG output is not valid UTF-8") +} + +fn extract_svg_id(svg: &str) -> String { + let mut reader = Reader::from_str(svg); + reader.config_mut().check_end_names = false; + for event in ReaderIter::new(reader) { + let Ok(Event::Start(e) | Event::Empty(e)) = event else { + continue; + }; + if e.name().as_ref() == b"svg" { + return e + .try_get_attribute("id") + .ok() + .flatten() + .and_then(|a| a.unescape_value().ok()) + .map(|v| v.into_owned()) + .unwrap_or_default(); + } + } + String::new() +} + +struct ReaderIter<'a> { + reader: Reader<&'a [u8]>, + done: bool, +} + +impl<'a> ReaderIter<'a> { + fn new(reader: Reader<&'a [u8]>) -> Self { + Self { + reader, + done: false, + } + } +} + +impl<'a> Iterator for ReaderIter<'a> { + type Item = Result>; + + fn next(&mut self) -> Option { + if self.done { + return None; + } + match self.reader.read_event() { + Ok(Event::Eof) => { + self.done = true; + None + } + Ok(event) => Some(Ok(event)), + Err(e) => { + self.done = true; + Some(Err(e.into())) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn default_theme() -> MermaidTheme { + MermaidTheme::default() + } + + #[test] + fn strip_css_handles_style_element_with_attributes() { + let svg = r#""#; + let result = postprocess(svg, &default_theme()).unwrap(); + assert!( + !result.contains("@keyframes"), + "Unsupported @keyframes should be stripped from +//! +//! +//! +//! ``` + +use std::collections::VecDeque; +use std::fmt::Write; + +use anyhow::Result; +use quick_xml::events::{BytesText, Event}; + +use crate::MermaidTheme; + +/// Morally equivalent to `format!(".section-{i}")`, but without allocating +const MINDMAP_SECTION_SELECTORS: [&str; 11] = [ + ".section-0", + ".section-1", + ".section-2", + ".section-3", + ".section-4", + ".section-5", + ".section-6", + ".section-7", + ".section-8", + ".section-9", + ".section-10", +]; + +struct InjectCss<'a, I> { + inner: I, + injected_css: String, + in_style: bool, + injected: bool, + pending: VecDeque>, +} + +impl<'a, I: Iterator>>> Iterator for InjectCss<'a, I> { + type Item = Result>; + + fn next(&mut self) -> Option { + if let Some(event) = self.pending.pop_front() { + return Some(Ok(event)); + } + + let event = match self.inner.next()? { + Ok(ev) => ev, + Err(e) => return Some(Err(e)), + }; + + match &event { + Event::Start(e) if e.name().as_ref() == b"style" => { + self.in_style = true; + return Some(Ok(event)); + } + Event::End(e) if e.name().as_ref() == b"style" => { + self.in_style = false; + if !self.injected { + self.injected = true; + self.pending + .push_back(Event::Text(BytesText::from_escaped(std::mem::take( + &mut self.injected_css, + )))); + self.pending.push_back(event); + return self.pending.pop_front().map(Ok); + } + return Some(Ok(event)); + } + Event::Text(text) if self.in_style => { + self.injected = true; + let existing = match std::str::from_utf8(text.as_ref()) { + Ok(s) => s, + Err(e) => return Some(Err(e.into())), + }; + let mut combined = String::with_capacity(existing.len() + self.injected_css.len()); + combined.push_str(existing); + combined.push_str(&self.injected_css); + return Some(Ok(Event::Text(BytesText::from_escaped(combined)))); + } + _ => {} + } + + Some(Ok(event)) + } +} + +pub(super) fn process<'a>( + events: impl Iterator>>, + theme: &MermaidTheme, + svg_id: &str, +) -> impl Iterator>> { + let injected_css = build_injected_css(theme, svg_id); + InjectCss { + inner: events, + injected_css, + in_style: false, + injected: false, + pending: VecDeque::new(), + } +} + +fn mindmap_section_css(theme: &MermaidTheme) -> String { + let colors: Vec = theme + .git_branch_colors + .iter() + .map(|c| crate::css_color(*c)) + .collect(); + let fills: Vec = theme + .git_branch_colors + .iter() + .map(|c| { + crate::css_color(blend_over_background( + *c, + theme.background, + ACCENT_FILL_OPACITY, + )) + }) + .collect(); + let text = crate::css_color(theme.text_color); + let mut css = String::with_capacity(5_400); + + let emit = |css: &mut String, selector: &str, color: &str, fill: &str, txt: &str| { + let section_index = selector + .trim_start_matches(".section-root.section-") + .trim_start_matches(".section-"); + write!( + css, + "{selector} rect, {selector} path, {selector} circle, {selector} polygon \ + {{ fill: {fill} !important; stroke: {color} !important; }}\n\ + {selector} text, {selector} span, \ + text{selector}, tspan{selector} \ + {{ fill: {txt} !important; color: {txt} !important; }}\n\ + {selector} foreignObject div, {selector} foreignObject span, {selector} foreignObject p \ + {{ color: {txt} !important; }}\n\ + .section-edge{section_index} {{ stroke: {color} !important; }}\n", + ) + .expect("write to String cannot fail"); + }; + + emit( + &mut css, + ".section-root.section--1", + &colors[0], + &fills[0], + &text, + ); + emit(&mut css, ".section--1", &colors[1], &fills[1], &text); + for (i, selector) in MINDMAP_SECTION_SELECTORS.iter().enumerate() { + let ci = 2 + (i % 6); + emit(&mut css, selector, &colors[ci], &fills[ci], &text); + } + css +} + +fn git_branch_css(theme: &MermaidTheme) -> String { + let text = crate::css_color(theme.text_color); + let mut css = String::with_capacity(8 * 200); + for i in 0..8 { + let c = crate::css_color(theme.git_branch_colors[i]); + let label_fill = crate::css_color(blend_over_background( + theme.git_branch_colors[i], + theme.background, + ACCENT_FILL_OPACITY, + )); + write!( + css, + ".commit{i} {{ stroke: {c}; fill: {c}; }}\n\ + .arrow{i} {{ stroke: {c}; }}\n\ + .label{i} {{ fill: {label_fill}; stroke: {c}; }}\n\ + .branch-label{i} {{ fill: {text}; }}\n" + ) + .expect("write to String cannot fail"); + } + css +} + +fn adjust_lightness(color: &mut gpui::Hsla, dark_mode: bool) { + if dark_mode { + color.l = (color.l * 0.7).max(0.0); + } else { + color.l = (color.l * 1.3).min(1.0); + } +} + +const ACCENT_FILL_OPACITY: f32 = 0.15; + +fn blend_over_background( + foreground: gpui::Hsla, + background: gpui::Hsla, + opacity: f32, +) -> gpui::Hsla { + let fg = gpui::Rgba::from(foreground); + let bg = gpui::Rgba::from(background); + let blended = gpui::Rgba { + r: fg.r * opacity + bg.r * (1.0 - opacity), + g: fg.g * opacity + bg.g * (1.0 - opacity), + b: fg.b * opacity + bg.b * (1.0 - opacity), + a: 1.0, + }; + gpui::Hsla::from(blended) +} + +fn accent_css(theme: &MermaidTheme) -> String { + let mut css = String::with_capacity(theme.accent_colors.len() * 420); + let text = crate::css_color(theme.text_color); + + for (i, accent) in theme.accent_colors.iter().enumerate() { + let stroke = crate::css_color(accent.foreground); + let fill = crate::css_color(blend_over_background( + accent.background, + theme.background, + ACCENT_FILL_OPACITY, + )); + let class = format!(".zed-accent-{i}"); + write!( + css, + "{class} rect, {class} path, {class} circle, {class} polygon, {class} ellipse, \ + rect{class}, path{class}, circle{class}, polygon{class}, ellipse{class} \ + {{ fill: {fill} !important; stroke: {stroke} !important; }}\n\ + {class} text, {class} tspan, text{class}, tspan{class} \ + {{ fill: {text} !important; }}\n", + ) + .expect("write to String cannot fail"); + } + css +} + +fn chart_color_css(theme: &MermaidTheme) -> String { + // Each block is around 230 bytes, add some headroom + let mut css = String::with_capacity(8 * 250); + for i in 0..8 { + let color = crate::css_color(theme.git_branch_colors[i]); + let class = format!(".zed-chart-{i}"); + write!( + css, + "path.pieCircle{class} {{ fill: {color} !important; }}\n\ + .plot rect{class}, .legend rect{class} {{ fill: {color} !important; stroke: {color} !important; }}\n\ + .plot path{class} {{ stroke: {color} !important; }}\n" + ) + .expect("write to String cannot fail"); + } + css +} + +fn timeline_css(theme: &MermaidTheme) -> String { + let mut css = String::with_capacity(8 * 300); + let text = crate::css_color(theme.text_color); + for i in 0..8 { + let c = crate::css_color(theme.git_branch_colors[i]); + let fill = crate::css_color(blend_over_background( + theme.git_branch_colors[i], + theme.background, + ACCENT_FILL_OPACITY, + )); + write!( + css, + "rect.task-type-{i}, rect.section-type-{i} {{ fill: {fill} !important; stroke: {c} !important; }}\n" + ).expect("write to String cannot fail"); + } + for i in 0..4 { + let c = crate::css_color(theme.git_branch_colors[i % 8]); + let fill = crate::css_color(blend_over_background( + theme.git_branch_colors[i % 8], + theme.background, + ACCENT_FILL_OPACITY, + )); + write!( + css, + ".section{i} {{ fill: {fill} !important; }}\n\ + .task{i} {{ fill: {fill} !important; stroke: {c} !important; }}\n\ + .taskText{i} {{ fill: {text} !important; }}\n\ + .taskTextOutside{i} {{ fill: {text} !important; }}\n" + ) + .expect("write to String cannot fail"); + } + css +} + +fn should_scope_css_line(trimmed: &str) -> bool { + !trimmed.is_empty() + && (trimmed.starts_with('.') + || trimmed.starts_with("foreignObject") + || trimmed.starts_with("g.") + || trimmed.starts_with("text") + || trimmed.starts_with("tspan") + || trimmed.starts_with("rect.") + || trimmed.starts_with("path.") + || trimmed.starts_with("defs") + || trimmed.starts_with('#')) +} + +fn scoped_selector_count(raw_css: &str) -> usize { + raw_css.lines().fold(0, |count, line| { + let trimmed = line.trim(); + if !should_scope_css_line(trimmed) { + return count; + } + let Some((selectors, _)) = trimmed.split_once('{') else { + return count; + }; + count.saturating_add(selectors.split(',').count()) + }) +} + +fn scope_css(raw_css: &str, svg_id: &str) -> String { + let scoped_selector_prefix_len = svg_id.len().saturating_add(2); + let result_capacity = raw_css + .len() + .saturating_add(scoped_selector_count(raw_css).saturating_mul(scoped_selector_prefix_len)); + let mut result = String::with_capacity(result_capacity); + for line in raw_css.lines() { + let trimmed = line.trim(); + + if should_scope_css_line(trimmed) { + if let Some(brace) = trimmed.find('{') { + let (selectors, rest) = trimmed.split_at(brace); + let mut first = true; + for selector in selectors.split(',') { + if !first { + result.push_str(", "); + } + first = false; + write!(result, "#{svg_id} {}", selector.trim()) + .expect("write to String cannot fail"); + } + writeln!(result, "{rest}").expect("write to String cannot fail"); + continue; + } + } + writeln!(result, "{line}").expect("write to String cannot fail"); + } + result +} + +fn build_injected_css(theme: &MermaidTheme, svg_id: &str) -> String { + let font = &theme.font_family; + let text = crate::css_color(theme.text_color); + let line = crate::css_color(theme.line_color); + let primary = crate::css_color(theme.primary_color); + let border = crate::css_color(theme.primary_border_color); + let secondary = crate::css_color(theme.secondary_color); + let tertiary = crate::css_color(theme.tertiary_color); + let background = crate::css_color(theme.background); + let edge_label_bg = crate::css_color(theme.edge_label_background); + let actor_bg = crate::css_color(theme.actor_background); + let actor_border = crate::css_color(theme.actor_border); + let error_bg = { + let mut c = theme.error_color; + adjust_lightness(&mut c, theme.dark_mode); + c + }; + let error = crate::css_color(error_bg); + let error_text = crate::css_color(crate::postprocess::util::text_color_for_background( + error_bg, + )); + let warning_bg = { + let mut c = theme.warning_color; + adjust_lightness(&mut c, theme.dark_mode); + c + }; + let warning = crate::css_color(warning_bg); + let warning_text = crate::css_color(crate::postprocess::util::text_color_for_background( + warning_bg, + )); + let note_bg = crate::css_color(theme.note_background); + let note_border = crate::css_color(theme.note_border); + let er_odd = crate::css_color(theme.er_attr_bg_odd); + let er_even = crate::css_color(theme.er_attr_bg_even); + + let actor_text = &text; + let note_text = &text; + + let raw_css = format!( + r#" + text, tspan, foreignObject div, foreignObject span, foreignObject p {{ font-family: {font} !important; }} + foreignObject div, foreignObject span, foreignObject p {{ font-size: 16px; color: {text}; }} + foreignObject p {{ margin: 0; }} + foreignObject {{ overflow: visible; }} + foreignObject div {{ max-width: none !important; }} + .label-group foreignObject {{ font-weight: bold; }} + .node rect, .node path {{ fill: {primary}; stroke: {border}; }} + .node polygon {{ fill: {primary}; stroke: {border}; }} + .label-container path {{ fill: {primary}; stroke: {border}; }} + {mindmap_css} + .mindmap-node line, .timeline-node line {{ stroke: transparent !important; }} + g.stateGroup rect {{ fill: {primary} !important; stroke: {border} !important; }} + g.stateGroup text {{ fill: {text} !important; }} + g.stateGroup .state-title {{ fill: {text} !important; }} + .stateGroup .composit {{ fill: {background} !important; }} + .stateGroup .alt-composit {{ fill: {tertiary} !important; }} + .state-note {{ stroke: {note_border} !important; fill: {note_bg} !important; }} + .state-note text {{ fill: {note_text} !important; }} + .stateLabel .box {{ fill: {primary} !important; }} + .stateLabel text {{ fill: {text} !important; }} + .node circle.state-start {{ fill: {line} !important; stroke: {line} !important; }} + .node .fork-join {{ fill: {line} !important; stroke: {line} !important; }} + .node circle.state-end {{ fill: {border} !important; stroke: {background} !important; }} + .end-state-inner {{ fill: {background} !important; }} + .statediagram-cluster rect {{ fill: {primary} !important; stroke: {border} !important; }} + .statediagram-cluster.statediagram-cluster .inner {{ fill: {background} !important; }} + .statediagram-cluster.statediagram-cluster-alt .inner {{ fill: {tertiary} !important; }} + .statediagram-state rect.divider {{ fill: {tertiary} !important; }} + .statediagram-note rect {{ fill: {note_bg} !important; stroke: {note_border} !important; }} + .statediagram-note text {{ fill: {note_text} !important; }} + .statediagramTitleText {{ fill: {text} !important; }} + .transition {{ stroke: {line} !important; }} + .cluster-label, .nodeLabel {{ color: {text} !important; }} + defs #statediagram-barbEnd {{ fill: {line} !important; stroke: {line} !important; }} + #statediagram-barbEnd {{ fill: {line} !important; }} + .edgeLabel .label rect {{ fill: {primary} !important; }} + .edgeLabel rect {{ fill: {primary} !important; background-color: {primary} !important; }} + .edgeLabel .label text {{ fill: {text} !important; }} + .edgeLabel p {{ background-color: {primary} !important; }} + .edgeLabel {{ background-color: {primary} !important; }} + .actor {{ stroke: {actor_border}; fill: {actor_bg}; }} + text.actor {{ text-anchor: middle; }} + text.actor>tspan {{ fill: {actor_text} !important; stroke: none; }} + .labelText, .labelText>tspan {{ fill: {actor_text} !important; }} + .actor-line {{ stroke: {actor_border} !important; }} + .messageLine0 {{ stroke: {text} !important; }} + .messageLine1 {{ stroke: {text} !important; }} + #arrowhead path {{ fill: {text} !important; stroke: {text} !important; }} + #crosshead path {{ fill: {text} !important; stroke: {text} !important; }} + .messageText {{ fill: {text} !important; }} + .loopText, .loopText>tspan {{ fill: {text} !important; }} + .loopLine {{ stroke: {actor_border} !important; fill: {actor_border} !important; }} + .note {{ stroke: {note_border} !important; fill: {note_bg} !important; }} + .noteText, .noteText>tspan {{ fill: {note_text} !important; }} + .activation0, .activation1, .activation2 {{ fill: {secondary} !important; stroke: {border} !important; }} + .labelBox {{ stroke: {actor_border} !important; fill: {actor_bg} !important; }} + .actor-man line {{ stroke: {actor_border} !important; fill: {actor_bg} !important; }} + .actor-man circle {{ stroke: {actor_border} !important; fill: {actor_bg} !important; }} + .pieTitleText {{ fill: {text} !important; }} + .slice {{ fill: {text} !important; }} + .legend text {{ fill: {text} !important; }} + .pieOuterCircle {{ stroke: {border} !important; }} + .pieCircle {{ stroke: {border} !important; }} + {timeline_css} + text.journey-section, text.task {{ fill: {text} !important; }} + .relationshipLabelBox {{ fill: {tertiary} !important; opacity: 0.7; background-color: {tertiary} !important; }} + .labelBkg {{ background-color: {tertiary} !important; }} + .edgeLabel .label {{ fill: {border} !important; }} + .label {{ color: {text} !important; }} + .relationshipLine {{ stroke: {line} !important; fill: none !important; }} + .entityBox {{ fill: {primary}; stroke: {border}; }} + .node .row-rect-odd path {{ fill: {er_odd} !important; }} + .node .row-rect-even path {{ fill: {er_even} !important; }} + .edge-thickness-normal {{ stroke-width: 1px; }} + .relation {{ stroke: {line}; stroke-width: 1; fill: none; }} + .edgePaths path {{ fill: none; }} + .marker {{ fill: {line} !important; stroke: {line} !important; }} + .marker.er {{ fill: none !important; stroke: {line} !important; }} + .composition {{ fill: {line} !important; stroke: {line} !important; stroke-width: 1; }} + .extension {{ fill: transparent !important; stroke: {line} !important; stroke-width: 1; }} + .aggregation {{ fill: transparent !important; stroke: {line} !important; stroke-width: 1; }} + .dependency {{ fill: {line} !important; stroke: {line} !important; stroke-width: 1; }} + .lollipop {{ fill: {primary} !important; stroke: {line} !important; stroke-width: 1; }} + .sectionTitle0, .sectionTitle1, .sectionTitle2, .sectionTitle3 {{ fill: {text} !important; }} + .sectionTitle {{ font-family: {font} !important; }} + .taskTextOutsideRight {{ fill: {text} !important; font-family: {font} !important; }} + .taskTextOutsideLeft {{ fill: {text} !important; }} + .active0, .active1, .active2, .active3 {{ fill: {secondary} !important; stroke: {border} !important; }} + .activeText0, .activeText1, .activeText2, .activeText3 {{ fill: {text} !important; }} + .done0, .done1, .done2, .done3 {{ stroke: {border} !important; fill: {secondary} !important; stroke-width: 2; }} + .doneText0, .doneText1, .doneText2, .doneText3 {{ fill: {text} !important; }} + .crit0, .crit1, .crit2, .crit3 {{ fill: {error} !important; stroke: {error} !important; }} + .critText0, .critText1, .critText2, .critText3 {{ fill: {error_text} !important; }} + .activeCrit0, .activeCrit1, .activeCrit2, .activeCrit3 {{ fill: {warning} !important; stroke: {warning} !important; }} + .activeCritText0, .activeCritText1, .activeCritText2, .activeCritText3 {{ fill: {warning_text} !important; }} + .doneCrit0, .doneCrit1, .doneCrit2, .doneCrit3 {{ fill: {error} !important; stroke: {border} !important; stroke-width: 2; }} + .doneCritText0, .doneCritText1, .doneCritText2, .doneCritText3 {{ fill: {error_text} !important; }} + .titleText {{ fill: {text} !important; font-family: {font} !important; }} + .grid .tick text {{ fill: {text} !important; font-family: {font} !important; }} + .grid .tick {{ stroke: {border} !important; }} + {git_branch_css} + .commit-merge {{ stroke: {primary}; fill: {primary}; }} + .commit-reverse {{ stroke: {primary}; fill: {primary}; stroke-width: 3; }} + .commit-highlight-inner {{ stroke: {primary}; fill: {primary}; }} + .tag-label {{ font-size: 10px; }} + .tag-label-bkg {{ fill: {primary}; stroke: {border}; }} + .tag-hole {{ fill: {line}; }} + .commit-label {{ fill: {text}; }} + .commit-label-bkg {{ fill: {edge_label_bg}; }} + .commit-id, .commit-msg, .branch-label {{ fill: {text}; color: {text}; font-family: {font}; }} + {accent_css} + .data-point text {{ fill: {text} !important; }} + {chart_color_css} + "#, + mindmap_css = mindmap_section_css(theme), + git_branch_css = git_branch_css(theme), + accent_css = accent_css(theme), + chart_color_css = chart_color_css(theme), + timeline_css = timeline_css(theme), + ); + + scope_css(&raw_css, svg_id) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scope_css_prefixes_selectors() { + let input = " .foo { color: red; }\n"; + let result = scope_css(input, "my-svg"); + assert!(result.contains("#my-svg .foo"), "got: {result}"); + } +} diff --git a/crates/mermaid_render/src/postprocess/strip_foreignobject.rs b/crates/mermaid_render/src/postprocess/strip_foreignobject.rs new file mode 100644 index 00000000000..8935c73e392 --- /dev/null +++ b/crates/mermaid_render/src/postprocess/strip_foreignobject.rs @@ -0,0 +1,114 @@ +//! Strips `` elements and their contents from the SVG, since +//! `usvg`/`resvg` does not support them. +//! +//! ```xml +//! +//!
Hello
+//! Hello +//! +//! +//! Hello +//! ``` + +use anyhow::Result; +use quick_xml::events::Event; + +struct StripForeignObject { + inner: I, + /// Depth inside a `` element being stripped. + foreign_depth: usize, + /// Depth inside a `` being stripped. + fallback_depth: usize, + /// Set to true once we see a `` element outside of foreignObjects + /// and fallback groups. When true, fallback groups are redundant and + /// should be stripped. + has_native_text: bool, +} + +impl<'a, I: Iterator>>> Iterator for StripForeignObject { + type Item = Result>; + + fn next(&mut self) -> Option { + loop { + let event = self.inner.next()?; + let event = match event { + Ok(event) => event, + Err(e) => return Some(Err(e)), + }; + + // Strip foreignObject elements and their contents. + match &event { + Event::Start(e) if e.name().as_ref() == b"foreignObject" => { + self.foreign_depth += 1; + continue; + } + Event::Start(_) if self.foreign_depth > 0 => { + self.foreign_depth += 1; + continue; + } + Event::End(_) if self.foreign_depth > 0 => { + self.foreign_depth -= 1; + continue; + } + Event::Empty(e) if e.name().as_ref() == b"foreignObject" => { + continue; + } + _ if self.foreign_depth > 0 => { + continue; + } + _ => {} + } + + // Strip fallback groups when native text exists. + match &event { + Event::Start(e) if e.name().as_ref() == b"g" && self.fallback_depth == 0 => { + if self.has_native_text { + if let Ok(Some(attr)) = e.try_get_attribute("data-merman-foreignobject") { + if attr.value.as_ref() == b"fallback" { + self.fallback_depth = 1; + continue; + } + } + } + } + Event::Start(_) if self.fallback_depth > 0 => { + self.fallback_depth += 1; + continue; + } + Event::End(_) if self.fallback_depth > 0 => { + self.fallback_depth -= 1; + continue; + } + _ if self.fallback_depth > 0 => { + continue; + } + _ => {} + } + + // Track whether the diagram has native elements. + if !self.has_native_text { + match &event { + Event::Start(e) | Event::Empty(e) if e.name().as_ref() == b"text" => { + if e.try_get_attribute("class").ok().flatten().is_some() { + self.has_native_text = true; + } + } + _ => {} + } + } + + return Some(Ok(event)); + } + } +} + +pub(super) fn process<'a>( + inner: impl Iterator>>, +) -> impl Iterator>> { + StripForeignObject { + inner, + foreign_depth: 0, + fallback_depth: 0, + has_native_text: false, + } +} diff --git a/crates/mermaid_render/src/postprocess/strip_invalid_css.rs b/crates/mermaid_render/src/postprocess/strip_invalid_css.rs new file mode 100644 index 00000000000..12efb99be2e --- /dev/null +++ b/crates/mermaid_render/src/postprocess/strip_invalid_css.rs @@ -0,0 +1,161 @@ +//! Removes CSS constructs that `usvg`/`resvg` cannot handle. +//! +//! - `@keyframes` and `@-webkit-keyframes` blocks +//! - `:root { ... }` blocks (CSS custom properties) +//! - `:not(...)` pseudo-selectors +//! - `deg` angle units (e.g. `rotate(45deg)` → `rotate(45)`) +//! +//! Also removes `!important` declarations (so that our injected theme CSS +//! always wins). + +use std::borrow::Cow; + +use anyhow::Result; +use quick_xml::events::{BytesText, Event}; + +struct StripInvalidCss { + inner: I, + in_style: bool, +} + +impl<'a, I: Iterator>>> Iterator for StripInvalidCss { + type Item = Result>; + + fn next(&mut self) -> Option { + let event = match self.inner.next()? { + Ok(ev) => ev, + Err(e) => return Some(Err(e)), + }; + + match &event { + Event::Start(e) if e.name().as_ref() == b"style" => { + self.in_style = true; + } + Event::End(e) if e.name().as_ref() == b"style" => { + self.in_style = false; + } + Event::Text(text) if self.in_style => { + let css_text = match std::str::from_utf8(text.as_ref()) { + Ok(s) => s, + Err(e) => return Some(Err(e.into())), + }; + return Some(match strip_unsupported_css(css_text) { + Cow::Borrowed(_) => Ok(event), + Cow::Owned(processed) => Ok(Event::Text(BytesText::from_escaped(processed))), + }); + } + _ => {} + } + + Some(Ok(event)) + } +} + +pub(super) fn process<'a>( + events: impl Iterator>>, +) -> impl Iterator>> { + StripInvalidCss { + inner: events, + in_style: false, + } +} + +fn strip_unsupported_css(css: &str) -> Cow<'_, str> { + let mut chars = css.char_indices().peekable(); + let mut result = None; + let mut copied_until = 0; + + while let Some((i, _)) = chars.next() { + let remaining = &css[i..]; + + if remaining.starts_with("@keyframes") + || remaining.starts_with("@-webkit-keyframes") + || remaining.starts_with(":root") + { + let result = result.get_or_insert_with(|| String::with_capacity(css.len())); + result.push_str(&css[copied_until..i]); + skip_css_block(&mut chars); + copied_until = chars.peek().map_or(css.len(), |&(i, _)| i); + } + } + + let mut result = if let Some(mut result) = result { + result.push_str(&css[copied_until..]); + Cow::Owned(result) + } else { + Cow::Borrowed(css) + }; + + strip_css_angle_units(&mut result); + strip_css_important(&mut result); + result +} + +fn skip_css_block(chars: &mut std::iter::Peekable) { + for (_, c) in chars.by_ref() { + if c == '{' { + break; + } + } + let mut depth = 1u32; + for (_, c) in chars.by_ref() { + match c { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return; + } + } + _ => {} + } + } +} + +fn replace_all_in_place(css: &mut Cow<'_, str>, needle: &str, replacement: &str) { + while let Some(pos) = css.as_ref().find(needle) { + css.to_mut() + .replace_range(pos..pos + needle.len(), replacement); + } +} + +fn strip_css_angle_units(css: &mut Cow<'_, str>) { + replace_all_in_place(css, "deg)", ")"); +} + +/// Strip `!important` from mermaid's generated CSS so that our injected +/// theme CSS (which uses `!important`) always takes priority. This works +/// around a usvg cascade bug where competing `!important` rules are +/// resolved by first-wins rather than the CSS spec's last-wins. +fn strip_css_important(css: &mut Cow<'_, str>) { + replace_all_in_place(css, "!important", ""); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strips_keyframes() { + let input = "@keyframes bounce { 0% { transform: scale(1); } 100% { transform: scale(1.1); } } .node rect { fill: red; }"; + let result = strip_unsupported_css(input); + assert!(!result.contains("@keyframes"), "got: {result}"); + assert!(result.contains(".node rect"), "got: {result}"); + } + + #[test] + fn strips_root_blocks() { + let input = ":root { --bg: white; } .foo { color: red; }"; + let result = strip_unsupported_css(input); + assert!(!result.contains(":root"), "got: {result}"); + assert!(result.contains(".foo"), "got: {result}"); + } + + #[test] + fn strips_deg_units() { + let input = ".foo { transform: rotate(45deg); }"; + let result = strip_unsupported_css(input); + assert!(result.contains("rotate(45)"), "got: {result}"); + assert!(!result.contains("deg"), "got: {result}"); + } +} diff --git a/crates/mermaid_render/src/postprocess/util.rs b/crates/mermaid_render/src/postprocess/util.rs new file mode 100644 index 00000000000..70df1af058e --- /dev/null +++ b/crates/mermaid_render/src/postprocess/util.rs @@ -0,0 +1,148 @@ +use gpui::{Hsla, Rgba}; + +/// Produces a readable text color for a given background, subtly tinted by the +/// background's own hue using the OKLCH color space. +/// +/// The result keeps ~15% of the background's chroma so the text feels +/// harmonious with its surroundings rather than a flat black or white. +/// Lightness is set to ensure readable contrast against the background. +pub fn text_color_for_background(background: Hsla) -> Hsla { + let rgba = Rgba::from(background); + let r_lin = srgb_to_linear(rgba.r); + let g_lin = srgb_to_linear(rgba.g); + let b_lin = srgb_to_linear(rgba.b); + + let (_, ok_a, ok_b) = linear_rgb_to_oklab(r_lin, g_lin, b_lin); + let chroma = (ok_a * ok_a + ok_b * ok_b).sqrt(); + let hue = ok_b.atan2(ok_a); + + let bg_luminance = relative_luminance(rgba); + let text_l = if bg_luminance > 0.18 { 0.18 } else { 0.96 }; + let text_c = chroma * 0.15; + + let build = |c: f32| -> Rgba { + let (tr, tg, tb) = oklab_to_linear_rgb(text_l, c * hue.cos(), c * hue.sin()); + Rgba { + r: linear_to_srgb(tr.clamp(0.0, 1.0)), + g: linear_to_srgb(tg.clamp(0.0, 1.0)), + b: linear_to_srgb(tb.clamp(0.0, 1.0)), + a: 1.0, + } + }; + + let meets_contrast = + |fg: Rgba| contrast_ratio_between(bg_luminance, relative_luminance(fg)) >= 4.5; + + let candidate = build(text_c); + let result = if meets_contrast(candidate) { + candidate + } else { + // Binary search for the maximum chroma that still meets 4.5:1. + let mut lo = 0.0_f32; + let mut hi = text_c; + for _ in 0..16 { + let mid = (lo + hi) * 0.5; + if meets_contrast(build(mid)) { + lo = mid; + } else { + hi = mid; + } + } + let best = build(lo); + // Floating-point precision can leave the binary search result just + // below the 4.5:1 threshold. Fall back to pure black or white. + if meets_contrast(best) { + best + } else if bg_luminance > 0.18 { + Rgba { + r: 0.0, + g: 0.0, + b: 0.0, + a: 1.0, + } + } else { + Rgba { + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + } + } + }; + Hsla::from(result) +} + +fn srgb_to_linear(c: f32) -> f32 { + if c <= 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } +} + +fn linear_to_srgb(c: f32) -> f32 { + if c <= 0.0031308 { + c * 12.92 + } else { + 1.055 * c.powf(1.0 / 2.4) - 0.055 + } +} + +fn linear_rgb_to_oklab(r: f32, g: f32, b: f32) -> (f32, f32, f32) { + let l = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b).cbrt(); + let m = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b).cbrt(); + let s = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b).cbrt(); + ( + 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s, + ) +} + +fn oklab_to_linear_rgb(l: f32, a: f32, b: f32) -> (f32, f32, f32) { + let l_ = l + 0.3963377774 * a + 0.2158037573 * b; + let m_ = l - 0.1055613458 * a - 0.0638541728 * b; + let s_ = l - 0.0894841775 * a - 1.2914855480 * b; + ( + 4.0767416621 * l_ * l_ * l_ - 3.3077115913 * m_ * m_ * m_ + 0.2309699292 * s_ * s_ * s_, + -1.2684380046 * l_ * l_ * l_ + 2.6097574011 * m_ * m_ * m_ - 0.3413193965 * s_ * s_ * s_, + -0.0041960863 * l_ * l_ * l_ - 0.7034186147 * m_ * m_ * m_ + 1.7076147010 * s_ * s_ * s_, + ) +} + +fn relative_luminance(c: Rgba) -> f32 { + 0.2126 * srgb_to_linear(c.r) + 0.7152 * srgb_to_linear(c.g) + 0.0722 * srgb_to_linear(c.b) +} + +fn contrast_ratio_between(luminance_a: f32, luminance_b: f32) -> f32 { + let (lighter, darker) = if luminance_a > luminance_b { + (luminance_a, luminance_b) + } else { + (luminance_b, luminance_a) + }; + (lighter + 0.05) / (darker + 0.05) +} + +#[cfg(test)] +fn wcag_contrast_ratio(a: Rgba, b: Rgba) -> f32 { + contrast_ratio_between(relative_luminance(a), relative_luminance(b)) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::proptest::prelude::*; + + #[gpui::property_test] + fn sufficient_contrast_for_any_opaque_background( + #[strategy = Hsla::opaque_strategy()] bg: Hsla, + ) -> Result<(), TestCaseError> { + let text = text_color_for_background(bg); + let ratio = wcag_contrast_ratio(Rgba::from(bg), Rgba::from(text)); + prop_assert!( + ratio >= 4.5, + "WCAG AA contrast ratio {ratio:.2} < 4.5 for bg {bg:?} -> text {text:?}", + ); + Ok(()) + } +} diff --git a/crates/mermaid_render/src/render.rs b/crates/mermaid_render/src/render.rs new file mode 100644 index 00000000000..22c49d3f35f --- /dev/null +++ b/crates/mermaid_render/src/render.rs @@ -0,0 +1,122 @@ +use std::sync::atomic::{AtomicU64, Ordering}; + +use anyhow::{Context as _, Result, anyhow}; + +use crate::{MermaidTheme, css_color}; + +pub(super) fn render_mermaid(source: &str, theme: &MermaidTheme) -> Result { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let id = COUNTER.fetch_add(1, Ordering::Relaxed); + let diagram_id = format!("merman-{id}"); + + let config = to_merman_config(theme); + let renderer = merman::render::HeadlessRenderer::new() + .with_site_config(config) + .with_vendored_text_measurer() + .with_diagram_id(&diagram_id); + + let svg = renderer + .render_svg_sync(source) + .context("merman render failed")? + .ok_or_else(|| anyhow!("merman returned no SVG for the given input"))?; + + Ok(svg) +} + +fn to_merman_config(theme: &MermaidTheme) -> merman::MermaidConfig { + let primary = css_color(theme.primary_color); + let primary_text = css_color(theme.primary_text_color); + let primary_border = css_color(theme.primary_border_color); + let line = css_color(theme.line_color); + let secondary = css_color(theme.secondary_color); + let tertiary = css_color(theme.tertiary_color); + let background = css_color(theme.background); + let cluster_bg = css_color(theme.cluster_background); + let cluster_border = css_color(theme.cluster_border); + let edge_label_bg = css_color(theme.edge_label_background); + let text = css_color(theme.text_color); + let note_bg = css_color(theme.note_background); + let note_border = css_color(theme.note_border); + let actor_bg = css_color(theme.actor_background); + let actor_border = css_color(theme.actor_border); + let activation_bg = css_color(theme.activation_background); + let activation_border = css_color(theme.activation_border); + let er_odd = css_color(theme.er_attr_bg_odd); + let er_even = css_color(theme.er_attr_bg_even); + let git: [String; 8] = theme.git_branch_colors.map(css_color); + let git_lbl: [String; 8] = theme.git_branch_label_colors.map(css_color); + + let mut theme_vars = serde_json::json!({ + "primaryColor": primary, + "primaryTextColor": primary_text, + "primaryBorderColor": primary_border, + "lineColor": line, + "secondaryColor": secondary, + "secondaryTextColor": text, + "tertiaryColor": tertiary, + "tertiaryTextColor": text, + "background": background, + "mainBkg": primary, + "nodeBorder": primary_border, + "nodeTextColor": primary_text, + "clusterBkg": cluster_bg, + "clusterBorder": cluster_border, + "titleColor": text, + "edgeLabelBackground": edge_label_bg, + "textColor": text, + "fontFamily": theme.font_family, + "noteBkgColor": note_bg, + "noteBorderColor": note_border, + "noteTextColor": text, + "actorBkg": actor_bg, + "actorBorder": actor_border, + "actorTextColor": primary_text, + "labelTextColor": text, + "loopTextColor": text, + "signalColor": text, + "signalTextColor": text, + "activationBkgColor": activation_bg, + "activationBorderColor": activation_border, + "classText": text, + "labelColor": primary_text, + "attributeBackgroundColorOdd": er_odd, + "attributeBackgroundColorEven": er_even, + "pieTitleTextColor": text, + "pieSectionTextColor": text, + "pieLegendTextColor": text, + "pieStrokeColor": primary_border, + "pieOuterStrokeColor": primary_border, + "quadrant1Fill": primary, + "quadrant2Fill": primary, + "quadrant3Fill": primary, + "quadrant4Fill": primary, + "quadrant1TextFill": text, + "quadrant2TextFill": text, + "quadrant3TextFill": text, + "quadrant4TextFill": text, + "quadrantPointFill": line, + "quadrantPointTextFill": text, + "quadrantTitleFill": text, + "quadrantXAxisTextFill": text, + "quadrantYAxisTextFill": text, + "quadrantExternalBorderStrokeFill": primary_border, + "quadrantInternalBorderStrokeFill": primary_border, + }); + + let map = theme_vars.as_object_mut().expect("just created as object"); + for i in 0..8 { + map.insert(format!("cScale{i}"), git[i].clone().into()); + map.insert(format!("cScaleLabel{i}"), git_lbl[i].clone().into()); + map.insert(format!("pie{}", i + 1), git[i].clone().into()); + } + + merman::MermaidConfig::from_value(serde_json::json!({ + "theme": "base", + "darkMode": theme.dark_mode, + "fontFamily": theme.font_family, + "flowchart": { + "padding": 16, + }, + "themeVariables": theme_vars, + })) +} diff --git a/crates/mermaid_render/tests/check_invalid_attrs.rs b/crates/mermaid_render/tests/check_invalid_attrs.rs new file mode 100644 index 00000000000..e4d49384bf2 --- /dev/null +++ b/crates/mermaid_render/tests/check_invalid_attrs.rs @@ -0,0 +1,394 @@ +use gpui::Hsla; +use mermaid_render::MermaidTheme; + +fn rgb(r: u8, g: u8, b: u8) -> Hsla { + gpui::Rgba { + r: r as f32 / 255.0, + g: g as f32 / 255.0, + b: b as f32 / 255.0, + a: 1.0, + } + .into() +} + +const DIAGRAMS: &[(&str, &str)] = &[ + ( + "flowchart", + "flowchart TD\n A[Hello] --> B[World]\n B --> C{Decision}\n C -->|Yes| D[OK]\n C -->|No| E[Fail]", + ), + ( + "sequence", + "sequenceDiagram\n Alice->>Bob: Hello\n Bob-->>Alice: Hi\n Note over Alice,Bob: A note", + ), + ( + "state", + "stateDiagram-v2\n [*] --> Active\n Active --> [*]", + ), + ( + "er", + "erDiagram\n A { int id PK }\n B { int id PK }\n A ||--o{ B : has", + ), + ( + "class", + "classDiagram\n class Foo {\n +bar() void\n }", + ), + ("pie", "pie title Test\n \"A\" : 42\n \"B\" : 58"), + ( + "gantt", + "gantt\n title Test\n dateFormat YYYY-MM-DD\n section S\n Task :a1, 2025-01-01, 7d", + ), + ("mindmap", "mindmap\n root((Root))\n Child1\n Child2"), + ( + "journey", + "journey\n title Test\n section S\n Task: 5: Actor", + ), + ( + "gitgraph", + "gitGraph\n commit id: \"init\"\n branch dev\n commit id: \"feat\"\n checkout main\n merge dev", + ), + ( + "quadrant", + "quadrantChart\n title Test\n x-axis Low --> High\n y-axis Low --> High\n A: [0.3, 0.8]\n B: [0.7, 0.4]", + ), + ( + "timeline", + "timeline\n title Test\n section 2020s\n 2020 : Event A\n 2022 : Event B", + ), + ( + "xychart", + "xychart-beta\n title Test\n x-axis [\"A\", \"B\", \"C\"]\n y-axis \"Val\" 0 --> 10\n bar [3, 7, 5]", + ), +]; + +fn rgb_theme() -> MermaidTheme { + MermaidTheme { + dark_mode: true, + font_family: "system-ui".to_string(), + background: rgb(40, 44, 51), + primary_color: rgb(47, 52, 62), + primary_text_color: rgb(220, 224, 229), + primary_border_color: rgb(70, 75, 87), + secondary_color: rgb(46, 52, 62), + tertiary_color: rgb(54, 60, 70), + line_color: rgb(70, 75, 87), + text_color: rgb(220, 224, 229), + edge_label_background: rgb(40, 44, 51), + cluster_background: rgb(47, 52, 62), + cluster_border: rgb(54, 60, 70), + note_background: rgb(47, 52, 62), + note_border: rgb(54, 60, 70), + actor_background: rgb(46, 52, 62), + actor_border: rgb(70, 75, 87), + activation_background: rgb(54, 60, 70), + activation_border: rgb(70, 75, 87), + git_branch_colors: [ + rgb(116, 173, 232), + rgb(190, 80, 70), + rgb(191, 149, 106), + rgb(180, 119, 207), + rgb(110, 180, 191), + rgb(208, 114, 119), + rgb(222, 193, 132), + rgb(161, 193, 129), + ], + git_branch_label_colors: [ + rgb(116, 173, 232), + rgb(190, 80, 70), + rgb(191, 149, 106), + rgb(180, 119, 207), + rgb(110, 180, 191), + rgb(208, 114, 119), + rgb(222, 193, 132), + rgb(161, 193, 129), + ] + .map(mermaid_render::text_color_for_background), + er_attr_bg_odd: rgb(47, 52, 62), + er_attr_bg_even: rgb(46, 52, 62), + error_color: rgb(220, 38, 38), + warning_color: rgb(217, 119, 6), + accent_colors: vec![ + mermaid_render::AccentColor { + foreground: rgb(116, 173, 232), + background: rgb(116, 173, 232), + }, + mermaid_render::AccentColor { + foreground: rgb(190, 80, 70), + background: rgb(190, 80, 70), + }, + mermaid_render::AccentColor { + foreground: rgb(191, 149, 106), + background: rgb(191, 149, 106), + }, + mermaid_render::AccentColor { + foreground: rgb(180, 119, 207), + background: rgb(180, 119, 207), + }, + mermaid_render::AccentColor { + foreground: rgb(110, 180, 191), + background: rgb(110, 180, 191), + }, + mermaid_render::AccentColor { + foreground: rgb(208, 114, 119), + background: rgb(208, 114, 119), + }, + mermaid_render::AccentColor { + foreground: rgb(222, 193, 132), + background: rgb(222, 193, 132), + }, + mermaid_render::AccentColor { + foreground: rgb(161, 193, 129), + background: rgb(161, 193, 129), + }, + ], + } +} + +fn check_svg_issues(name: &str, svg: &str) -> Vec { + let bad_patterns = [ + "fill=\"\"", + "stroke=\"\"", + "width=\"\"", + "height=\"\"", + "NaN", + // Also check for empty values in style attributes + "fill: ;", + "fill:;", + "stroke: ;", + "stroke:;", + // Check for attributes with just whitespace + "fill=\" \"", + ]; + let mut issues = Vec::new(); + for pattern in &bad_patterns { + let mut start = 0; + while let Some(pos) = svg[start..].find(pattern) { + let abs = start + pos; + let ctx_start = abs.saturating_sub(100); + let ctx_end = (abs + pattern.len() + 60).min(svg.len()); + issues.push(format!( + "{name}: found `{pattern}` at byte {abs}:\n ...{}...\n", + &svg[ctx_start..ctx_end] + )); + start = abs + pattern.len(); + } + } + + // Parse with quick-xml to find ANY empty attribute values on visual elements + use quick_xml::events::Event; + let mut reader = quick_xml::Reader::from_str(svg); + loop { + match reader.read_event() { + Ok(Event::Eof) => break, + Ok(Event::Start(e)) | Ok(Event::Empty(e)) => { + let tag = String::from_utf8_lossy(e.name().local_name().as_ref()).to_string(); + for attr in e.attributes().flatten() { + let key = String::from_utf8_lossy(attr.key.local_name().as_ref()).to_string(); + let val = attr.unescape_value().unwrap_or_default(); + let visual_attr = matches!( + key.as_str(), + "fill" + | "stroke" + | "width" + | "height" + | "x" + | "y" + | "r" + | "cx" + | "cy" + | "rx" + | "ry" + | "stroke-width" + ); + if visual_attr && val.is_empty() { + issues.push(format!("{name}: <{tag}> has empty {key}=\"\"\n")); + } + // Check for CSS length units that usvg can't parse + if visual_attr + && matches!(key.as_str(), "width" | "height") + && val.ends_with("px") + { + issues.push(format!("{name}: <{tag}> has {key}=\"{val}\" (px suffix)\n")); + } + } + } + Err(e) => { + issues.push(format!("{name}: XML parse error: {e}\n")); + break; + } + _ => {} + } + } + + issues +} + +#[test] +fn accent_colors_auto_applied_to_nodes() { + let theme = rgb_theme(); + + // A plain state diagram with no :::accent syntax should get + // automatic accent colors applied to its node groups. + let source = "stateDiagram-v2\n [*] --> Idle\n Idle --> Processing\n Processing --> Done\n Done --> [*]"; + + let svg = mermaid_render::render_to_svg(source, &theme).expect("render failed"); + + // accent_fill_and_text darkens the background color for dark mode. + // The stroke colors are direct hex conversions of the accent rgb values. + // With 3 states (Idle, Processing, Done), we expect at least accent0 and + // accent1 stroke colors to appear. + let accent0_stroke = "#74ade8"; // rgb(116, 173, 232) -> hex + let accent1_stroke = "#be5046"; // rgb(190, 80, 70) -> hex + + assert!( + svg.contains(accent0_stroke), + "Expected accent0 stroke color ({accent0_stroke}) in auto-colored state diagram SVG.\n\ + This means auto-coloring did not apply accent colors to node groups.\n\ + SVG snippet: {}...", + &svg[..svg.len().min(2000)] + ); + assert!( + svg.contains(accent1_stroke), + "Expected accent1 stroke color ({accent1_stroke}) in auto-colored state diagram SVG." + ); +} + +#[test] +fn generics_not_double_escaped() { + let theme = rgb_theme(); + let source = "classDiagram\n class Shelter {\n -List~Animal~ animals\n +adopt(Animal a) bool\n }"; + let svg = mermaid_render::render_to_svg(source, &theme).expect("render failed"); + assert!( + !svg.contains("&lt;"), + "Double-escaped &lt; found in SVG" + ); + assert!( + !svg.contains("&gt;"), + "Double-escaped &gt; found in SVG" + ); +} + +#[test] +fn backslash_n_converted_to_line_break() { + let theme = rgb_theme(); + let source = r#"graph TD + L7["Layer 7\nHTTP, FTP"] + L6["Layer 6\nEncryption"] + L7 --> L6"#; + let svg = mermaid_render::render_to_svg(source, &theme).expect("render failed"); + assert!( + !svg.contains(r"\n"), + "Literal \\n should not appear in SVG output" + ); + assert!( + svg.contains(">Layer 7<") && svg.contains(">HTTP, FTP<"), + "Label lines should be split into separate elements" + ); +} + +#[test] +fn class_diagram_fallback_text_uses_accent_classes() { + let theme = rgb_theme(); + let source = r#"classDiagram + class Animal { + +String name + +makeSound() void + } + class Dog { + +String breed + +bark() void + } + Dog --|> Animal"#; + + let svg = mermaid_render::render_to_svg(source, &theme).expect("render failed"); + + use quick_xml::events::Event; + let mut reader = quick_xml::Reader::from_str(&svg); + let mut in_fallback = false; + let mut accent_classes: Vec = Vec::new(); + loop { + match reader.read_event() { + Ok(Event::Eof) => break, + Ok(Event::Start(e)) => { + if e.name().as_ref() == b"g" { + if let Ok(Some(attr)) = e.try_get_attribute("data-merman-foreignobject") { + if attr.value.as_ref() == b"fallback" { + in_fallback = true; + } + } + } + if in_fallback && e.name().as_ref() == b"text" { + if let Ok(Some(class_attr)) = e.try_get_attribute("class") { + let class = class_attr.unescape_value().unwrap_or_default().to_string(); + for token in class.split_whitespace() { + if token.starts_with("zed-accent-") { + accent_classes.push(token.to_string()); + } + } + } + } + } + Ok(Event::End(e)) if e.name().as_ref() == b"g" => { + in_fallback = false; + } + _ => {} + } + } + + assert!( + !accent_classes.is_empty(), + "expected zed-accent-N classes on text elements in fallback groups", + ); +} + +#[test] +fn sequence_diagram_tspan_uses_accent_classes() { + let theme = rgb_theme(); + let source = "sequenceDiagram\n participant Database"; + let svg = mermaid_render::render_to_svg(source, &theme).expect("render failed"); + + use quick_xml::events::Event; + let mut reader = quick_xml::Reader::from_str(&svg); + let mut accent_classes: Vec = Vec::new(); + loop { + match reader.read_event() { + Ok(Event::Eof) => break, + Ok(Event::Start(e)) if e.name().as_ref() == b"tspan" => { + if let Ok(Some(class_attr)) = e.try_get_attribute("class") { + let class = class_attr.unescape_value().unwrap_or_default().to_string(); + for token in class.split_whitespace() { + if token.starts_with("zed-accent-") { + accent_classes.push(token.to_string()); + } + } + } + } + _ => {} + } + } + + assert!( + !accent_classes.is_empty(), + "expected zed-accent-N classes on tspan elements in sequence diagram", + ); +} + +#[test] +fn no_empty_attributes_or_nan_with_rgb_theme() { + let theme = rgb_theme(); + let mut all_issues = Vec::new(); + + for (name, source) in DIAGRAMS { + match mermaid_render::render_to_svg(source, &theme) { + Ok(svg) => all_issues.extend(check_svg_issues(name, &svg)), + Err(e) => eprintln!("{name}: render failed (skipped): {e}"), + } + } + + if !all_issues.is_empty() { + panic!( + "Found {} issues in merman SVG output (rgb theme):\n\n{}", + all_issues.len(), + all_issues.join("\n") + ); + } +} diff --git a/typos.toml b/typos.toml index 22823e6b2d9..4f877457dc1 100644 --- a/typos.toml +++ b/typos.toml @@ -98,6 +98,10 @@ extend-ignore-re = [ # Yarn Plug'n'Play "PnP", # `image` crate method: Delay::from_numer_denom_ms - "numer" + "numer", + # Abbreviation for foreignObject in mermaid SVG processing + "fo", + # Mermaid CSS class name for state diagram composites + "composit" ] check-filename = true