From cec9689f262c73276692a7f28c4aada2119c33fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 12 Oct 2026 09:56:38 -0400 Subject: [PATCH] typography and documents: formula and diagram SVG for export; a diagram label without a font is an error --- src/documents/diagram.rs | 79 +++++++++++++++++++++++++++++++++++- src/documents/mod.rs | 2 +- src/typography/math.rs | 12 ++++++ src/typography/math/tests.rs | 8 ++++ src/typography/mod.rs | 2 +- 5 files changed, 100 insertions(+), 3 deletions(-) diff --git a/src/documents/diagram.rs b/src/documents/diagram.rs index 46e599f..52fbcce 100644 --- a/src/documents/diagram.rs +++ b/src/documents/diagram.rs @@ -1,6 +1,6 @@ use std::{ panic::{AssertUnwindSafe, catch_unwind}, - sync::Arc, + sync::{Arc, OnceLock}, }; use gpui::{ @@ -95,6 +95,72 @@ fn svg(chart: &Chart) -> Result { } } +/// `source` as light SVG of plain paths, for export. +pub fn diagram_svg(source: &str) -> Result { + let chart = Chart { + source: source.to_owned().into(), + dark: false, + ground: "#ffffff".into(), + ink: "#000000".into(), + scale: 100, + }; + let drawn = svg(&chart)?; + // Markers and text become paths any reader draws. + let options = usvg::Options { + fontdb: fonts(), + ..usvg::Options::default() + }; + let tree = usvg::Tree::from_data(drawn.as_bytes(), &options) + .map_err(|error| SharedString::from(format!("{error}")))?; + // A label without a font vanishes: say so. + let (written, kept) = (labels(&drawn)?, texts(tree.root())); + if kept < written { + log::error!("diagram: {kept} of {written} labels found a font"); + return Err("no installed font could set this diagram's labels".into()); + } + Ok(tree.to_string(&usvg::WriteOptions::default())) +} + +/// The `` elements holding words. +fn labels(svg: &str) -> Result { + let doc = usvg::roxmltree::Document::parse(svg) + .map_err(|error| SharedString::from(format!("{error}")))?; + let worded = |node: &usvg::roxmltree::Node| { + node.descendants() + .any(|word| word.text().is_some_and(|text| !text.trim().is_empty())) + }; + Ok(doc + .descendants() + .filter(|node| node.has_tag_name("text") && worded(node)) + .count()) +} + +/// Text nodes usvg set. +fn texts(group: &usvg::Group) -> usize { + group + .children() + .iter() + .map(|node| match node { + usvg::Node::Group(inner) => texts(inner), + usvg::Node::Text(_) => 1, + _ => 0, + }) + .sum() +} + +/// The system's fonts, read once. +fn fonts() -> Arc { + static FONTS: OnceLock> = OnceLock::new(); + FONTS + .get_or_init(|| { + let mut fonts = usvg::fontdb::Database::new(); + fonts.load_system_fonts(); + log::info!("diagram: {} system font faces for export", fonts.len()); + Arc::new(fonts) + }) + .clone() +} + /// `#rrggbb` of a theme color. fn hex(color: Hsla) -> SharedString { let rgb = color.to_rgb(); @@ -182,6 +248,17 @@ mod tests { assert!(!why.is_empty()); } + #[test] + fn an_exported_diagram_is_plain_paths() { + let svg = diagram_svg( + "flowchart LR + A[Start] --> B", + ) + .expect("draws"); + assert!(svg.contains(" Result { + let formula = Formula { + source: source.to_owned().into(), + display, + rgba: 0x0000_00ff, + font: 1600, + scale: 100, + }; + svg(&formula).map(|(svg, _)| svg) +} + /// The formula as outlined SVG holding all ink; its size. fn svg(formula: &Formula) -> Result<(String, Size), MathError> { let ast = parsed(&formula.source)?; diff --git a/src/typography/math/tests.rs b/src/typography/math/tests.rs index 130da22..5ad86eb 100644 --- a/src/typography/math/tests.rs +++ b/src/typography/math/tests.rs @@ -177,3 +177,11 @@ fn a_shared_formula_stays_until_its_last_holder_leaves(cx: &mut gpui::TestAppCon flush(cx); assert!(!cached("c^2", cx), "the last holder freed it"); } + +#[test] +fn an_exported_formula_is_black_outlines() { + let svg = formula_svg(r"\sqrt{x}", true).expect("typesets"); + assert!(svg.starts_with("