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
+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;
|
||||
Reference in new issue
Block a user