From 76697ac01117aaf3fee0478617e426568a1e5a1b Mon Sep 17 00:00:00 2001 From: Tony D'Addeo Date: Sat, 31 Oct 2026 09:46:46 -0500 Subject: [PATCH 1/2] feat: add render.Options.theme to write the theme as literal values --- .changeset/theme-option.md | 5 + crates/compiler/src/lib.rs | 40 +- crates/compiler/src/svg/resolve.rs | 402 ++++++++++++++++++ crates/compiler/src/svg/root.rs | 6 +- crates/compiler/src/svg/theme.rs | 2 +- crates/compiler/tests/theme.rs | 143 +++++++ site/src/content/docs/api/compiler/render.mdx | 19 +- site/src/content/docs/guides/theming.mdx | 15 + src/Render.test.ts | 31 ++ src/Render.ts | 63 +++ 10 files changed, 722 insertions(+), 4 deletions(-) create mode 100644 .changeset/theme-option.md create mode 100644 crates/compiler/src/svg/resolve.rs create mode 100644 crates/compiler/tests/theme.rs diff --git a/.changeset/theme-option.md b/.changeset/theme-option.md new file mode 100644 index 0000000..6000e16 --- /dev/null +++ b/.changeset/theme-option.md @@ -0,0 +1,5 @@ +--- +'ondine': patch +--- + +Added the `theme` render option, which writes the theme into the SVG as literal values. The output has no `var()` or `color-mix()`, so renderers without CSS custom properties draw it, such as resvg, librsvg, and native SVG parsers. diff --git a/crates/compiler/src/lib.rs b/crates/compiler/src/lib.rs index a775afd..e0da3fb 100644 --- a/crates/compiler/src/lib.rs +++ b/crates/compiler/src/lib.rs @@ -24,6 +24,7 @@ mod svg { pub(crate) mod builder; pub(crate) mod cluster; pub(crate) mod edges; + pub(crate) mod resolve; pub(crate) mod root; pub(crate) mod shapes; pub(crate) mod theme; @@ -182,7 +183,10 @@ pub fn render(source: &str, options: &str, now: f64) -> Result internal: link.internal, }) .collect(), - svg: drawn.svg, + svg: match &options.theme { + Some(theme) => svg::resolve::resolve(&drawn.svg, theme), + None => drawn.svg, + }, r#type, width: drawn.width, }; @@ -232,6 +236,39 @@ fn exceeds(text: &str, limit: f64) -> bool { units as f64 > limit } +/// Reads the `theme` option: an object of `--ondine-*` property names in camel case, without the +/// prefix, and their values. Keys become full property names, so `surfaceAlt` sets +/// `--ondine-surface-alt` and `series1` sets `--ondine-series-1`. A value that is not a string, or +/// that an author style could not hold, is ignored, so its property keeps its default. +fn theme(value: &yaml::Value) -> Option> { + let yaml::Value::Object(entries) = value else { + return None; + }; + + let mut theme = Vec::with_capacity(entries.len()); + for (key, value) in entries { + let yaml::Value::String(value) = value else { + continue; + }; + let value = value.trim(); + if value.is_empty() || svg::theme::unsafe_value(value) { + continue; + } + + let mut name = String::from("--ondine-"); + let mut digits = false; + for c in key.chars() { + if c.is_ascii_uppercase() || (c.is_ascii_digit() && !digits) { + name.push('-'); + } + name.push(c.to_ascii_lowercase()); + digits = c.is_ascii_digit(); + } + theme.push((name, svg::builder::esc(value).into_owned())); + } + Some(theme) +} + /// Reads host options from JSON. Unknown fields are ignored, as in Pele. fn options_from_json(json: &str) -> Result { let mut options = svg::root::RenderOptions::default(); @@ -287,6 +324,7 @@ fn options_from_json(json: &str) -> Result { "outputLimit" => options.output_limit = number(value), "padding" => options.padding = number(value), "responsive" => options.responsive = boolean(value), + "theme" => options.theme = theme(value), _ => {} } } diff --git a/crates/compiler/src/svg/resolve.rs b/crates/compiler/src/svg/resolve.rs new file mode 100644 index 0000000..623d72d --- /dev/null +++ b/crates/compiler/src/svg/resolve.rs @@ -0,0 +1,402 @@ +//! Writes the theme into an SVG document as literal values, for renderers without CSS custom +//! properties or `color-mix()`, such as server-side rasterizers, native SVG parsers, and email. +//! +//! Each `var()` in an attribute takes the host's value for its `--ondine-*` property, the root's +//! declaration of a private alias, or its own fallback. Each `color-mix()` of two parsed colors then +//! becomes one hex color. Declarations of custom properties are dropped, since nothing reads them. +//! Text content is copied as it is, so a label that spells out `var(` keeps its text. + +use crate::util::float; + +/// How deep values may nest. Ondine's aliases go three levels deep. The limit stops a host value +/// that refers to itself. +const MAX_DEPTH: u8 = 8; + +/// A parsed color: red, green, and blue from 0 to 255, and alpha from 0 to 1. +type Color = [f64; 4]; + +/// Where `var()` references read their values. +struct Lookup<'a> { + /// The custom properties the root element declares. + declared: Vec<(&'a str, &'a str)>, + /// The host's values by full property name, such as `--ondine-bg`. + theme: &'a [(String, String)], +} + +impl Lookup<'_> { + /// The value of the custom property `name`, when the host or the root sets one. + fn get(&self, name: &str) -> Option<&str> { + if name.starts_with("--ondine-") { + return self + .theme + .iter() + .find(|(key, _)| key == name) + .map(|(_, value)| value.as_str()); + } + self.declared + .iter() + .find(|(key, _)| *key == name) + .map(|(_, value)| *value) + } +} + +/// `svg` with every themed attribute written out with `theme`, a list of full +/// `--ondine-*` property names and their values. +pub(crate) fn resolve(svg: &str, theme: &[(String, String)]) -> String { + let lookup = Lookup { + declared: root_declarations(svg), + theme, + }; + + let mut out = String::with_capacity(svg.len()); + let mut rest = svg; + while let Some(open) = rest.find('<') { + out.push_str(rest.get(..open).unwrap_or("")); + rest = push_tag(&mut out, rest.get(open..).unwrap_or(""), &lookup); + } + out.push_str(rest); + out +} + +/// The custom properties declared in the `style` of the first `` tag. +fn root_declarations(svg: &str) -> Vec<(&str, &str)> { + let mut declared = Vec::new(); + let Some(tag) = svg.find("').unwrap_or(tag.len())).unwrap_or(""); + let Some(style) = tag.find(" style=\"").and_then(|at| tag.get(at + 8..)) else { + return declared; + }; + + let mut rest = style + .get(..style.find('"').unwrap_or(style.len())) + .unwrap_or(""); + loop { + let (part, next) = split_once_top(rest, b';'); + if let Some(colon) = part.find(':') { + let name = part.get(..colon).unwrap_or("").trim(); + if name.starts_with("--") { + declared.push((name, part.get(colon + 1..).unwrap_or("").trim())); + } + } + let Some(next) = next else { + return declared; + }; + rest = next; + } +} + +/// Appends the tag at the start of `text` with its themed attributes written out, and returns +/// the text after it. Attributes are escaped, so a `>` outside quotes ends the tag. +fn push_tag<'a>(out: &mut String, text: &'a str, lookup: &Lookup<'_>) -> &'a str { + let bytes = text.as_bytes(); + let mut copied = 0; + let mut name = ""; + let mut at = 1; + while let Some(&c) = bytes.get(at) { + match c { + b'>' => { + at += 1; + break; + } + b'=' => name = attribute_name(text, at), + b'"' | b'\'' => { + let start = at + 1; + let end = text + .get(start..) + .and_then(|rest| rest.find(char::from(c))) + .map_or(text.len(), |length| start + length); + let value = text.get(start..end).unwrap_or(""); + if themed(name) && value.contains('(') { + out.push_str(text.get(copied..start).unwrap_or("")); + if name == "style" { + push_style(out, value, lookup); + } else { + push_value(out, value, lookup); + } + copied = end; + } + name = ""; + at = end; + } + _ => {} + } + at += 1; + } + + let end = at.min(text.len()); + out.push_str(text.get(copied..end).unwrap_or("")); + text.get(end..).unwrap_or("") +} + +/// The name of the attribute whose `=` is at `eq`. +fn attribute_name(text: &str, eq: usize) -> &str { + let head = text.get(..eq).unwrap_or(""); + let start = head + .bytes() + .rposition(|c| !(c.is_ascii_alphanumeric() || matches!(c, b'-' | b':' | b'_'))) + .map_or(0, |at| at + 1); + head.get(start..).unwrap_or("") +} + +/// Whether the attribute `name` may hold a themed value. Names, links, and accessible text +/// hold authored text, which is copied as it is. +fn themed(name: &str) -> bool { + !(matches!(name, "class" | "href" | "id" | "xlink:href") + || name.starts_with("aria-") + || name.starts_with("data-")) +} + +/// Appends a `style` value without its custom properties and with the rest written out. +fn push_style(out: &mut String, style: &str, lookup: &Lookup<'_>) { + let mut first = true; + let mut rest = style; + loop { + let (part, next) = split_once_top(rest, b';'); + if !part.trim_start().starts_with("--") { + if !first { + out.push(';'); + } + push_value(out, part, lookup); + first = false; + } + let Some(next) = next else { + return; + }; + rest = next; + } +} + +/// Appends `value` with its `var()` references and color mixes written out. +fn push_value(out: &mut String, value: &str, lookup: &Lookup<'_>) { + if !value.contains('(') { + out.push_str(value); + return; + } + + let mut vars = String::with_capacity(value.len() + 16); + push_vars(&mut vars, value, lookup, 0); + push_mixes(out, &vars, 0); +} + +/// Appends `text` with each `var(--name, fallback)` replaced by the value of `--name`, or by +/// the fallback, written out in turn. A reference with neither is kept. +fn push_vars(out: &mut String, text: &str, lookup: &Lookup<'_>, depth: u8) { + let mut rest = text; + while let Some(start) = rest.find("var(") { + let Some(end) = close(rest, start + 3) else { + break; + }; + out.push_str(rest.get(..start).unwrap_or("")); + + let inner = rest.get(start + 4..end).unwrap_or(""); + let (name, fallback) = match inner.find(',') { + Some(comma) => ( + inner.get(..comma).unwrap_or(""), + inner.get(comma + 1..).map(str::trim), + ), + None => (inner, None), + }; + match lookup.get(name.trim()).or(fallback) { + Some(value) if depth < MAX_DEPTH => push_vars(out, value, lookup, depth + 1), + _ => out.push_str(rest.get(start..=end).unwrap_or("")), + } + rest = rest.get(end + 1..).unwrap_or(""); + } + out.push_str(rest); +} + +/// Appends `text` with each `color-mix()` evaluated, innermost first. A mix whose colors do not +/// parse becomes its first color, which every renderer draws. +fn push_mixes(out: &mut String, text: &str, depth: u8) { + const CALL: &str = "color-mix("; + + let mut rest = text; + while let Some(start) = rest.find(CALL) { + let Some(end) = close(rest, start + CALL.len() - 1) else { + break; + }; + out.push_str(rest.get(..start).unwrap_or("")); + + let raw = rest.get(start + CALL.len()..end).unwrap_or(""); + if depth < MAX_DEPTH { + let mut inner = String::with_capacity(raw.len()); + push_mixes(&mut inner, raw, depth + 1); + match mix(&inner) { + Some(color) => push_hex(out, color), + None => out.push_str(first_color(&inner)), + } + } else { + out.push_str(rest.get(start..=end).unwrap_or("")); + } + rest = rest.get(end + 1..).unwrap_or(""); + } + out.push_str(rest); +} + +/// The index of the parenthesis that closes the one at `open`. +fn close(text: &str, open: usize) -> Option { + let mut depth = 0_usize; + for (at, c) in text.bytes().enumerate().skip(open) { + match c { + b'(' => depth += 1, + b')' => { + depth = depth.saturating_sub(1); + if depth == 0 { + return Some(at); + } + } + _ => {} + } + } + None +} + +/// `text` before the first `separator` outside parentheses, and the text after it, if any. +fn split_once_top(text: &str, separator: u8) -> (&str, Option<&str>) { + let mut depth = 0_usize; + for (at, c) in text.bytes().enumerate() { + match c { + b'(' => depth += 1, + b')' => depth = depth.saturating_sub(1), + c if c == separator && depth == 0 => { + return (text.get(..at).unwrap_or(""), text.get(at + 1..)); + } + _ => {} + } + } + (text, None) +} + +/// The arguments of a `color-mix()`: the color space and two colors with optional percentages. +fn arguments(inner: &str) -> Option<[&str; 3]> { + let (space, rest) = split_once_top(inner, b','); + let (first, second) = split_once_top(rest?, b','); + let second = second?; + if split_once_top(second, b',').1.is_some() { + return None; + } + Some([space, first, second]) +} + +/// The first color of a mix, without its percentage, or nothing when the mix has none. +fn first_color(inner: &str) -> &str { + let (_, rest) = split_once_top(inner, b','); + let first = split_once_top(rest.unwrap_or(""), b',').0; + percentage(first).map_or(first.trim(), |(color, _)| color) +} + +/// `color-mix(in srgb, A p%, B q%)` as CSS Color 5 mixes it: percentages default to the rest +/// of 100 and scale to sum to 100, a sum under 100 lowers the alpha, and channels mix premultiplied. +fn mix(inner: &str) -> Option { + let [space, first, second] = arguments(inner)?; + if !space.trim().eq_ignore_ascii_case("in srgb") { + return None; + } + + let (first, p1) = percentage(first)?; + let (second, p2) = percentage(second)?; + let a = parse_color(first)?; + let b = parse_color(second)?; + let (p1, p2) = match (p1, p2) { + (None, None) => (50.0, 50.0), + (Some(p1), None) => (p1, 100.0 - p1), + (None, Some(p2)) => (100.0 - p2, p2), + (Some(p1), Some(p2)) => (p1, p2), + }; + let sum = p1 + p2; + if !(0.0..=100.0).contains(&p1) || !(0.0..=100.0).contains(&p2) || sum <= 0.0 { + return None; + } + + let scale = if sum < 100.0 { sum / 100.0 } else { 1.0 }; + let (p1, p2) = (p1 / sum, p2 / sum); + let [r1, g1, b1, a1] = a; + let [r2, g2, b2, a2] = b; + let alpha = a1 * p1 + a2 * p2; + let channel = |x: f64, y: f64| { + if alpha == 0.0 { + return 0.0; + } + (x * a1 * p1 + y * a2 * p2) / alpha + }; + Some([ + channel(r1, r2), + channel(g1, g2), + channel(b1, b2), + alpha * scale, + ]) +} + +/// A mix argument split into its color and its percentage, if it has one. `None` when the +/// percentage is not a number. +fn percentage(argument: &str) -> Option<(&str, Option)> { + let argument = argument.trim(); + let Some(number) = argument.strip_suffix('%') else { + return Some((argument, None)); + }; + let space = number.rfind(|c: char| c.is_ascii_whitespace())?; + let value = float::parse(number.get(space + 1..)?).filter(|value| value.is_finite())?; + Some((number.get(..space)?.trim(), Some(value))) +} + +/// A hex color or `transparent`. Other colors are left to the fallback, which keeps them as written. +fn parse_color(text: &str) -> Option { + let text = text.trim(); + if text.eq_ignore_ascii_case("transparent") { + return Some([0.0; 4]); + } + parse_hex(text.strip_prefix('#')?.as_bytes()) +} + +/// Hex digits of a color: `rgb`, `rgba`, `rrggbb`, or `rrggbbaa`. +fn parse_hex(digits: &[u8]) -> Option { + let width = match digits.len() { + 3 | 4 => 1, + 6 | 8 => 2, + _ => return None, + }; + + let mut color = [255.0; 4]; + for (slot, chunk) in color.iter_mut().zip(digits.chunks(width)) { + let mut value = 0; + for &c in chunk { + value = value * 16 + char::from(c).to_digit(16)?; + } + // A short digit stands for itself repeated, so `f` is `ff`. + *slot = f64::from(if width == 1 { value * 17 } else { value }); + } + let [red, green, blue, alpha] = color; + Some([red, green, blue, alpha / 255.0]) +} + +/// Appends `color` as `#rrggbb`, or `#rrggbbaa` when it is not opaque. +fn push_hex(out: &mut String, color: Color) { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + + let [red, green, blue, alpha] = color; + let alpha = byte(alpha * 255.0); + out.push('#'); + for value in [byte(red), byte(green), byte(blue)] + .into_iter() + .chain((alpha < 255).then_some(alpha)) + { + for nibble in [value >> 4, value & 15] { + out.push(char::from( + DIGITS.get(usize::from(nibble)).copied().unwrap_or(b'0'), + )); + } + } +} + +/// `value` rounded to the nearest byte, halves up. +fn byte(value: f64) -> u8 { + #[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "the value is clamped to a byte's range first" + )] + let byte = (value + 0.5).floor().clamp(0.0, 255.0) as u8; + byte +} diff --git a/crates/compiler/src/svg/root.rs b/crates/compiler/src/svg/root.rs index db6ab27..50b9d77 100644 --- a/crates/compiler/src/svg/root.rs +++ b/crates/compiler/src/svg/root.rs @@ -46,6 +46,9 @@ pub(crate) struct RenderOptions { pub(crate) padding: Option, /// Whether the document scales down to its container. On unless `Some(false)`. pub(crate) responsive: Option, + /// When present, the theme is written into the document as literal values: full + /// `--ondine-*` property names and the host's values for them. The rest keep their defaults. + pub(crate) theme: Option>, } /// A diagram's accessible title and description. @@ -177,7 +180,8 @@ pub(crate) fn svg_document( } } - let content = if themed { + // A written-out radius stays in the `rx` attribute, which renderers without CSS geometry read. + let content = if themed && options.theme.is_none() { let (content, moved) = rounded(content); radius |= moved; content diff --git a/crates/compiler/src/svg/theme.rs b/crates/compiler/src/svg/theme.rs index 5a5ba96..43a2121 100644 --- a/crates/compiler/src/svg/theme.rs +++ b/crates/compiler/src/svg/theme.rs @@ -129,7 +129,7 @@ fn call_at(text: &str, at: usize, word: &str) -> bool { } /// Pele's `/url\s*\(|expression\s*\(|javascript:|[<>{};\\]|@import|image-set|\/\*/i`. -fn unsafe_value(value: &str) -> bool { +pub(crate) fn unsafe_value(value: &str) -> bool { let bytes = value.as_bytes(); (0..bytes.len()).any(|at| { matches!( diff --git a/crates/compiler/tests/theme.rs b/crates/compiler/tests/theme.rs new file mode 100644 index 0000000..e87b9f9 --- /dev/null +++ b/crates/compiler/tests/theme.rs @@ -0,0 +1,143 @@ +//! Renders diagrams with the `theme` option, which writes the theme into the SVG as literal +//! values for renderers without CSS custom properties or `color-mix()`. + +use std::fmt::Write; + +/// Renders `source` with `options` and returns the SVG. +fn svg(source: &str, options: &str) -> String { + compiler::render(source, options, 0.0) + .expect("the source renders") + .svg +} + +/// The value of the first `name` attribute after `marker` in `svg`. +fn attribute<'a>(svg: &'a str, marker: &str, name: &str) -> &'a str { + let rest = &svg[svg.find(marker).expect("the marker is present")..]; + let start = rest + .find(&format!(" {name}=\"")) + .expect("the attribute is present") + + name.len() + + 3; + &rest[start..start + rest[start..].find('"').expect("the value ends")] +} + +#[test] +fn render_writes_default_theme() { + insta::assert_snapshot!(svg("flowchart LR\n A --> B", r#"{"theme":{}}"#), @r##"AB"##); +} + +#[test] +fn render_writes_host_theme() { + let options = r##"{"theme":{"bg":"#101010","border":"#555","fg":"#eee","font":"'Inter', sans-serif","radius":"6px","stroke":"1.5px","surface":"#202020"}}"##; + + insta::assert_snapshot!(svg("flowchart LR\n A[\"var(--ondine-bg)\"] --> B", options), @r##"var(--ondine-bg)B"##); +} + +#[test] +fn render_mixes_series_colors() { + insta::assert_snapshot!(svg("pie\n \"A\" : 3\n \"B\" : 2\n \"C\" : 1", r#"{"theme":{}}"#), @r##"50%33%17%ABC"##); +} + +#[test] +fn render_leaves_no_custom_properties() { + let sources = [ + "classDiagram\n class Account {\n +deposit(amount)\n }\n Account <|-- Savings", + "flowchart TD\n subgraph S\n A --> B\n end\n B --> C", + "gantt\n dateFormat YYYY-MM-DD\n section Work\n Done :done, a, 2024-01-01, 2d\n Next :crit, b, after a, 1d", + "pie title Share\n \"A\" : 3\n \"B\" : 2", + "sequenceDiagram\n Alice->>Bob: Hi\n Note over Alice,Bob: Greeting\n Bob-->>Alice: Hello", + "stateDiagram-v2\n [*] --> Idle\n Idle --> Busy\n Busy --> [*]", + "xychart-beta\n x-axis [a, b, c]\n bar [1, 2, 3]\n line [3, 1, 2]\n line [2, 2, 1]", + ]; + + let mut report = String::new(); + for source in sources { + let rendered = + compiler::render(source, r#"{"theme":{}}"#, 0.0).expect("the source renders"); + writeln!( + report, + "{}: var {}, color-mix {}, aliases {}", + rendered.r#type, + rendered.svg.contains("var("), + rendered.svg.contains("color-mix("), + rendered.svg.contains("--_"), + ) + .expect("the report is a string"); + } + + insta::assert_snapshot!(report, @" + class: var false, color-mix false, aliases false + flowchart: var false, color-mix false, aliases false + gantt: var false, color-mix false, aliases false + pie: var false, color-mix false, aliases false + sequence: var false, color-mix false, aliases false + state: var false, color-mix false, aliases false + xychart: var false, color-mix false, aliases false + "); +} + +#[test] +fn render_mixes_host_colors() { + let source = "xychart-beta\n x-axis [a, b]\n bar [1, 2]\n bar [2, 1]"; + let mut report = String::new(); + for fg in [ + "#336699", + "#36c", + "#33669980", + "rgb(51 102 153)", + "rgba(51, 102, 153, 0.5)", + "transparent", + "red", + "url(#x)", + ] { + let options = format!(r##"{{"theme":{{"bg":"#ffffff","fg":"{fg}"}}}}"##); + let svg = svg(source, &options); + writeln!(report, "{fg}: {}", attribute(&svg, "ondine-bar", "fill")) + .expect("the report is a string"); + } + + insta::assert_snapshot!(report, @" + #336699: #336699 + #36c: #3366cc + #33669980: #33669980 + rgb(51 102 153): rgb(51 102 153) + rgba(51, 102, 153, 0.5): rgba(51, 102, 153, 0.5) + transparent: #00000000 + red: red + url(#x): #000000 + "); +} + +#[test] +fn render_normalizes_mix_percentages() { + let source = "flowchart TD\n subgraph S\n A\n end"; + let mut report = String::new(); + for alt in [ + "color-mix(in srgb, #ff0000 30%, #0000ff 30%)", + "color-mix(in srgb, #ff0000 80%, #0000ff 80%)", + "color-mix(in srgb, #ff0000, #0000ff)", + "color-mix(in srgb, #ff0000, #0000ff 25%)", + "color-mix(in srgb, #ff000000 50%, #0000ff)", + "color-mix(in srgb, color-mix(in srgb, #000 50%, #fff) 50%, #fff)", + "color-mix(in oklch, #ff0000 50%, #0000ff)", + ] { + let options = format!(r#"{{"theme":{{"surfaceAlt":"{alt}"}}}}"#); + let svg = svg(source, &options); + writeln!( + report, + "{alt}: {}", + attribute(&svg, "ondine-cluster", "fill") + ) + .expect("the report is a string"); + } + + insta::assert_snapshot!(report, @" + color-mix(in srgb, #ff0000 30%, #0000ff 30%): #80008099 + color-mix(in srgb, #ff0000 80%, #0000ff 80%): #800080 + color-mix(in srgb, #ff0000, #0000ff): #800080 + color-mix(in srgb, #ff0000, #0000ff 25%): #bf0040 + color-mix(in srgb, #ff000000 50%, #0000ff): #0000ff80 + color-mix(in srgb, color-mix(in srgb, #000 50%, #fff) 50%, #fff): #c0c0c0 + color-mix(in oklch, #ff0000 50%, #0000ff): #ff0000 + "); +} diff --git a/site/src/content/docs/api/compiler/render.mdx b/site/src/content/docs/api/compiler/render.mdx index fbf2713..33efba8 100644 --- a/site/src/content/docs/api/compiler/render.mdx +++ b/site/src/content/docs/api/compiler/render.mdx @@ -191,6 +191,23 @@ import { render } from 'ondine/compiler' render('flowchart LR\n A --> B', { responsive: false }) ``` +### options.theme + +- **Type:** `render.Theme{:ts}` + +Writes the theme into the SVG as literal values. The output has no `var()` or `color-mix()`, so renderers without CSS custom properties can draw it, such as resvg, librsvg, and native SVG parsers. + +Each key is an `--ondine-*` property name without its prefix, in camel case: `accent`, `bg`, `boldWeight`, `border`, `critical`, `fg`, `font`, `headingWeight`, `line`, `muted`, `radius`, `series1` to `series8`, `stroke`, `surface`, `surfaceAlt`, and `titleWeight`. A property without a key keeps its [default](/docs/guides/theming). An empty object writes the default theme. + +Default shades mix `fg` into `bg`. Give these two as hex or `transparent` colors, and the compiler writes each shade as a hex color. The SVG then ignores `--ondine-*` properties on the page, so a new theme needs a new render. + +```ts +import { render } from 'ondine/compiler' + +// Writes the dark scheme with rounded corners as fixed values +render('flowchart LR\n A --> B', { theme: { bg: '#000', fg: '#fff', radius: '6px' } }) +``` + ## Returns ### height @@ -223,7 +240,7 @@ const { links } = render('flowchart LR\n A --> B\n click A "https://example.co - **Type:** `string{:ts}` -The SVG document. The root element has the classes `ondine` and `ondine-`, and it reads its theme from `--ondine-*` custom properties. +The SVG document. The root element has the classes `ondine` and `ondine-`. Unless `theme` is set, the SVG reads its theme from `--ondine-*` custom properties. ```ts import { render } from 'ondine/compiler' diff --git a/site/src/content/docs/guides/theming.mdx b/site/src/content/docs/guides/theming.mdx index e80e037..22a9eca 100644 --- a/site/src/content/docs/guides/theming.mdx +++ b/site/src/content/docs/guides/theming.mdx @@ -188,6 +188,21 @@ A diagram in an image cannot read the styles of the page. Thus the [image Worker ![Flow](https://ondine.sh/eJxLy8kvT85ILCpR8AniUlBwVNDVtVNwAgBUvQYu.svg?scheme=dark) ``` +### Inline the Theme + +Some SVG renderers have no CSS custom properties or `color-mix()`. Examples are resvg, librsvg, and the SVG parsers of native apps. The `theme` option of [`render`](/docs/api/compiler/render) writes each property value into the SVG instead. + +```ts title="png.ts" +import { render } from 'ondine/compiler' + +// Writes the dark scheme into the SVG as hex colors +const { svg } = render('flowchart LR\n A --> B', { + theme: { bg: '#000', fg: '#fff' }, +}) +``` + +Keys are the property names without the `--ondine-` prefix, in camel case, such as `surfaceAlt` and `series1`. The SVG then ignores `--ondine-*` properties on the page. Thus a new scheme needs a new render. + ## More diff --git a/src/Render.test.ts b/src/Render.test.ts index 2d78587..d6abd4c 100644 --- a/src/Render.test.ts +++ b/src/Render.test.ts @@ -1,6 +1,7 @@ /** @module Integration tests for rendering Mermaid source through the WebAssembly compiler. */ import * as Fs from 'node:fs' import { Render, render, RenderError } from 'ondine/compiler' +import * as Vectors from '../test/Vectors.js' describe('render', () => { test('renders a flowchart', () => { @@ -20,6 +21,36 @@ describe('render', () => { ) }) + test('writes the theme into the SVG', () => { + const { svg } = render('flowchart LR\n A --> B', { + theme: { bg: '#000', fg: '#fff', radius: '6px' }, + }) + + expect(svg).toMatchInlineSnapshot( + `"AB"`, + ) + }) + + test('writes the theme into every vector', () => { + const themed = Vectors.types().flatMap((type) => + Vectors.of(type).flatMap((vector) => { + const svg = (() => { + try { + return render(vector.source, { now: 0, theme: {} }).svg + } catch { + // A vector that the compiler rejects has no SVG to check. + return '' + } + })() + return svg.includes('var(') || svg.includes('color-mix(') + ? [`${type}: ${vector.label}`] + : [] + }), + ) + + expect(themed).toMatchInlineSnapshot(`[]`) + }) + test('rejects text without a diagram type', () => { expect(() => render('not a diagram')).toThrowErrorMatchingInlineSnapshot( `[Render.RenderError: No diagram type detected for the given text.]`, diff --git a/src/Render.ts b/src/Render.ts index d2f0749..ebd9235 100644 --- a/src/Render.ts +++ b/src/Render.ts @@ -129,6 +129,14 @@ export declare namespace render { readonly padding?: number | undefined /** Whether the SVG scales to its container. Defaults to `true`. */ readonly responsive?: boolean | undefined + /** + * Writes the theme into the SVG as literal values instead of CSS custom properties. + * + * The output has no `var()` or `color-mix()`, so renderers without them draw it, such as resvg, + * librsvg, and native SVG parsers. Properties without a value keep their defaults, and an + * empty object writes the default theme. The SVG then ignores `--ondine-*` properties on the page. + */ + readonly theme?: Theme | undefined } /** A rendered diagram. */ @@ -144,6 +152,61 @@ export declare namespace render { /** Width in pixels. */ readonly width: number } + + /** + * Values for the `--ondine-*` properties that the `theme` option writes into the SVG. Each key is + * a property name without its prefix, in camel case. Values are CSS values. Default shades mix + * the foreground and background, so `bg` and `fg` take hex or `transparent` colors. A + * mix of other colors takes its first color. + */ + type Theme = { + /** `--ondine-accent`: highlighted elements. Defaults to `fg`. */ + readonly accent?: string | undefined + /** `--ondine-bg`: the background, and the default for fills. Defaults to `#fff`. */ + readonly bg?: string | undefined + /** `--ondine-bold-weight`: the font weight of bold labels. Defaults to `bold`. */ + readonly boldWeight?: string | undefined + /** `--ondine-border`: shape outlines. Defaults to `fg`. */ + readonly border?: string | undefined + /** `--ondine-critical`: elements that a diagram marks as critical. Defaults to `fg`. */ + readonly critical?: string | undefined + /** `--ondine-fg`: text, and the default for lines and borders. Defaults to `#000`. */ + readonly fg?: string | undefined + /** `--ondine-font`: the label font stack. Defaults to a monospace stack. */ + readonly font?: string | undefined + /** `--ondine-heading-weight`: the font weight of section and group headings. Defaults to `bold`. */ + readonly headingWeight?: string | undefined + /** `--ondine-line`: edges and connectors. Defaults to `fg`. */ + readonly line?: string | undefined + /** `--ondine-muted`: secondary text. Defaults to `fg`. */ + readonly muted?: string | undefined + /** `--ondine-radius`: the corner radius of rectangles. Defaults to `0`. */ + readonly radius?: string | undefined + /** `--ondine-series-1`: the first chart series. Defaults to a shade of `fg`. */ + readonly series1?: string | undefined + /** `--ondine-series-2`: the second chart series. Defaults to a shade of `fg`. */ + readonly series2?: string | undefined + /** `--ondine-series-3`: the third chart series. Defaults to a shade of `fg`. */ + readonly series3?: string | undefined + /** `--ondine-series-4`: the fourth chart series. Defaults to a shade of `fg`. */ + readonly series4?: string | undefined + /** `--ondine-series-5`: the fifth chart series. Defaults to a shade of `fg`. */ + readonly series5?: string | undefined + /** `--ondine-series-6`: the sixth chart series. Defaults to a shade of `fg`. */ + readonly series6?: string | undefined + /** `--ondine-series-7`: the seventh chart series. Defaults to a shade of `fg`. */ + readonly series7?: string | undefined + /** `--ondine-series-8`: the eighth chart series. Defaults to a shade of `fg`. */ + readonly series8?: string | undefined + /** `--ondine-stroke`: the width of outlines and edges. Defaults to `2px`. */ + readonly stroke?: string | undefined + /** `--ondine-surface`: node fills. Defaults to `bg`. */ + readonly surface?: string | undefined + /** `--ondine-surface-alt`: cluster fills, grids, and finished work. Defaults to 20% `fg`. */ + readonly surfaceAlt?: string | undefined + /** `--ondine-title-weight`: the font weight of diagram titles. Defaults to `bold`. */ + readonly titleWeight?: string | undefined + } } /** Options as the compiler reads them, without the time, which is passed apart. Empty for none. */ From 5af0fbc35bcdd4f62c04ea0e9014eb437b5b2642 Mon Sep 17 00:00:00 2001 From: Tony D'Addeo Date: Sat, 31 Oct 2026 10:37:39 -0500 Subject: [PATCH 2/2] feat: gate render.Options.theme behind the compiler theme feature and mix named and rgb() colors --- .changeset/theme-option.md | 2 +- crates/compiler/Cargo.toml | 5 +- crates/compiler/src/error.rs | 3 + crates/compiler/src/lib.rs | 56 +-- crates/compiler/src/svg/resolve.rs | 378 ++++++++++++++++-- crates/compiler/src/svg/root.rs | 11 +- crates/compiler/tests/theme.rs | 52 ++- crates/wasm/Cargo.toml | 1 + scripts/build-wasm.ts | 19 +- .../content/docs/api/compiler/RenderError.mdx | 3 +- site/src/content/docs/api/compiler/create.mdx | 2 + site/src/content/docs/api/compiler/render.mdx | 6 +- site/src/content/docs/guides/theming.mdx | 2 + src/Render.test.ts | 30 ++ src/Render.ts | 13 +- 15 files changed, 476 insertions(+), 107 deletions(-) diff --git a/.changeset/theme-option.md b/.changeset/theme-option.md index 6000e16..7e59e98 100644 --- a/.changeset/theme-option.md +++ b/.changeset/theme-option.md @@ -2,4 +2,4 @@ 'ondine': patch --- -Added the `theme` render option, which writes the theme into the SVG as literal values. The output has no `var()` or `color-mix()`, so renderers without CSS custom properties draw it, such as resvg, librsvg, and native SVG parsers. +Added the `theme` render option, which writes the theme into the SVG as literal values. The output has no `var()` or `color-mix()`, so renderers without CSS custom properties draw it, such as resvg, librsvg, and native SVG parsers. Named, `rgb()`, `rgba()`, hex, and `transparent` colors mix. Only `ondine/compiler` and the `all.fast` module support the option. diff --git a/crates/compiler/Cargo.toml b/crates/compiler/Cargo.toml index 7365216..a71344a 100644 --- a/crates/compiler/Cargo.toml +++ b/crates/compiler/Cargo.toml @@ -9,7 +9,7 @@ rust-version.workspace = true version.workspace = true [features] -default = ["all"] +default = ["all", "theme"] # Every diagram type, for native builds and tests. all = [ "agentflow", @@ -82,6 +82,9 @@ wardley = [] xychart = [] # Serializes parsed models as JSON for parity tests. Release modules leave it out. model = [] +# Writes the `theme` render option into the SVG as literal values. Only the speed-optimized `all` +# module includes it, so the size-optimized browser modules leave out the resolve pass. +theme = [] [dev-dependencies] insta.workspace = true diff --git a/crates/compiler/src/error.rs b/crates/compiler/src/error.rs index b675b95..3b6b1f0 100644 --- a/crates/compiler/src/error.rs +++ b/crates/compiler/src/error.rs @@ -9,6 +9,8 @@ pub enum Code { Semantic, /// The source does not match the diagram's grammar. Syntax, + /// The options ask for something that this compiler module does not support. + Unsupported, /// No supported diagram type was detected. UnsupportedDiagram, } @@ -20,6 +22,7 @@ impl Code { Self::Limit => "limit", Self::Semantic => "semantic", Self::Syntax => "syntax", + Self::Unsupported => "unsupported", Self::UnsupportedDiagram => "unsupported-diagram", } } diff --git a/crates/compiler/src/lib.rs b/crates/compiler/src/lib.rs index e0da3fb..d0d7ffc 100644 --- a/crates/compiler/src/lib.rs +++ b/crates/compiler/src/lib.rs @@ -24,6 +24,7 @@ mod svg { pub(crate) mod builder; pub(crate) mod cluster; pub(crate) mod edges; + #[cfg(feature = "theme")] pub(crate) mod resolve; pub(crate) mod root; pub(crate) mod shapes; @@ -172,6 +173,13 @@ pub fn render(source: &str, options: &str, now: f64) -> Result }; let drawn = (diagram.render)(&input, &options, &url)?; + #[cfg(feature = "theme")] + let document = match &options.theme { + Some(theme) => svg::resolve::resolve(&drawn.svg, theme)?, + None => drawn.svg, + }; + #[cfg(not(feature = "theme"))] + let document = drawn.svg; let mut rendered = Rendered { height: drawn.height, links: drawn @@ -183,10 +191,7 @@ pub fn render(source: &str, options: &str, now: f64) -> Result internal: link.internal, }) .collect(), - svg: match &options.theme { - Some(theme) => svg::resolve::resolve(&drawn.svg, theme), - None => drawn.svg, - }, + svg: document, r#type, width: drawn.width, }; @@ -236,39 +241,6 @@ fn exceeds(text: &str, limit: f64) -> bool { units as f64 > limit } -/// Reads the `theme` option: an object of `--ondine-*` property names in camel case, without the -/// prefix, and their values. Keys become full property names, so `surfaceAlt` sets -/// `--ondine-surface-alt` and `series1` sets `--ondine-series-1`. A value that is not a string, or -/// that an author style could not hold, is ignored, so its property keeps its default. -fn theme(value: &yaml::Value) -> Option> { - let yaml::Value::Object(entries) = value else { - return None; - }; - - let mut theme = Vec::with_capacity(entries.len()); - for (key, value) in entries { - let yaml::Value::String(value) = value else { - continue; - }; - let value = value.trim(); - if value.is_empty() || svg::theme::unsafe_value(value) { - continue; - } - - let mut name = String::from("--ondine-"); - let mut digits = false; - for c in key.chars() { - if c.is_ascii_uppercase() || (c.is_ascii_digit() && !digits) { - name.push('-'); - } - name.push(c.to_ascii_lowercase()); - digits = c.is_ascii_digit(); - } - theme.push((name, svg::builder::esc(value).into_owned())); - } - Some(theme) -} - /// Reads host options from JSON. Unknown fields are ignored, as in Pele. fn options_from_json(json: &str) -> Result { let mut options = svg::root::RenderOptions::default(); @@ -324,7 +296,15 @@ fn options_from_json(json: &str) -> Result { "outputLimit" => options.output_limit = number(value), "padding" => options.padding = number(value), "responsive" => options.responsive = boolean(value), - "theme" => options.theme = theme(value), + #[cfg(feature = "theme")] + "theme" => options.theme = svg::resolve::properties(value), + #[cfg(not(feature = "theme"))] + "theme" => { + return Err(Error::new( + Code::Unsupported, + "This module does not support the theme option. Use ondine/compiler.", + )); + } _ => {} } } diff --git a/crates/compiler/src/svg/resolve.rs b/crates/compiler/src/svg/resolve.rs index 623d72d..74278a1 100644 --- a/crates/compiler/src/svg/resolve.rs +++ b/crates/compiler/src/svg/resolve.rs @@ -5,22 +5,194 @@ //! declaration of a private alias, or its own fallback. Each `color-mix()` of two parsed colors then //! becomes one hex color. Declarations of custom properties are dropped, since nothing reads them. //! Text content is copied as it is, so a label that spells out `var(` keeps its text. +//! +//! A mix of a host color that does not parse is an error that names its key. A mix that only +//! authored styles write becomes its first color, which every renderer draws. +use crate::error::{Code, Error}; +use crate::svg::{builder, theme}; use crate::util::float; +use crate::yaml; /// How deep values may nest. Ondine's aliases go three levels deep. The limit stops a host value /// that refers to itself. const MAX_DEPTH: u8 = 8; +/// The start of a mix. +const MIX: &str = "color-mix("; + +/// CSS named colors, sorted by name for binary search. +const NAMED: [(&str, [u8; 3]); 148] = [ + ("aliceblue", [240, 248, 255]), + ("antiquewhite", [250, 235, 215]), + ("aqua", [0, 255, 255]), + ("aquamarine", [127, 255, 212]), + ("azure", [240, 255, 255]), + ("beige", [245, 245, 220]), + ("bisque", [255, 228, 196]), + ("black", [0, 0, 0]), + ("blanchedalmond", [255, 235, 205]), + ("blue", [0, 0, 255]), + ("blueviolet", [138, 43, 226]), + ("brown", [165, 42, 42]), + ("burlywood", [222, 184, 135]), + ("cadetblue", [95, 158, 160]), + ("chartreuse", [127, 255, 0]), + ("chocolate", [210, 105, 30]), + ("coral", [255, 127, 80]), + ("cornflowerblue", [100, 149, 237]), + ("cornsilk", [255, 248, 220]), + ("crimson", [220, 20, 60]), + ("cyan", [0, 255, 255]), + ("darkblue", [0, 0, 139]), + ("darkcyan", [0, 139, 139]), + ("darkgoldenrod", [184, 134, 11]), + ("darkgray", [169, 169, 169]), + ("darkgreen", [0, 100, 0]), + ("darkgrey", [169, 169, 169]), + ("darkkhaki", [189, 183, 107]), + ("darkmagenta", [139, 0, 139]), + ("darkolivegreen", [85, 107, 47]), + ("darkorange", [255, 140, 0]), + ("darkorchid", [153, 50, 204]), + ("darkred", [139, 0, 0]), + ("darksalmon", [233, 150, 122]), + ("darkseagreen", [143, 188, 143]), + ("darkslateblue", [72, 61, 139]), + ("darkslategray", [47, 79, 79]), + ("darkslategrey", [47, 79, 79]), + ("darkturquoise", [0, 206, 209]), + ("darkviolet", [148, 0, 211]), + ("deeppink", [255, 20, 147]), + ("deepskyblue", [0, 191, 255]), + ("dimgray", [105, 105, 105]), + ("dimgrey", [105, 105, 105]), + ("dodgerblue", [30, 144, 255]), + ("firebrick", [178, 34, 34]), + ("floralwhite", [255, 250, 240]), + ("forestgreen", [34, 139, 34]), + ("fuchsia", [255, 0, 255]), + ("gainsboro", [220, 220, 220]), + ("ghostwhite", [248, 248, 255]), + ("gold", [255, 215, 0]), + ("goldenrod", [218, 165, 32]), + ("gray", [128, 128, 128]), + ("green", [0, 128, 0]), + ("greenyellow", [173, 255, 47]), + ("grey", [128, 128, 128]), + ("honeydew", [240, 255, 240]), + ("hotpink", [255, 105, 180]), + ("indianred", [205, 92, 92]), + ("indigo", [75, 0, 130]), + ("ivory", [255, 255, 240]), + ("khaki", [240, 230, 140]), + ("lavender", [230, 230, 250]), + ("lavenderblush", [255, 240, 245]), + ("lawngreen", [124, 252, 0]), + ("lemonchiffon", [255, 250, 205]), + ("lightblue", [173, 216, 230]), + ("lightcoral", [240, 128, 128]), + ("lightcyan", [224, 255, 255]), + ("lightgoldenrodyellow", [250, 250, 210]), + ("lightgray", [211, 211, 211]), + ("lightgreen", [144, 238, 144]), + ("lightgrey", [211, 211, 211]), + ("lightpink", [255, 182, 193]), + ("lightsalmon", [255, 160, 122]), + ("lightseagreen", [32, 178, 170]), + ("lightskyblue", [135, 206, 250]), + ("lightslategray", [119, 136, 153]), + ("lightslategrey", [119, 136, 153]), + ("lightsteelblue", [176, 196, 222]), + ("lightyellow", [255, 255, 224]), + ("lime", [0, 255, 0]), + ("limegreen", [50, 205, 50]), + ("linen", [250, 240, 230]), + ("magenta", [255, 0, 255]), + ("maroon", [128, 0, 0]), + ("mediumaquamarine", [102, 205, 170]), + ("mediumblue", [0, 0, 205]), + ("mediumorchid", [186, 85, 211]), + ("mediumpurple", [147, 112, 219]), + ("mediumseagreen", [60, 179, 113]), + ("mediumslateblue", [123, 104, 238]), + ("mediumspringgreen", [0, 250, 154]), + ("mediumturquoise", [72, 209, 204]), + ("mediumvioletred", [199, 21, 133]), + ("midnightblue", [25, 25, 112]), + ("mintcream", [245, 255, 250]), + ("mistyrose", [255, 228, 225]), + ("moccasin", [255, 228, 181]), + ("navajowhite", [255, 222, 173]), + ("navy", [0, 0, 128]), + ("oldlace", [253, 245, 230]), + ("olive", [128, 128, 0]), + ("olivedrab", [107, 142, 35]), + ("orange", [255, 165, 0]), + ("orangered", [255, 69, 0]), + ("orchid", [218, 112, 214]), + ("palegoldenrod", [238, 232, 170]), + ("palegreen", [152, 251, 152]), + ("paleturquoise", [175, 238, 238]), + ("palevioletred", [219, 112, 147]), + ("papayawhip", [255, 239, 213]), + ("peachpuff", [255, 218, 185]), + ("peru", [205, 133, 63]), + ("pink", [255, 192, 203]), + ("plum", [221, 160, 221]), + ("powderblue", [176, 224, 230]), + ("purple", [128, 0, 128]), + ("rebeccapurple", [102, 51, 153]), + ("red", [255, 0, 0]), + ("rosybrown", [188, 143, 143]), + ("royalblue", [65, 105, 225]), + ("saddlebrown", [139, 69, 19]), + ("salmon", [250, 128, 114]), + ("sandybrown", [244, 164, 96]), + ("seagreen", [46, 139, 87]), + ("seashell", [255, 245, 238]), + ("sienna", [160, 82, 45]), + ("silver", [192, 192, 192]), + ("skyblue", [135, 206, 235]), + ("slateblue", [106, 90, 205]), + ("slategray", [112, 128, 144]), + ("slategrey", [112, 128, 144]), + ("snow", [255, 250, 250]), + ("springgreen", [0, 255, 127]), + ("steelblue", [70, 130, 180]), + ("tan", [210, 180, 140]), + ("teal", [0, 128, 128]), + ("thistle", [216, 191, 216]), + ("tomato", [255, 99, 71]), + ("turquoise", [64, 224, 208]), + ("violet", [238, 130, 238]), + ("wheat", [245, 222, 179]), + ("white", [255, 255, 255]), + ("whitesmoke", [245, 245, 245]), + ("yellow", [255, 255, 0]), + ("yellowgreen", [154, 205, 50]), +]; + /// A parsed color: red, green, and blue from 0 to 255, and alpha from 0 to 1. type Color = [f64; 4]; +/// A value of the `theme` option. +#[derive(Clone, Debug)] +pub(crate) struct Property { + /// The key as the host writes it, such as `surfaceAlt`. + pub(crate) key: String, + /// The full property name, such as `--ondine-surface-alt`. + pub(crate) name: String, + /// The value, escaped for an attribute. + pub(crate) value: String, +} + /// Where `var()` references read their values. struct Lookup<'a> { /// The custom properties the root element declares. declared: Vec<(&'a str, &'a str)>, - /// The host's values by full property name, such as `--ondine-bg`. - theme: &'a [(String, String)], + /// The host's values. + theme: &'a [Property], } impl Lookup<'_> { @@ -30,8 +202,8 @@ impl Lookup<'_> { return self .theme .iter() - .find(|(key, _)| key == name) - .map(|(_, value)| value.as_str()); + .find(|property| property.name == name) + .map(|property| property.value.as_str()); } self.declared .iter() @@ -40,9 +212,49 @@ impl Lookup<'_> { } } -/// `svg` with every themed attribute written out with `theme`, a list of full -/// `--ondine-*` property names and their values. -pub(crate) fn resolve(svg: &str, theme: &[(String, String)]) -> String { +/// Reads the `theme` option: an object of `--ondine-*` property names in camel case, without the +/// prefix, and their values. Keys become full property names, so `surfaceAlt` sets +/// `--ondine-surface-alt` and `series1` sets `--ondine-series-1`. A value that is not a string, or +/// that an author style could not hold, is ignored, so its property keeps its default. +pub(crate) fn properties(value: &yaml::Value) -> Option> { + let yaml::Value::Object(entries) = value else { + return None; + }; + + let mut properties = Vec::with_capacity(entries.len()); + for (key, value) in entries { + let yaml::Value::String(value) = value else { + continue; + }; + let value = value.trim(); + if value.is_empty() || theme::unsafe_value(value) { + continue; + } + + let mut name = String::from("--ondine-"); + let mut digits = false; + for c in key.chars() { + if c.is_ascii_uppercase() || (c.is_ascii_digit() && !digits) { + name.push('-'); + } + name.push(c.to_ascii_lowercase()); + digits = c.is_ascii_digit(); + } + properties.push(Property { + key: key.clone(), + name, + value: builder::esc(value).into_owned(), + }); + } + Some(properties) +} + +/// `svg` with every themed attribute written out with `theme`. +/// +/// # Errors +/// +/// Returns an [`Error`] when a `color-mix()` reads a host value that is not a color it parses. +pub(crate) fn resolve(svg: &str, theme: &[Property]) -> Result { let lookup = Lookup { declared: root_declarations(svg), theme, @@ -52,10 +264,10 @@ pub(crate) fn resolve(svg: &str, theme: &[(String, String)]) -> String { let mut rest = svg; while let Some(open) = rest.find('<') { out.push_str(rest.get(..open).unwrap_or("")); - rest = push_tag(&mut out, rest.get(open..).unwrap_or(""), &lookup); + rest = push_tag(&mut out, rest.get(open..).unwrap_or(""), &lookup)?; } out.push_str(rest); - out + Ok(out) } /// The custom properties declared in the `style` of the first `` tag. @@ -89,7 +301,7 @@ fn root_declarations(svg: &str) -> Vec<(&str, &str)> { /// Appends the tag at the start of `text` with its themed attributes written out, and returns /// the text after it. Attributes are escaped, so a `>` outside quotes ends the tag. -fn push_tag<'a>(out: &mut String, text: &'a str, lookup: &Lookup<'_>) -> &'a str { +fn push_tag<'a>(out: &mut String, text: &'a str, lookup: &Lookup<'_>) -> Result<&'a str, Error> { let bytes = text.as_bytes(); let mut copied = 0; let mut name = ""; @@ -111,9 +323,9 @@ fn push_tag<'a>(out: &mut String, text: &'a str, lookup: &Lookup<'_>) -> &'a str if themed(name) && value.contains('(') { out.push_str(text.get(copied..start).unwrap_or("")); if name == "style" { - push_style(out, value, lookup); + push_style(out, value, lookup)?; } else { - push_value(out, value, lookup); + push_value(out, value, lookup)?; } copied = end; } @@ -127,7 +339,7 @@ fn push_tag<'a>(out: &mut String, text: &'a str, lookup: &Lookup<'_>) -> &'a str let end = at.min(text.len()); out.push_str(text.get(copied..end).unwrap_or("")); - text.get(end..).unwrap_or("") + Ok(text.get(end..).unwrap_or("")) } /// The name of the attribute whose `=` is at `eq`. @@ -149,7 +361,7 @@ fn themed(name: &str) -> bool { } /// Appends a `style` value without its custom properties and with the rest written out. -fn push_style(out: &mut String, style: &str, lookup: &Lookup<'_>) { +fn push_style(out: &mut String, style: &str, lookup: &Lookup<'_>) -> Result<(), Error> { let mut first = true; let mut rest = style; loop { @@ -158,26 +370,26 @@ fn push_style(out: &mut String, style: &str, lookup: &Lookup<'_>) { if !first { out.push(';'); } - push_value(out, part, lookup); + push_value(out, part, lookup)?; first = false; } let Some(next) = next else { - return; + return Ok(()); }; rest = next; } } /// Appends `value` with its `var()` references and color mixes written out. -fn push_value(out: &mut String, value: &str, lookup: &Lookup<'_>) { +fn push_value(out: &mut String, value: &str, lookup: &Lookup<'_>) -> Result<(), Error> { if !value.contains('(') { out.push_str(value); - return; + return Ok(()); } let mut vars = String::with_capacity(value.len() + 16); push_vars(&mut vars, value, lookup, 0); - push_mixes(out, &vars, 0); + push_mixes(out, &vars, lookup, 0) } /// Appends `text` with each `var(--name, fallback)` replaced by the value of `--name`, or by @@ -207,32 +419,49 @@ fn push_vars(out: &mut String, text: &str, lookup: &Lookup<'_>, depth: u8) { out.push_str(rest); } -/// Appends `text` with each `color-mix()` evaluated, innermost first. A mix whose colors do not -/// parse becomes its first color, which every renderer draws. -fn push_mixes(out: &mut String, text: &str, depth: u8) { - const CALL: &str = "color-mix("; - +/// Appends `text` with each `color-mix()` evaluated to a hex color. +/// +/// # Errors +/// +/// Returns an [`Error`] naming the first host value in a mix that does not evaluate, if any. +fn push_mixes(out: &mut String, text: &str, lookup: &Lookup<'_>, depth: u8) -> Result<(), Error> { let mut rest = text; - while let Some(start) = rest.find(CALL) { - let Some(end) = close(rest, start + CALL.len() - 1) else { + while let Some(start) = rest.find(MIX) { + let Some(end) = close(rest, start + MIX.len() - 1) else { break; }; out.push_str(rest.get(..start).unwrap_or("")); - let raw = rest.get(start + CALL.len()..end).unwrap_or(""); - if depth < MAX_DEPTH { - let mut inner = String::with_capacity(raw.len()); - push_mixes(&mut inner, raw, depth + 1); - match mix(&inner) { - Some(color) => push_hex(out, color), - None => out.push_str(first_color(&inner)), - } + let call = rest.get(start..=end).unwrap_or(""); + // The mix holds the host values written out, so a value in it that is not a color + // is the one that stops the mix. + if let Some(color) = color(call, depth) { + push_hex(out, color); + } else if let Some(property) = lookup + .theme + .iter() + .find(|property| call.contains(&property.value) && color(&property.value, 0).is_none()) + { + return Err(unmixable(&property.key)); + } else if depth < MAX_DEPTH { + let inner = call.get(MIX.len()..call.len() - 1).unwrap_or(""); + push_mixes(out, first_color(inner), lookup, depth + 1)?; } else { - out.push_str(rest.get(start..=end).unwrap_or("")); + out.push_str(call); } rest = rest.get(end + 1..).unwrap_or(""); } out.push_str(rest); + Ok(()) +} + +/// The error for a mix that reads the host value of `key`. +#[cold] +fn unmixable(key: &str) -> Error { + let mut message = String::from("The theme value for \""); + message.push_str(key); + message.push_str("\" cannot be mixed. Use a hex, named, rgb(), or transparent color."); + Error::new(Code::Unsupported, message) } /// The index of the parenthesis that closes the one at `open`. @@ -287,9 +516,21 @@ fn first_color(inner: &str) -> &str { percentage(first).map_or(first.trim(), |(color, _)| color) } +/// `text` as a color: a parsed color, or a `color-mix()` of them evaluated. +fn color(text: &str, depth: u8) -> Option { + let text = text.trim(); + if !text.starts_with(MIX) { + return parse_color(text); + } + if depth >= MAX_DEPTH || close(text, MIX.len() - 1)? + 1 != text.len() { + return None; + } + mix(text.get(MIX.len()..text.len() - 1)?, depth + 1) +} + /// `color-mix(in srgb, A p%, B q%)` as CSS Color 5 mixes it: percentages default to the rest /// of 100 and scale to sum to 100, a sum under 100 lowers the alpha, and channels mix premultiplied. -fn mix(inner: &str) -> Option { +fn mix(inner: &str, depth: u8) -> Option { let [space, first, second] = arguments(inner)?; if !space.trim().eq_ignore_ascii_case("in srgb") { return None; @@ -297,8 +538,8 @@ fn mix(inner: &str) -> Option { let (first, p1) = percentage(first)?; let (second, p2) = percentage(second)?; - let a = parse_color(first)?; - let b = parse_color(second)?; + let a = color(first, depth)?; + let b = color(second, depth)?; let (p1, p2) = match (p1, p2) { (None, None) => (50.0, 50.0), (Some(p1), None) => (p1, 100.0 - p1), @@ -341,13 +582,33 @@ fn percentage(argument: &str) -> Option<(&str, Option)> { Some((number.get(..space)?.trim(), Some(value))) } -/// A hex color or `transparent`. Other colors are left to the fallback, which keeps them as written. +/// A hex color, `rgb()` or `rgba()`, a named color, or `transparent`. fn parse_color(text: &str) -> Option { - let text = text.trim(); if text.eq_ignore_ascii_case("transparent") { return Some([0.0; 4]); } - parse_hex(text.strip_prefix('#')?.as_bytes()) + if let Some(digits) = text.strip_prefix('#') { + return parse_hex(digits.as_bytes()); + } + if let Some(inner) = function(text, "rgba(").or_else(|| function(text, "rgb(")) { + return parse_rgb(inner); + } + + let at = NAMED + .binary_search_by(|(name, _)| { + name.bytes() + .cmp(text.bytes().map(|c| c.to_ascii_lowercase())) + }) + .ok()?; + let (_, [red, green, blue]) = NAMED.get(at)?; + Some([f64::from(*red), f64::from(*green), f64::from(*blue), 1.0]) +} + +/// The arguments of `text` when it calls the function `open`, such as `rgb(`, in any case. +fn function<'a>(text: &'a str, open: &str) -> Option<&'a str> { + text.get(..open.len()) + .filter(|head| head.eq_ignore_ascii_case(open))?; + text.get(open.len()..)?.strip_suffix(')') } /// Hex digits of a color: `rgb`, `rgba`, `rrggbb`, or `rrggbbaa`. @@ -371,6 +632,41 @@ fn parse_hex(digits: &[u8]) -> Option { Some([red, green, blue, alpha / 255.0]) } +/// The arguments of `rgb()` or `rgba()`: three channels and an optional alpha, separated by commas +/// (`51, 102, 153, 0.5`) or by spaces with a slash before the alpha (`51 102 153 / 50%`). +fn parse_rgb(inner: &str) -> Option { + let (channels, alpha) = if inner.contains(',') { + let mut parts: Vec<&str> = inner.split(',').map(str::trim).collect(); + let alpha = if parts.len() == 4 { parts.pop() } else { None }; + (parts, alpha) + } else { + let (channels, alpha) = match inner.split_once('/') { + Some((channels, alpha)) => (channels, Some(alpha.trim())), + None => (inner, None), + }; + (channels.split_ascii_whitespace().collect(), alpha) + }; + let &[red, green, blue] = channels.as_slice() else { + return None; + }; + + let channel = |text: &str| number(text, 2.55).map(|value| value.clamp(0.0, 255.0)); + let alpha = match alpha { + Some(alpha) => number(alpha, 0.01)?.clamp(0.0, 1.0), + None => 1.0, + }; + Some([channel(red)?, channel(green)?, channel(blue)?, alpha]) +} + +/// A finite number, or a percentage times `scale`. +fn number(text: &str, scale: f64) -> Option { + let value = match text.strip_suffix('%') { + Some(percent) => float::parse(percent)? * scale, + None => float::parse(text)?, + }; + value.is_finite().then_some(value) +} + /// Appends `color` as `#rrggbb`, or `#rrggbbaa` when it is not opaque. fn push_hex(out: &mut String, color: Color) { const DIGITS: &[u8; 16] = b"0123456789abcdef"; diff --git a/crates/compiler/src/svg/root.rs b/crates/compiler/src/svg/root.rs index 50b9d77..d141623 100644 --- a/crates/compiler/src/svg/root.rs +++ b/crates/compiler/src/svg/root.rs @@ -46,9 +46,10 @@ pub(crate) struct RenderOptions { pub(crate) padding: Option, /// Whether the document scales down to its container. On unless `Some(false)`. pub(crate) responsive: Option, - /// When present, the theme is written into the document as literal values: full - /// `--ondine-*` property names and the host's values for them. The rest keep their defaults. - pub(crate) theme: Option>, + /// When present, the theme is written into the document as literal values: the host's + /// `--ondine-*` properties. The rest keep their defaults. + #[cfg(feature = "theme")] + pub(crate) theme: Option>, } /// A diagram's accessible title and description. @@ -181,7 +182,9 @@ pub(crate) fn svg_document( } // A written-out radius stays in the `rx` attribute, which renderers without CSS geometry read. - let content = if themed && options.theme.is_none() { + #[cfg(feature = "theme")] + let themed = themed && options.theme.is_none(); + let content = if themed { let (content, moved) = rounded(content); radius |= moved; content diff --git a/crates/compiler/tests/theme.rs b/crates/compiler/tests/theme.rs index e87b9f9..b1cce33 100644 --- a/crates/compiler/tests/theme.rs +++ b/crates/compiler/tests/theme.rs @@ -1,5 +1,6 @@ //! Renders diagrams with the `theme` option, which writes the theme into the SVG as literal //! values for renderers without CSS custom properties or `color-mix()`. +#![cfg(feature = "theme")] use std::fmt::Write; @@ -85,9 +86,12 @@ fn render_mixes_host_colors() { "#36c", "#33669980", "rgb(51 102 153)", + "rgb(51 102 153 / 50%)", "rgba(51, 102, 153, 0.5)", + "RGB(20%, 40%, 60%)", "transparent", - "red", + "rebeccapurple", + "Red", "url(#x)", ] { let options = format!(r##"{{"theme":{{"bg":"#ffffff","fg":"{fg}"}}}}"##); @@ -100,10 +104,13 @@ fn render_mixes_host_colors() { #336699: #336699 #36c: #3366cc #33669980: #33669980 - rgb(51 102 153): rgb(51 102 153) - rgba(51, 102, 153, 0.5): rgba(51, 102, 153, 0.5) + rgb(51 102 153): #336699 + rgb(51 102 153 / 50%): #33669980 + rgba(51, 102, 153, 0.5): #33669980 + RGB(20%, 40%, 60%): #336699 transparent: #00000000 - red: red + rebeccapurple: #663399 + Red: #ff0000 url(#x): #000000 "); } @@ -119,7 +126,6 @@ fn render_normalizes_mix_percentages() { "color-mix(in srgb, #ff0000, #0000ff 25%)", "color-mix(in srgb, #ff000000 50%, #0000ff)", "color-mix(in srgb, color-mix(in srgb, #000 50%, #fff) 50%, #fff)", - "color-mix(in oklch, #ff0000 50%, #0000ff)", ] { let options = format!(r#"{{"theme":{{"surfaceAlt":"{alt}"}}}}"#); let svg = svg(source, &options); @@ -137,7 +143,39 @@ fn render_normalizes_mix_percentages() { color-mix(in srgb, #ff0000, #0000ff): #800080 color-mix(in srgb, #ff0000, #0000ff 25%): #bf0040 color-mix(in srgb, #ff000000 50%, #0000ff): #0000ff80 - color-mix(in srgb, color-mix(in srgb, #000 50%, #fff) 50%, #fff): #c0c0c0 - color-mix(in oklch, #ff0000 50%, #0000ff): #ff0000 + color-mix(in srgb, color-mix(in srgb, #000 50%, #fff) 50%, #fff): #bfbfbf "); } + +#[test] +fn render_rejects_unmixable_host_colors() { + let source = "flowchart TD\n subgraph S\n A\n end"; + let mut report = String::new(); + for theme in [ + r#"{"fg":"hsl(210 50% 40%)"}"#, + r##"{"bg":"#fff","fg":"oklch(0.5 0.1 250)"}"##, + r#"{"fg":"currentColor"}"#, + r#"{"surfaceAlt":"color-mix(in oklch, #f00 50%, #00f)"}"#, + r#"{"surfaceAlt":"color-mix(in srgb, #f00 50%, hsl(0 0% 0%))"}"#, + ] { + let options = format!(r#"{{"theme":{theme}}}"#); + let error = compiler::render(source, &options, 0.0).expect_err("the theme is rejected"); + writeln!(report, "{theme}: {} {}", error.code.as_str(), error.message) + .expect("the report is a string"); + } + + insta::assert_snapshot!(report, @r##" + {"fg":"hsl(210 50% 40%)"}: unsupported The theme value for "fg" cannot be mixed. Use a hex, named, rgb(), or transparent color. + {"bg":"#fff","fg":"oklch(0.5 0.1 250)"}: unsupported The theme value for "fg" cannot be mixed. Use a hex, named, rgb(), or transparent color. + {"fg":"currentColor"}: unsupported The theme value for "fg" cannot be mixed. Use a hex, named, rgb(), or transparent color. + {"surfaceAlt":"color-mix(in oklch, #f00 50%, #00f)"}: unsupported The theme value for "surfaceAlt" cannot be mixed. Use a hex, named, rgb(), or transparent color. + {"surfaceAlt":"color-mix(in srgb, #f00 50%, hsl(0 0% 0%))"}: unsupported The theme value for "surfaceAlt" cannot be mixed. Use a hex, named, rgb(), or transparent color. + "##); +} + +#[test] +fn render_keeps_unmixed_host_colors() { + let options = r##"{"theme":{"border":"hsl(210 50% 40%)","fg":"#000"}}"##; + + insta::assert_snapshot!(attribute(&svg("flowchart LR\n A", options), "ondine-node", "stroke"), @"hsl(210 50% 40%)"); +} diff --git a/crates/wasm/Cargo.toml b/crates/wasm/Cargo.toml index 686d504..72a252e 100644 --- a/crates/wasm/Cargo.toml +++ b/crates/wasm/Cargo.toml @@ -49,6 +49,7 @@ venn = ["compiler/venn"] wardley = ["compiler/wardley"] xychart = ["compiler/xychart"] model = ["compiler/model"] +theme = ["compiler/theme"] [dependencies] compiler.workspace = true diff --git a/scripts/build-wasm.ts b/scripts/build-wasm.ts index efcaa65..22c3e0c 100644 --- a/scripts/build-wasm.ts +++ b/scripts/build-wasm.ts @@ -2,10 +2,10 @@ * Builds one size-optimized WebAssembly module per diagram type and inlines each as base64 in * `src/internal/.generated.ts`, beside the raw `src/internal/.generated.wasm` for hosts * that only accept precompiled modules. Also builds `all.fast.generated.wasm`, every type optimized - * for speed, which `ondine/compiler` loads in Node, and `test/oracles/model.generated.wasm`, every - * type with the `model` export that the oracle tests read. Outputs are gitignored and rewritten only - * when they change. Writes the matching `ondine/compiler/wasm/` and `.wasm` exports into - * `package.json`. + * for speed with the `theme` option, which `ondine/compiler` loads in Node, and + * `test/oracles/model.generated.wasm`, every type with the `model` export that the oracle tests + * read. Outputs are gitignored and rewritten only when they change. Writes the matching + * `ondine/compiler/wasm/` and `.wasm` exports into `package.json`. * @module */ import * as ChildProcess from 'node:child_process' @@ -91,7 +91,8 @@ const fresh = if (fresh) console.log('WebAssembly modules are up to date.') else { // `all` includes every diagram type, for the root entrypoint and the tests. Build tools, - // servers, and the CLI download nothing, so `all.fast` trades size for speed. + // servers, and the CLI download nothing, so `all.fast` trades size for speed and alone adds the + // `theme` option. Browsers and the image Worker load the size modules, which stay without it. const jobs: (() => Promise)[] = [ ...['all', ...types].map((type) => async () => { const bytes = await build(type) @@ -101,7 +102,7 @@ else { async () => write( Path.join(root, 'src/internal/all.fast.generated.wasm'), - await build('all', { fast: true }), + await build('all,theme', { fast: true }), ), // Tests compare parsed models, which no published module exports. async () => write(modelFile, await build('all,model', { model: true })), @@ -160,11 +161,13 @@ function run(command: string, args: readonly string[], env: NodeJS.ProcessEnv): }) } -/** Builds the `wasm` crate with one diagram feature, optimizes it, and returns its bytes. */ +/** Builds the `wasm` crate with `feature`, or a comma-separated list, optimizes it, and returns its bytes. */ async function build(feature: string, options: build.Options = {}): Promise { const fast = options.fast === true const model = options.model === true - const targetDir = model ? 'target/wasm-model' : `target/wasm-${feature}${fast ? '-fast' : ''}` + const targetDir = model + ? 'target/wasm-model' + : `target/wasm-${feature.replaceAll(',', '-')}${fast ? '-fast' : ''}` const sized = !fast && !model && nightlyInstalled // Rebuilding `std` with `optimize_for_size` and aborting panics without formatting their messages diff --git a/site/src/content/docs/api/compiler/RenderError.mdx b/site/src/content/docs/api/compiler/RenderError.mdx index 3333706..97b4eae 100644 --- a/site/src/content/docs/api/compiler/RenderError.mdx +++ b/site/src/content/docs/api/compiler/RenderError.mdx @@ -28,7 +28,7 @@ class RenderError extends Error ### code -- **Type:** `'limit' | 'semantic' | 'syntax' | 'unsupported-diagram'{:ts}` +- **Type:** `'limit' | 'semantic' | 'syntax' | 'unsupported' | 'unsupported-diagram'{:ts}` The kind of failure. @@ -37,6 +37,7 @@ The kind of failure. | `limit` | The source, edge count, or output exceeds a `render` limit. | | `semantic` | The source parses but describes an invalid diagram, such as an invalid gantt date. | | `syntax` | The source does not parse. | +| `unsupported` | The module does not support the `theme` option, or a mix cannot read its color. | | `unsupported-diagram` | No diagram type is detected, or the type is not ported yet. | ```ts diff --git a/site/src/content/docs/api/compiler/create.mdx b/site/src/content/docs/api/compiler/create.mdx index 33fd66b..9d91bb7 100644 --- a/site/src/content/docs/api/compiler/create.mdx +++ b/site/src/content/docs/api/compiler/create.mdx @@ -72,3 +72,5 @@ create({ modules: {} }).render('flowchart LR\n A --> B') ### RenderError If `render` cannot render the source, it throws a [`RenderError`](/docs/api/compiler/RenderError). This also applies if no supplied module contains the diagram type of the source. + +The size-optimized modules leave out the `theme` option. With `theme`, they throw a `RenderError` with the code `unsupported`. Supply `ondine/compiler/wasm/all.fast.wasm` to use it. diff --git a/site/src/content/docs/api/compiler/render.mdx b/site/src/content/docs/api/compiler/render.mdx index 33efba8..e21f49d 100644 --- a/site/src/content/docs/api/compiler/render.mdx +++ b/site/src/content/docs/api/compiler/render.mdx @@ -199,7 +199,7 @@ Writes the theme into the SVG as literal values. The output has no `var()` or `c Each key is an `--ondine-*` property name without its prefix, in camel case: `accent`, `bg`, `boldWeight`, `border`, `critical`, `fg`, `font`, `headingWeight`, `line`, `muted`, `radius`, `series1` to `series8`, `stroke`, `surface`, `surfaceAlt`, and `titleWeight`. A property without a key keeps its [default](/docs/guides/theming). An empty object writes the default theme. -Default shades mix `fg` into `bg`. Give these two as hex or `transparent` colors, and the compiler writes each shade as a hex color. The SVG then ignores `--ondine-*` properties on the page, so a new theme needs a new render. +Default shades mix `fg` into `bg`, and the compiler writes each shade as a hex color. A color in a mix must be a hex, named, `rgb()`, `rgba()`, or `transparent` color. The SVG then ignores `--ondine-*` properties on the page, so a new theme needs a new render. ```ts import { render } from 'ondine/compiler' @@ -279,7 +279,9 @@ const { width } = render('flowchart LR\n A --> B') ### RenderError -If `render` cannot render the source, it throws a `RenderError`. The error `code` is `limit`, `semantic`, `syntax`, or `unsupported-diagram`. See [`RenderError`](/docs/api/compiler/RenderError). +If `render` cannot render the source, it throws a `RenderError`. The error `code` is `limit`, `semantic`, `syntax`, `unsupported`, or `unsupported-diagram`. See [`RenderError`](/docs/api/compiler/RenderError). + +The code is `unsupported` if a mix reads a `theme` color in another form, such as `hsl()` or `oklch()`. The message names the key. ```ts import { render, RenderError } from 'ondine/compiler' diff --git a/site/src/content/docs/guides/theming.mdx b/site/src/content/docs/guides/theming.mdx index 22a9eca..07979f3 100644 --- a/site/src/content/docs/guides/theming.mdx +++ b/site/src/content/docs/guides/theming.mdx @@ -203,6 +203,8 @@ const { svg } = render('flowchart LR\n A --> B', { Keys are the property names without the `--ondine-` prefix, in camel case, such as `surfaceAlt` and `series1`. The SVG then ignores `--ondine-*` properties on the page. Thus a new scheme needs a new render. +Default shades mix `fg` into `bg`. A color in a mix must be a hex, named, `rgb()`, `rgba()`, or `transparent` color, or the render throws. Browser modules leave the option out for size, so it needs `ondine/compiler`. + ## More diff --git a/src/Render.test.ts b/src/Render.test.ts index d6abd4c..5fa83e7 100644 --- a/src/Render.test.ts +++ b/src/Render.test.ts @@ -31,6 +31,14 @@ describe('render', () => { ) }) + test('rejects a theme color that a mix cannot read', () => { + expect(() => + render('pie\n "A" : 3', { theme: { fg: 'hsl(0 0% 0%)' } }), + ).toThrowErrorMatchingInlineSnapshot( + `[Render.RenderError: The theme value for "fg" cannot be mixed. Use a hex, named, rgb(), or transparent color.]`, + ) + }) + test('writes the theme into every vector', () => { const themed = Vectors.types().flatMap((type) => Vectors.of(type).flatMap((vector) => { @@ -70,4 +78,26 @@ describe('create', () => { `[Render.RenderError: No diagram type detected for the given text.]`, ) }) + + test('rejects the theme option in a size-optimized module', () => { + const module = new WebAssembly.Module( + Fs.readFileSync(new URL('./internal/flowchart.generated.wasm', import.meta.url)), + ) + const renderer = Render.create({ modules: { flowchart: module } }) + + const error = (() => { + try { + renderer.render('flowchart LR\n A --> B', { theme: {} }) + } catch (error) { + if (error instanceof RenderError) return error + throw error + } + return undefined + })() + + expect(error).toMatchInlineSnapshot( + `[Render.RenderError: This module does not support the theme option. Use ondine/compiler.]`, + ) + expect(error?.code).toMatchInlineSnapshot(`"unsupported"`) + }) }) diff --git a/src/Render.ts b/src/Render.ts index ebd9235..fb0be28 100644 --- a/src/Render.ts +++ b/src/Render.ts @@ -28,7 +28,7 @@ export class RenderError extends Error { export declare namespace RenderError { /** The kind of failure. */ - type Code = 'limit' | 'semantic' | 'syntax' | 'unsupported-diagram' + type Code = 'limit' | 'semantic' | 'syntax' | 'unsupported' | 'unsupported-diagram' /** Constructor fields. */ type Options = { @@ -135,6 +135,9 @@ export declare namespace render { * The output has no `var()` or `color-mix()`, so renderers without them draw it, such as resvg, * librsvg, and native SVG parsers. Properties without a value keep their defaults, and an * empty object writes the default theme. The SVG then ignores `--ondine-*` properties on the page. + * + * Only the speed-optimized module of `ondine/compiler` and `ondine/compiler/wasm/all.fast.wasm` + * supports this option. Other modules throw a {@link RenderError} with the code `unsupported`. */ readonly theme?: Theme | undefined } @@ -155,9 +158,11 @@ export declare namespace render { /** * Values for the `--ondine-*` properties that the `theme` option writes into the SVG. Each key is - * a property name without its prefix, in camel case. Values are CSS values. Default shades mix - * the foreground and background, so `bg` and `fg` take hex or `transparent` colors. A - * mix of other colors takes its first color. + * a property name without its prefix, in camel case. Values are CSS values. + * + * Default shades mix the foreground and background. A color in a mix must be a hex, named, + * `rgb()`, `rgba()`, or `transparent` color. If a mix reads a value in another form, such as + * `hsl()`, the render throws a {@link RenderError} with the code `unsupported` that names its key. */ type Theme = { /** `--ondine-accent`: highlighted elements. Defaults to `fg`. */