Markdown pictures: freed with their elements, glyphs whole, failures keep the source, file URLs parsed, Mermaid labels in Ely's ink
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d5ecc5a69e
commit
ab9a14c692
12 files changed
+417
-126
No files matched your search
@@ -159,7 +159,7 @@ impl BlockEditor {
|
||||
}
|
||||
BlockKind::Math => {
|
||||
let tex = block.fields[0].read(cx).text().to_string();
|
||||
let shown = match Latex::parse(&tex) {
|
||||
let shown = match Latex::parse((id.clone(), format!("math-{key}")), &tex) {
|
||||
_ if tex.trim().is_empty() => div().into_any_element(),
|
||||
Ok(math) => div()
|
||||
.flex()
|
||||
|
||||
+25
-11
@@ -4,22 +4,27 @@ use std::{
|
||||
};
|
||||
|
||||
use gpui::{
|
||||
App, Asset, Hsla, IntoElement, ParentElement, Pixels, RenderImage, RenderOnce, SharedString,
|
||||
Size, Styled, Window, div, img, px, size,
|
||||
App, Asset, ElementId, Hsla, IntoElement, ParentElement, Pixels, RenderImage, RenderOnce,
|
||||
SharedString, Size, Styled, Window, div, img, px, size,
|
||||
};
|
||||
use mermaid_rs_renderer::{LayoutConfig, RenderOptions, Theme, render_strict};
|
||||
|
||||
use crate::theme::{ActiveTheme, Radius, TextSize};
|
||||
use crate::{
|
||||
primitives::hold,
|
||||
theme::{ActiveTheme, Radius, TextSize},
|
||||
};
|
||||
|
||||
/// A Mermaid diagram; a failure shows why and the source.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Diagram {
|
||||
id: ElementId,
|
||||
source: SharedString,
|
||||
}
|
||||
|
||||
impl Diagram {
|
||||
pub fn new(source: impl Into<SharedString>) -> Self {
|
||||
pub fn new(id: impl Into<ElementId>, source: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
source: source.into(),
|
||||
}
|
||||
}
|
||||
@@ -66,9 +71,14 @@ fn svg(chart: &Chart) -> Result<String, SharedString> {
|
||||
true => Theme::dark(),
|
||||
false => Theme::modern(),
|
||||
};
|
||||
theme.background = chart.ground.to_string();
|
||||
theme.edge_label_background = chart.ground.to_string();
|
||||
theme.text_color = chart.ink.to_string();
|
||||
let (ground, ink) = (chart.ground.to_string(), chart.ink.to_string());
|
||||
theme.background = ground.clone();
|
||||
theme.edge_label_background = ground;
|
||||
theme.primary_text_color = ink.clone();
|
||||
theme.text_color = ink.clone();
|
||||
theme.pie_title_text_color = ink.clone();
|
||||
theme.pie_section_text_color = ink.clone();
|
||||
theme.pie_legend_text_color = ink;
|
||||
let options = RenderOptions {
|
||||
theme,
|
||||
layout: LayoutConfig::default(),
|
||||
@@ -114,7 +124,8 @@ impl RenderOnce for Diagram {
|
||||
theme.text_size(TextSize::Sm),
|
||||
theme.mono_family.clone(),
|
||||
);
|
||||
match window.use_asset::<Drawer>(&chart, cx) {
|
||||
let picture = |drawn: &Drawn| drawn.as_ref().ok().map(|(image, _)| image.clone());
|
||||
match hold::<Drawer>(self.id, &chart, picture, window, cx) {
|
||||
Some(Ok((picture, shape))) => div()
|
||||
.flex()
|
||||
.justify_center()
|
||||
@@ -157,9 +168,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_flowchart_draws_with_the_ground_and_ink() {
|
||||
let svg = svg(&chart("flowchart LR\n A --> B", true)).expect("draws");
|
||||
assert!(svg.contains("<svg"), "{svg}");
|
||||
assert!(svg.to_lowercase().contains("#2c2c2c"), "the ground: {svg}");
|
||||
let svg = svg(&chart("flowchart LR\n A[Start] --> B", true)).expect("draws");
|
||||
let svg = svg.to_lowercase();
|
||||
assert!(svg.contains("#2c2c2c"), "the ground: {svg}");
|
||||
let label = svg.find(">start<").expect("the label");
|
||||
let tag = &svg[svg[..label].rfind("<text").expect("its tag")..label];
|
||||
assert!(tag.contains("fill=\"#eeeeee\""), "the label's ink: {tag}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+59
-30
@@ -5,14 +5,14 @@ use std::{
|
||||
|
||||
use base64::Engine as _;
|
||||
use gpui::{
|
||||
AnyElement, App, Asset, Hsla, Image, ImageAssetLoader, ImageFormat, InteractiveElement,
|
||||
IntoElement, ParentElement, RenderImage, RenderOnce, Resource, SMOOTH_SVG_SCALE_FACTOR,
|
||||
SharedString, Styled, Window, div, img, px,
|
||||
AnyElement, App, Asset, ElementId, Hsla, Image, ImageAssetLoader, ImageFormat,
|
||||
InteractiveElement, IntoElement, ParentElement, RenderImage, RenderOnce, Resource,
|
||||
SMOOTH_SVG_SCALE_FACTOR, SharedString, Styled, Window, div, img, px,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
i18n,
|
||||
primitives::{Icon, IconName},
|
||||
primitives::{Icon, IconName, hold},
|
||||
theme::{ActiveTheme, IconSize, Radius},
|
||||
};
|
||||
|
||||
@@ -62,14 +62,8 @@ pub(crate) fn origin(url: &str, base: Option<&Path>) -> Origin {
|
||||
if lower.starts_with("data:") {
|
||||
return inline(url);
|
||||
}
|
||||
if let Some(rest) = url.strip_prefix("file://") {
|
||||
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
|
||||
// Windows writes file:///C:/…
|
||||
let rest = match rest.as_bytes() {
|
||||
[b'/', _, b':', ..] if cfg!(windows) => &rest[1..],
|
||||
_ => rest,
|
||||
};
|
||||
return path(rest).map_or_else(Origin::Bad, Origin::File);
|
||||
if lower.starts_with("file:") {
|
||||
return file(&url[5..]);
|
||||
}
|
||||
let scheme = url.split_once(':').is_some_and(|(scheme, _)| {
|
||||
scheme.len() > 1
|
||||
@@ -88,6 +82,24 @@ pub(crate) fn origin(url: &str, base: Option<&Path>) -> Origin {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `file:` address after its scheme: host, path, no query.
|
||||
fn file(rest: &str) -> Origin {
|
||||
let rest = rest.split(['?', '#']).next().unwrap_or_default();
|
||||
let (host, written) = match rest.strip_prefix("//") {
|
||||
Some(after) => after.split_at(after.find('/').unwrap_or(after.len())),
|
||||
None => ("", rest),
|
||||
};
|
||||
let local = host.is_empty() || host.eq_ignore_ascii_case("localhost");
|
||||
let written = match (local, written.as_bytes()) {
|
||||
// Windows writes file:///C:/…
|
||||
(true, [b'/', _, b':', ..]) if cfg!(windows) => written[1..].to_string(),
|
||||
(true, _) => written.to_string(),
|
||||
(false, _) if cfg!(windows) => format!("//{host}{written}"),
|
||||
(false, _) => return Origin::Bad(format!("files on {host} are not read").into()),
|
||||
};
|
||||
path(&written).map_or_else(Origin::Bad, Origin::File)
|
||||
}
|
||||
|
||||
/// A `data:` address: base64 bytes of an image type.
|
||||
fn inline(url: &str) -> Origin {
|
||||
let Some((head, payload)) = url[5..].split_once(',') else {
|
||||
@@ -128,6 +140,7 @@ impl Asset for Unpacker {
|
||||
/// The picture at `url`, natural size up to the column.
|
||||
#[derive(IntoElement)]
|
||||
pub(crate) struct Picture {
|
||||
pub id: ElementId,
|
||||
pub alt: String,
|
||||
pub url: String,
|
||||
/// The folder relative addresses start from.
|
||||
@@ -136,32 +149,32 @@ pub(crate) struct Picture {
|
||||
|
||||
impl RenderOnce for Picture {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
picture(&self.alt, &self.url, self.base.as_deref(), window, cx)
|
||||
picture(self, window, cx)
|
||||
}
|
||||
}
|
||||
|
||||
fn picture(
|
||||
alt: &str,
|
||||
url: &str,
|
||||
base: Option<&Path>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement {
|
||||
fn picture(shown: Picture, window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
let Picture { id, alt, url, base } = shown;
|
||||
let (alt, url) = (alt.as_str(), url.as_str());
|
||||
let colors = cx.theme().colors.clone();
|
||||
let (loaded, svg) = match origin(url, base) {
|
||||
let (loaded, svg) = match origin(url, base.as_deref()) {
|
||||
Origin::File(path) => {
|
||||
let svg = path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("svg"));
|
||||
let resource = Resource::Path(path.into());
|
||||
let loaded = window
|
||||
.use_asset::<ImageAssetLoader>(&resource, cx)
|
||||
let image = |loaded: &Result<Arc<RenderImage>, _>| loaded.as_ref().ok().cloned();
|
||||
let loaded = hold::<ImageAssetLoader>(id, &resource, image, window, cx)
|
||||
.map(|loaded| loaded.map_err(|error| SharedString::from(error.to_string())));
|
||||
(loaded, svg)
|
||||
}
|
||||
Origin::Data(format, bytes) => {
|
||||
let svg = format == ImageFormat::Svg;
|
||||
(window.use_asset::<Unpacker>(&(format, bytes), cx), svg)
|
||||
let image = |loaded: &Result<Arc<RenderImage>, _>| loaded.as_ref().ok().cloned();
|
||||
(
|
||||
hold::<Unpacker>(id, &(format, bytes), image, window, cx),
|
||||
svg,
|
||||
)
|
||||
}
|
||||
Origin::Web => {
|
||||
let said = i18n::text(cx, "markdown.picture.web", &[]);
|
||||
@@ -240,19 +253,35 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_addresses_decode() {
|
||||
assert_eq!(
|
||||
origin("file:///tmp/a%23b.png", None),
|
||||
Origin::File(PathBuf::from("/tmp/a#b.png"))
|
||||
);
|
||||
fn file_addresses_decode_without_query_or_fragment() {
|
||||
for url in [
|
||||
"file:///tmp/a%23b.png",
|
||||
"file://localhost/tmp/a%23b.png#x",
|
||||
"FILE:/tmp/a%23b.png?y",
|
||||
] {
|
||||
assert_eq!(
|
||||
origin(url, None),
|
||||
Origin::File(PathBuf::from("/tmp/a#b.png")),
|
||||
"{url}"
|
||||
);
|
||||
}
|
||||
if cfg!(windows) {
|
||||
assert_eq!(
|
||||
origin("file:///C:/notes/a.png", None),
|
||||
origin("file:///C:/notes/a.png#preview", None),
|
||||
Origin::File(PathBuf::from("C:/notes/a.png"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_on_another_host_is_its_share_or_refused() {
|
||||
let found = origin("file://server/share/a.png", Some(Path::new("/notes")));
|
||||
match cfg!(windows) {
|
||||
true => assert_eq!(found, Origin::File(PathBuf::from("//server/share/a.png"))),
|
||||
false => assert!(matches!(found, Origin::Bad(_)), "{found:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_addresses_carry_their_bytes() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::{
|
||||
navigation::{Anchor, Sections},
|
||||
primitives::{FocusRing as _, Icon, IconName},
|
||||
theme::{ActiveTheme, IconSize, Radius, TextSize},
|
||||
typography::Latex,
|
||||
typography::{Latex, failed},
|
||||
};
|
||||
|
||||
type OnLink = Rc<dyn Fn(&str, &mut Window, &mut App)>;
|
||||
@@ -214,7 +214,7 @@ impl Draw<'_> {
|
||||
.into_any_element(),
|
||||
Node::Code { language, text } => {
|
||||
if language.as_deref() == Some("mermaid") {
|
||||
return Diagram::new(text.clone()).into_any_element();
|
||||
return Diagram::new(self.key("diagram"), text.clone()).into_any_element();
|
||||
}
|
||||
if let Some(draw) = self.code {
|
||||
return draw(
|
||||
@@ -351,16 +351,14 @@ impl Draw<'_> {
|
||||
.flex()
|
||||
.justify_center()
|
||||
.py_2()
|
||||
.child(match Latex::parse(tex) {
|
||||
.child(match Latex::parse(self.key("math"), tex) {
|
||||
Ok(math) => math.display().into_any_element(),
|
||||
Err(error) => div()
|
||||
.text_color(colors.danger)
|
||||
.child(error.to_string())
|
||||
.into_any_element(),
|
||||
Err(error) => failed(&error, tex, cx),
|
||||
})
|
||||
.into_any_element(),
|
||||
Node::Image { alt, url, link } => {
|
||||
let picture = div().id(self.key("image")).child(Picture {
|
||||
id: self.key("picture"),
|
||||
alt: alt.clone(),
|
||||
url: url.clone(),
|
||||
base: self.base.clone(),
|
||||
|
||||
@@ -358,3 +358,15 @@ fn a_picture_beside_the_file_draws_at_its_ratio_within_the_column(cx: &mut TestA
|
||||
fn a_picture_narrower_than_the_column_keeps_its_size(cx: &mut TestAppContext) {
|
||||
assert_eq!(drawn_picture(400.0, cx), size(px(200.0), px(100.0)));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_formula_that_fails_shows_why_and_its_source(cx: &mut TestAppContext) {
|
||||
cx.update(Theme::init);
|
||||
let (_, cx) = cx.add_window_view(|_, _| Page {
|
||||
source: "$$x^2^3$$\n",
|
||||
base: std::env::temp_dir(),
|
||||
width: 300.0,
|
||||
});
|
||||
settle(cx);
|
||||
assert!(cx.debug_bounds("latex-error").is_some(), "the failure drew");
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::{App, Asset, ElementId, RenderImage, Window};
|
||||
|
||||
/// An element's asset; freed on change or when it leaves.
|
||||
pub(crate) struct Held<A: Asset> {
|
||||
source: Option<A::Source>,
|
||||
image: Option<Arc<RenderImage>>,
|
||||
}
|
||||
|
||||
impl<A: Asset> Held<A> {
|
||||
fn free(&mut self, cx: &mut App) {
|
||||
if let Some(source) = self.source.take() {
|
||||
cx.remove_asset::<A>(&source);
|
||||
}
|
||||
if let Some(image) = self.image.take() {
|
||||
// The window may be drawing; free after it.
|
||||
cx.defer(move |cx| cx.drop_image(image, None));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `source` as `A` for element `id`; the previous one freed.
|
||||
pub(crate) fn hold<A: Asset>(
|
||||
id: impl Into<ElementId>,
|
||||
source: &A::Source,
|
||||
image: impl Fn(&A::Output) -> Option<Arc<RenderImage>>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Option<A::Output>
|
||||
where
|
||||
A::Source: PartialEq,
|
||||
{
|
||||
let held = window.use_keyed_state(id, cx, |_, cx| {
|
||||
cx.on_release(|held: &mut Held<A>, cx| held.free(cx))
|
||||
.detach();
|
||||
Held {
|
||||
source: None,
|
||||
image: None,
|
||||
}
|
||||
});
|
||||
let loaded = window.use_asset::<A>(source, cx);
|
||||
held.update(cx, |held, cx| {
|
||||
if held.source.as_ref() != Some(source) {
|
||||
held.free(cx);
|
||||
held.source = Some(source.clone());
|
||||
}
|
||||
if let Some(loaded) = &loaded {
|
||||
held.image = image(loaded);
|
||||
}
|
||||
});
|
||||
loaded
|
||||
}
|
||||
@@ -5,6 +5,7 @@ mod divider;
|
||||
mod files;
|
||||
mod focus;
|
||||
mod ghost;
|
||||
mod held;
|
||||
mod icon;
|
||||
mod image;
|
||||
mod layer;
|
||||
@@ -28,6 +29,7 @@ pub(crate) use focus::{
|
||||
};
|
||||
pub use focus::{FocusNext, FocusPrev, FocusRing, FocusScope};
|
||||
pub use ghost::DragGhost;
|
||||
pub(crate) use held::hold;
|
||||
pub use icon::{Icon, IconName};
|
||||
pub use image::Image;
|
||||
pub(crate) use image::{checked_ratio, framed};
|
||||
|
||||
+112
-71
@@ -5,39 +5,67 @@ use std::{
|
||||
};
|
||||
|
||||
use gpui::{
|
||||
App, Asset, IntoElement, ParentElement, Pixels, RenderImage, RenderOnce, SharedString, Size,
|
||||
Styled, Window, div, img, px, size,
|
||||
AnyElement, App, Asset, ElementId, InteractiveElement, IntoElement, ParentElement, Pixels,
|
||||
RenderImage, RenderOnce, SharedString, Size, Styled, Window, div, img, px, size,
|
||||
};
|
||||
use ratex_layout::{LayoutOptions, layout, to_display_list};
|
||||
use ratex_svg::{SvgOptions, render_to_svg};
|
||||
use ratex_types::{color::Color, math_style::MathStyle};
|
||||
|
||||
use crate::theme::{ActiveTheme, TextSize};
|
||||
use crate::{
|
||||
primitives::hold,
|
||||
theme::{ActiveTheme, TextSize},
|
||||
};
|
||||
|
||||
/// Why a formula did not typeset: RaTeX's own words.
|
||||
/// Why a formula did not typeset, and where.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MathError(SharedString);
|
||||
pub struct MathError {
|
||||
message: SharedString,
|
||||
/// The offset RaTeX names, when it names one.
|
||||
at: Option<usize>,
|
||||
}
|
||||
|
||||
impl fmt::Display for MathError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
match self.at {
|
||||
Some(at) => write!(f, "{} at {at}", self.message),
|
||||
None => f.write_str(&self.message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MathError {}
|
||||
|
||||
/// TeX typeset by RaTeX, in the text's color.
|
||||
impl MathError {
|
||||
fn said(message: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parsed(source: &str) -> Result<Vec<ratex_parser::parse_node::ParseNode>, MathError> {
|
||||
ratex_parser::parse(source).map_err(|error| MathError {
|
||||
message: error.message.into(),
|
||||
at: error.loc.map(|loc| loc.start),
|
||||
})
|
||||
}
|
||||
|
||||
/// TeX typeset by RaTeX; a failure shows why and source.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Latex {
|
||||
id: ElementId,
|
||||
source: SharedString,
|
||||
size: TextSize,
|
||||
display: bool,
|
||||
}
|
||||
|
||||
impl Latex {
|
||||
pub fn parse(source: &str) -> Result<Self, MathError> {
|
||||
ratex_parser::parse(source).map_err(|error| MathError(error.message.into()))?;
|
||||
pub fn parse(id: impl Into<ElementId>, source: &str) -> Result<Self, MathError> {
|
||||
parsed(source)?;
|
||||
Ok(Self {
|
||||
id: id.into(),
|
||||
source: source.to_string().into(),
|
||||
size: TextSize::Lg,
|
||||
display: false,
|
||||
@@ -82,16 +110,15 @@ impl Asset for Typesetter {
|
||||
let (svg, shape) = svg(&formula)?;
|
||||
let picture = renderer
|
||||
.render_single_frame(svg.as_bytes(), formula.scale as f32 / 100.0)
|
||||
.map_err(|error| MathError(format!("{error}").into()))?;
|
||||
.map_err(|error| MathError::said(format!("{error}")))?;
|
||||
Ok((picture, shape))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The formula as SVG, glyphs outlined, and its size.
|
||||
/// The formula as outlined SVG holding all ink; its size.
|
||||
fn svg(formula: &Formula) -> Result<(String, Size<Pixels>), MathError> {
|
||||
let ast =
|
||||
ratex_parser::parse(&formula.source).map_err(|error| MathError(error.message.into()))?;
|
||||
let ast = parsed(&formula.source)?;
|
||||
let [r, g, b, a] = formula
|
||||
.rgba
|
||||
.to_be_bytes()
|
||||
@@ -103,8 +130,7 @@ fn svg(formula: &Formula) -> Result<(String, Size<Pixels>), MathError> {
|
||||
})
|
||||
.with_color(Color::new(r, g, b, a));
|
||||
let font = f64::from(formula.font) / 100.0;
|
||||
// RaTeX may panic on input it parsed.
|
||||
let laid = catch_unwind(AssertUnwindSafe(|| {
|
||||
let laid = guarded(|| {
|
||||
let list = to_display_list(&layout(&ast, &options));
|
||||
let svg = render_to_svg(
|
||||
&list,
|
||||
@@ -116,80 +142,95 @@ fn svg(formula: &Formula) -> Result<(String, Size<Pixels>), MathError> {
|
||||
font_dir: String::new(),
|
||||
},
|
||||
);
|
||||
(list.width, list.height + list.depth, svg)
|
||||
}));
|
||||
let (width, height, svg) = laid.map_err(|_| {
|
||||
log::error!("latex: RaTeX failed to lay out {:?}", formula.source);
|
||||
MathError("RaTeX could not lay out this formula".into())
|
||||
(list.width * font, (list.height + list.depth) * font, svg)
|
||||
})?;
|
||||
let shape = size(px((width * font) as f32), px((height * font) as f32));
|
||||
Ok((svg, shape))
|
||||
let (width, height, svg) = laid;
|
||||
framed(&svg, width as f32, height as f32)
|
||||
}
|
||||
|
||||
impl RenderOnce for Latex {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
/// Runs RaTeX's layout; a panic becomes an error.
|
||||
fn guarded<T>(lay: impl FnOnce() -> T) -> Result<T, MathError> {
|
||||
catch_unwind(AssertUnwindSafe(lay)).map_err(|_| {
|
||||
// The formula is the user's text: not logged.
|
||||
log::error!("latex: RaTeX failed to lay out a formula");
|
||||
MathError::said("RaTeX could not lay out this formula")
|
||||
})
|
||||
}
|
||||
|
||||
/// `svg` reframed around its ink and layout box.
|
||||
fn framed(svg: &str, width: f32, height: f32) -> Result<(String, Size<Pixels>), MathError> {
|
||||
let tree = usvg::Tree::from_data(svg.as_bytes(), &usvg::Options::default())
|
||||
.map_err(|error| MathError::said(format!("{error}")))?;
|
||||
let ink = tree.root().abs_stroke_bounding_box();
|
||||
let left = ink.left().min(0.0);
|
||||
let top = ink.top().min(0.0);
|
||||
let right = ink.right().max(width);
|
||||
let bottom = ink.bottom().max(height);
|
||||
let open = svg
|
||||
.find('>')
|
||||
.ok_or_else(|| MathError::said("RaTeX wrote no svg tag"))?;
|
||||
let close = svg
|
||||
.rfind("</svg>")
|
||||
.ok_or_else(|| MathError::said("RaTeX left the svg open"))?;
|
||||
let (w, h) = (right - left, bottom - top);
|
||||
let reframed = format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"{left} {top} {w} {h}\" width=\"{w}\" height=\"{h}\">{}</svg>",
|
||||
&svg[open + 1..close]
|
||||
);
|
||||
Ok((reframed, size(px(w), px(h))))
|
||||
}
|
||||
|
||||
impl Latex {
|
||||
/// The asset key this formula draws from in `window`.
|
||||
fn formula(&self, window: &Window, cx: &App) -> (Formula, Pixels) {
|
||||
let theme = cx.theme();
|
||||
let (color, danger) = (theme.colors.fg, theme.colors.danger);
|
||||
let font = theme.text_size(self.size).to_pixels(window.rem_size());
|
||||
let formula = Formula {
|
||||
source: self.source.clone(),
|
||||
display: self.display,
|
||||
rgba: u32::from(color.to_rgb()),
|
||||
rgba: u32::from(theme.colors.fg.to_rgb()),
|
||||
font: (font.as_f32() * 100.0).round() as u32,
|
||||
scale: (window.scale_factor() * 100.0).round() as u32,
|
||||
};
|
||||
match window.use_asset::<Typesetter>(&formula, cx) {
|
||||
(formula, font)
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Latex {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let (formula, font) = self.formula(window, cx);
|
||||
let picture = |typeset: &Typeset| typeset.as_ref().ok().map(|(image, _)| image.clone());
|
||||
match hold::<Typesetter>(self.id, &formula, picture, window, cx) {
|
||||
Some(Ok((picture, shape))) => img(picture)
|
||||
.w(shape.width)
|
||||
.h(shape.height)
|
||||
.into_any_element(),
|
||||
Some(Err(error)) => div()
|
||||
.text_color(danger)
|
||||
.child(error.to_string())
|
||||
.into_any_element(),
|
||||
Some(Err(error)) => failed(&error, &self.source, cx),
|
||||
None => div().h(font).into_any_element(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn formula(source: &str, display: bool) -> Formula {
|
||||
Formula {
|
||||
source: source.to_string().into(),
|
||||
display,
|
||||
rgba: 0x112233ff,
|
||||
font: 1600,
|
||||
scale: 200,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_formula_typesets_to_outlined_svg_in_its_color() {
|
||||
let (svg, shape) = svg(&formula(r"\frac{a}{b} + \sqrt{x}", false)).expect("typesets");
|
||||
assert!(svg.starts_with("<svg") && !svg.contains("<text"), "{svg}");
|
||||
assert!(
|
||||
svg.contains("17,34,51") || svg.contains("#112233"),
|
||||
"the color: {svg}"
|
||||
);
|
||||
assert!(shape.width > px(16.) && shape.height > px(16.), "{shape:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_style_sets_limits_larger() {
|
||||
let (_, text) = svg(&formula(r"\sum_{k=1}^{n} k", false)).expect("typesets");
|
||||
let (_, display) = svg(&formula(r"\sum_{k=1}^{n} k", true)).expect("typesets");
|
||||
assert!(display.height > text.height, "{display:?} over {text:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_tex_says_why() {
|
||||
let Err(error) = Latex::parse(r"\frac{a}") else {
|
||||
panic!("a missing argument parses")
|
||||
};
|
||||
assert!(!error.to_string().is_empty());
|
||||
assert!(Latex::parse(r"x^{2").is_err(), "an open brace");
|
||||
}
|
||||
/// A formula that did not typeset: why, then its source.
|
||||
pub(crate) fn failed(error: &MathError, source: &str, cx: &App) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.debug_selector(|| "latex-error".into())
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(
|
||||
div()
|
||||
.text_color(theme.colors.danger)
|
||||
.child(error.to_string()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.font_family(theme.mono_family.clone())
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child(source.to_string()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,140 @@
|
||||
use super::*;
|
||||
|
||||
fn formula(source: &str, display: bool) -> Formula {
|
||||
Formula {
|
||||
source: source.to_string().into(),
|
||||
display,
|
||||
rgba: 0x112233ff,
|
||||
font: 1600,
|
||||
scale: 200,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_formula_typesets_to_outlined_svg_in_its_color() {
|
||||
let (svg, shape) = svg(&formula(r"\frac{a}{b} + \sqrt{x}", false)).expect("typesets");
|
||||
assert!(svg.starts_with("<svg") && !svg.contains("<text"), "{svg}");
|
||||
assert!(svg.contains("17,34,51"), "the color: {svg}");
|
||||
assert!(shape.width > px(16.) && shape.height > px(16.), "{shape:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_style_sets_limits_larger() {
|
||||
let (_, text) = svg(&formula(r"\sum_{k=1}^{n} k", false)).expect("typesets");
|
||||
let (_, display) = svg(&formula(r"\sum_{k=1}^{n} k", true)).expect("typesets");
|
||||
assert!(display.height > text.height, "{display:?} over {text:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_glyph_lies_inside_the_picture() {
|
||||
for source in [
|
||||
r"\text{私密} x",
|
||||
"x",
|
||||
r"\int_0^1 f(x)\,dx",
|
||||
r"\left(\frac{a}{b}\right)",
|
||||
] {
|
||||
let (svg, shape) = svg(&formula(source, false)).expect("typesets");
|
||||
let tree = usvg::Tree::from_data(svg.as_bytes(), &usvg::Options::default()).expect("svg");
|
||||
let ink = tree.root().abs_stroke_bounding_box();
|
||||
let (w, h) = (shape.width.as_f32(), shape.height.as_f32());
|
||||
let near = |a: f32, b: f32| (a - b).abs() < 0.01;
|
||||
assert!(
|
||||
near(tree.size().width(), w) && near(tree.size().height(), h),
|
||||
"{source}"
|
||||
);
|
||||
assert!(
|
||||
ink.left() >= -0.01 && ink.top() >= -0.01,
|
||||
"{source}: ink {ink:?} starts before the picture"
|
||||
);
|
||||
assert!(
|
||||
ink.right() <= w + 0.01 && ink.bottom() <= h + 0.01,
|
||||
"{source}: ink {ink:?} passes {w}×{h}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_tex_says_why_and_where() {
|
||||
let Err(error) = Latex::parse("x", r"x^2^3") else {
|
||||
panic!("a double superscript parses")
|
||||
};
|
||||
assert_eq!(error.to_string(), "Double superscript at 3");
|
||||
assert!(Latex::parse("x", r"x^{2").is_err(), "an open brace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layout_panic_is_an_error_without_the_panic_text() {
|
||||
let laid: Result<(), MathError> = guarded(|| panic!("private formula text"));
|
||||
let error = laid.expect_err("the panic is an error");
|
||||
assert!(!error.to_string().contains("private"), "{error}");
|
||||
}
|
||||
|
||||
/// Formulas drawn by id; the test changes them.
|
||||
struct Shown(Vec<&'static str>);
|
||||
|
||||
impl gpui::Render for Shown {
|
||||
fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
|
||||
div().children(
|
||||
self.0
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, source)| Latex::parse(ix, source).expect("parses")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn open<'a>(
|
||||
sources: Vec<&'static str>,
|
||||
cx: &'a mut gpui::TestAppContext,
|
||||
) -> (gpui::Entity<Shown>, &'a mut gpui::VisualTestContext) {
|
||||
cx.update(crate::theme::Theme::init);
|
||||
let (view, cx) = cx.add_window_view(|_, _| Shown(sources));
|
||||
settle(cx);
|
||||
(view, cx)
|
||||
}
|
||||
|
||||
fn settle(cx: &mut gpui::VisualTestContext) {
|
||||
for _ in 0..3 {
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, _| window.refresh());
|
||||
}
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// Whether `source`'s picture sits in the asset cache.
|
||||
fn cached(source: &str, cx: &mut gpui::VisualTestContext) -> bool {
|
||||
cx.update(|window, cx| {
|
||||
let latex = Latex::parse("probe", source).expect("parses");
|
||||
let (formula, _) = latex.formula(window, cx);
|
||||
cx.has_asset::<Typesetter>(&formula)
|
||||
})
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_formula_frees_its_picture_when_it_changes_or_leaves(cx: &mut gpui::TestAppContext) {
|
||||
let (view, cx) = open(vec!["a^2"], cx);
|
||||
assert!(cached("a^2", cx), "drawn once");
|
||||
view.update(cx, |view, cx| {
|
||||
view.0 = vec!["b^2"];
|
||||
cx.notify();
|
||||
});
|
||||
settle(cx);
|
||||
assert!(!cached("a^2", cx) && cached("b^2", cx), "the old one freed");
|
||||
view.update(cx, |view, cx| {
|
||||
view.0.clear();
|
||||
cx.notify();
|
||||
});
|
||||
settle(cx);
|
||||
assert!(!cached("b^2", cx), "gone with its element");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_shared_formula_draws_on_after_one_user_leaves(cx: &mut gpui::TestAppContext) {
|
||||
let (view, cx) = open(vec!["c^2", "c^2"], cx);
|
||||
view.update(cx, |view, cx| {
|
||||
view.0.pop();
|
||||
cx.notify();
|
||||
});
|
||||
settle(cx);
|
||||
assert!(cached("c^2", cx), "the one left loads it again");
|
||||
}
|
||||
@@ -25,6 +25,7 @@ pub use formatted::{
|
||||
};
|
||||
pub use inline::{Blockquote, Code, Highlight, Kbd, KbdCombo};
|
||||
pub use link::{ExternalLink, Link};
|
||||
pub(crate) use math::failed;
|
||||
pub use math::{Latex, MathError};
|
||||
pub use rolling::AnimatedNumber;
|
||||
pub use select::SelectableText;
|
||||
|
||||
Reference in new issue
Block a user