Markdown: display math through RaTeX, Mermaid through mermaid-rs-renderer, pictures from the owner's folder
Latex typesets with RaTeX to outlined SVG drawn by gpui's SvgRenderer at the window's scale, loaded as an asset off the main thread. Diagram draws a mermaid block in Mermaid's theme for the mode. MarkdownRenderer::base names the folder pictures load from; data addresses decode, web ones stay unloaded. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
508cc55af6
commit
d5ecc5a69e
11 files changed
+927
-328
No files matched your search
@@ -0,0 +1,175 @@
|
||||
use std::{
|
||||
panic::{AssertUnwindSafe, catch_unwind},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use gpui::{
|
||||
App, Asset, 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};
|
||||
|
||||
/// A Mermaid diagram; a failure shows why and the source.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Diagram {
|
||||
source: SharedString,
|
||||
}
|
||||
|
||||
impl Diagram {
|
||||
pub fn new(source: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
source: source.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a drawn diagram depends on.
|
||||
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
||||
struct Chart {
|
||||
source: SharedString,
|
||||
dark: bool,
|
||||
/// Background and text, as `#rrggbb`.
|
||||
ground: SharedString,
|
||||
ink: SharedString,
|
||||
/// Window scale, in hundredths.
|
||||
scale: u32,
|
||||
}
|
||||
|
||||
type Drawn = Result<(Arc<RenderImage>, Size<Pixels>), SharedString>;
|
||||
|
||||
enum Drawer {}
|
||||
|
||||
impl Asset for Drawer {
|
||||
type Source = Chart;
|
||||
type Output = Drawn;
|
||||
|
||||
fn load(chart: Chart, cx: &mut App) -> impl Future<Output = Drawn> + Send + 'static {
|
||||
let renderer = cx.svg_renderer();
|
||||
async move {
|
||||
let svg = svg(&chart)?;
|
||||
let tree = usvg::Tree::from_data(svg.as_bytes(), &usvg::Options::default())
|
||||
.map_err(|error| SharedString::from(format!("{error}")))?;
|
||||
let shape = size(px(tree.size().width()), px(tree.size().height()));
|
||||
let picture = renderer
|
||||
.render_single_frame(svg.as_bytes(), chart.scale as f32 / 100.0)
|
||||
.map_err(|error| SharedString::from(format!("{error}")))?;
|
||||
Ok((picture, shape))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The diagram as SVG in Mermaid's theme for the mode.
|
||||
fn svg(chart: &Chart) -> Result<String, SharedString> {
|
||||
let mut theme = match chart.dark {
|
||||
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 options = RenderOptions {
|
||||
theme,
|
||||
layout: LayoutConfig::default(),
|
||||
};
|
||||
// The renderer may panic on input it parsed.
|
||||
let drawn = catch_unwind(AssertUnwindSafe(|| render_strict(&chart.source, options)));
|
||||
match drawn {
|
||||
Ok(Ok(svg)) => Ok(svg),
|
||||
Ok(Err(error)) => Err(error.to_string().into()),
|
||||
Err(_) => {
|
||||
log::error!("diagram: the renderer failed on a parsed diagram");
|
||||
Err("the Mermaid renderer could not draw this diagram".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `#rrggbb` of a theme color.
|
||||
fn hex(color: Hsla) -> SharedString {
|
||||
let rgb = color.to_rgb();
|
||||
let channel = |value: f32| (value.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
format!(
|
||||
"#{:02x}{:02x}{:02x}",
|
||||
channel(rgb.r),
|
||||
channel(rgb.g),
|
||||
channel(rgb.b)
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
impl RenderOnce for Diagram {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let chart = Chart {
|
||||
source: self.source.clone(),
|
||||
dark: theme.is_dark(),
|
||||
ground: hex(colors.surface),
|
||||
ink: hex(colors.fg),
|
||||
scale: (window.scale_factor() * 100.0).round() as u32,
|
||||
};
|
||||
let (radius, small, mono) = (
|
||||
theme.radius(Radius::Md),
|
||||
theme.text_size(TextSize::Sm),
|
||||
theme.mono_family.clone(),
|
||||
);
|
||||
match window.use_asset::<Drawer>(&chart, cx) {
|
||||
Some(Ok((picture, shape))) => div()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.child(img(picture).w(shape.width).h(shape.height).max_w_full())
|
||||
.into_any_element(),
|
||||
Some(Err(why)) => div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.p_3()
|
||||
.rounded(radius)
|
||||
.bg(colors.sunken)
|
||||
.text_size(small)
|
||||
.child(div().text_color(colors.danger).child(why))
|
||||
.child(
|
||||
div()
|
||||
.font_family(mono)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(self.source),
|
||||
)
|
||||
.into_any_element(),
|
||||
None => div().h_12().into_any_element(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn chart(source: &str, dark: bool) -> Chart {
|
||||
Chart {
|
||||
source: source.to_string().into(),
|
||||
dark,
|
||||
ground: "#2c2c2c".into(),
|
||||
ink: "#eeeeee".into(),
|
||||
scale: 100,
|
||||
}
|
||||
}
|
||||
|
||||
#[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}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_diagram_says_why() {
|
||||
let why = svg(&chart("flowchart LR\n A -->", false)).expect_err("an error");
|
||||
assert!(!why.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colors_write_as_hex() {
|
||||
assert_eq!(hex(gpui::rgb(0x2c2c2c).into()), "#2c2c2c");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
mod blocks;
|
||||
mod counts;
|
||||
mod diagram;
|
||||
mod epub;
|
||||
mod find;
|
||||
mod format;
|
||||
@@ -9,6 +10,7 @@ mod link;
|
||||
mod markdown;
|
||||
mod modes;
|
||||
mod pages;
|
||||
mod picture;
|
||||
mod print;
|
||||
mod reading;
|
||||
mod render;
|
||||
@@ -28,6 +30,7 @@ use gpui::App;
|
||||
pub(crate) use blocks::source;
|
||||
pub use blocks::{Align, BlockData, BlockEditor, BlockEvent, BlockKind, Media};
|
||||
pub use counts::{ReadingTime, WordCount};
|
||||
pub use diagram::Diagram;
|
||||
pub use epub::{Chapter, EpubReader};
|
||||
pub use format::{Format, format};
|
||||
pub(crate) use history::Change;
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
i18n,
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, IconSize, Radius},
|
||||
};
|
||||
|
||||
/// Where a picture's bytes come from.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum Origin {
|
||||
File(PathBuf),
|
||||
/// Bytes inline in the address, and their type.
|
||||
Data(ImageFormat, Arc<[u8]>),
|
||||
Web,
|
||||
/// A relative path with no folder to read it in.
|
||||
Loose,
|
||||
/// An address that names no picture, and why.
|
||||
Bad(SharedString),
|
||||
}
|
||||
|
||||
/// `%XX` decoded; a broken escape stays as written.
|
||||
fn decoded(text: &str) -> Vec<u8> {
|
||||
let bytes = text.as_bytes();
|
||||
let (mut out, mut at) = (Vec::with_capacity(bytes.len()), 0);
|
||||
while at < bytes.len() {
|
||||
let pair = bytes.get(at + 1..at + 3);
|
||||
let hex =
|
||||
pair.and_then(|pair| u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok());
|
||||
match (bytes[at], hex) {
|
||||
(b'%', Some(byte)) => (out.push(byte), at += 3),
|
||||
(byte, _) => (out.push(byte), at += 1),
|
||||
};
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A path written in a link, `%XX` decoded.
|
||||
fn path(written: &str) -> Result<PathBuf, SharedString> {
|
||||
String::from_utf8(decoded(written))
|
||||
.map(PathBuf::from)
|
||||
.map_err(|_| "the path is not UTF-8".into())
|
||||
}
|
||||
|
||||
/// Where `url` points, read from the folder `base`.
|
||||
pub(crate) fn origin(url: &str, base: Option<&Path>) -> Origin {
|
||||
let url = url.trim();
|
||||
let lower = url.to_ascii_lowercase();
|
||||
if lower.starts_with("http://") || lower.starts_with("https://") || url.starts_with("//") {
|
||||
return Origin::Web;
|
||||
}
|
||||
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);
|
||||
}
|
||||
let scheme = url.split_once(':').is_some_and(|(scheme, _)| {
|
||||
scheme.len() > 1
|
||||
&& scheme
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || "+-.".contains(ch))
|
||||
});
|
||||
if scheme {
|
||||
return Origin::Bad(format!("{url} is no picture address").into());
|
||||
}
|
||||
let written = url.split(['?', '#']).next().unwrap_or_default();
|
||||
match path(written) {
|
||||
Ok(path) if path.is_absolute() => Origin::File(path),
|
||||
Ok(path) => base.map_or(Origin::Loose, |base| Origin::File(base.join(path))),
|
||||
Err(why) => Origin::Bad(why),
|
||||
}
|
||||
}
|
||||
|
||||
/// A `data:` address: base64 bytes of an image type.
|
||||
fn inline(url: &str) -> Origin {
|
||||
let Some((head, payload)) = url[5..].split_once(',') else {
|
||||
return Origin::Bad("the data address has no comma".into());
|
||||
};
|
||||
let Some(mime) = head.strip_suffix(";base64") else {
|
||||
return Origin::Bad("only base64 data addresses draw".into());
|
||||
};
|
||||
let Some(format) = ImageFormat::from_mime_type(&mime.to_ascii_lowercase()) else {
|
||||
return Origin::Bad(format!("{mime} is no image type").into());
|
||||
};
|
||||
match base64::engine::general_purpose::STANDARD.decode(payload.trim()) {
|
||||
Ok(bytes) => Origin::Data(format, bytes.into()),
|
||||
Err(error) => Origin::Bad(format!("{error}").into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline bytes decoded as gpui decodes files.
|
||||
enum Unpacker {}
|
||||
|
||||
impl Asset for Unpacker {
|
||||
type Source = (ImageFormat, Arc<[u8]>);
|
||||
type Output = Result<Arc<RenderImage>, SharedString>;
|
||||
|
||||
fn load(
|
||||
(format, bytes): Self::Source,
|
||||
cx: &mut App,
|
||||
) -> impl Future<Output = Self::Output> + Send + 'static {
|
||||
let renderer = cx.svg_renderer();
|
||||
async move {
|
||||
Image::from_bytes(format, bytes.to_vec())
|
||||
.to_image_data(renderer)
|
||||
.map_err(|error| format!("{error:#}").into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The picture at `url`, natural size up to the column.
|
||||
#[derive(IntoElement)]
|
||||
pub(crate) struct Picture {
|
||||
pub alt: String,
|
||||
pub url: String,
|
||||
/// The folder relative addresses start from.
|
||||
pub base: Option<Arc<Path>>,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
fn picture(
|
||||
alt: &str,
|
||||
url: &str,
|
||||
base: Option<&Path>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> AnyElement {
|
||||
let colors = cx.theme().colors.clone();
|
||||
let (loaded, svg) = match origin(url, base) {
|
||||
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)
|
||||
.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)
|
||||
}
|
||||
Origin::Web => {
|
||||
let said = i18n::text(cx, "markdown.picture.web", &[]);
|
||||
return note(alt, said, colors.fg_subtle, cx);
|
||||
}
|
||||
Origin::Loose => {
|
||||
let said = i18n::text(cx, "markdown.picture.folder", &[]);
|
||||
return note(alt, said, colors.fg_subtle, cx);
|
||||
}
|
||||
Origin::Bad(why) => return note(alt, why, colors.danger, cx),
|
||||
};
|
||||
match loaded {
|
||||
Some(Ok(image)) => {
|
||||
let natural = image.size(0);
|
||||
// The picture's own height follows its ratio.
|
||||
let smooth = if svg { SMOOTH_SVG_SCALE_FACTOR } else { 1.0 };
|
||||
let width = i32::from(natural.width) as f32 / smooth;
|
||||
div()
|
||||
.debug_selector(|| "markdown-picture".into())
|
||||
.w_full()
|
||||
.max_w(px(width))
|
||||
.child(img(image).size_full())
|
||||
.into_any_element()
|
||||
}
|
||||
Some(Err(why)) => {
|
||||
log::warn!("markdown: a picture did not load: {why}");
|
||||
note(alt, why, colors.danger, cx)
|
||||
}
|
||||
None => note(alt, url.to_string().into(), colors.fg_subtle, cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// A picture not drawn: its alt text and why.
|
||||
fn note(alt: &str, said: SharedString, tone: Hsla, cx: &App) -> AnyElement {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.p_3()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(
|
||||
Icon::new(IconName::Image)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.fg_subtle),
|
||||
)
|
||||
.child(div().flex_1().child(alt.to_string()))
|
||||
.child(div().text_color(tone).child(said))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn addresses_resolve_against_the_folder() {
|
||||
let base = Path::new("/notes");
|
||||
assert_eq!(
|
||||
origin("assets/a%20b.png?x#y", Some(base)),
|
||||
Origin::File(base.join("assets/a b.png"))
|
||||
);
|
||||
assert_eq!(origin("assets/a.png", None), Origin::Loose);
|
||||
assert_eq!(origin("https://e.x/a.png", Some(base)), Origin::Web);
|
||||
assert_eq!(origin("//e.x/a.png", Some(base)), Origin::Web);
|
||||
assert!(matches!(origin("mailto:a@b", Some(base)), Origin::Bad(_)));
|
||||
let absolute = std::env::current_dir().expect("a dir").join("a.png");
|
||||
assert_eq!(
|
||||
origin(&absolute.display().to_string(), Some(base)),
|
||||
Origin::File(absolute)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_addresses_decode() {
|
||||
assert_eq!(
|
||||
origin("file:///tmp/a%23b.png", None),
|
||||
Origin::File(PathBuf::from("/tmp/a#b.png"))
|
||||
);
|
||||
if cfg!(windows) {
|
||||
assert_eq!(
|
||||
origin("file:///C:/notes/a.png", None),
|
||||
Origin::File(PathBuf::from("C:/notes/a.png"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_addresses_carry_their_bytes() {
|
||||
assert_eq!(
|
||||
origin("data:image/png;base64,cG5n", None),
|
||||
Origin::Data(ImageFormat::Png, Arc::from(&b"png"[..]))
|
||||
);
|
||||
assert!(matches!(origin("data:image/png,raw", None), Origin::Bad(_)));
|
||||
assert!(matches!(
|
||||
origin("data:text/plain;base64,eA==", None),
|
||||
Origin::Bad(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
origin("data:image/png;base64,***", None),
|
||||
Origin::Bad(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
+25
-20
@@ -1,4 +1,4 @@
|
||||
use std::rc::Rc;
|
||||
use std::{path::Path, rc::Rc, sync::Arc};
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
||||
@@ -7,7 +7,11 @@ use gpui::{
|
||||
};
|
||||
use markdown::mdast::AlignKind;
|
||||
|
||||
use super::tree::{Inline, Node, Style, parse};
|
||||
use super::{
|
||||
diagram::Diagram,
|
||||
picture::Picture,
|
||||
tree::{Inline, Node, Style, parse},
|
||||
};
|
||||
use crate::{
|
||||
editor::code_colors,
|
||||
navigation::{Anchor, Sections},
|
||||
@@ -28,6 +32,7 @@ pub struct MarkdownRenderer {
|
||||
size: TextSize,
|
||||
sections: Option<Entity<Sections>>,
|
||||
code: Option<DrawCode>,
|
||||
base: Option<Arc<Path>>,
|
||||
}
|
||||
|
||||
impl MarkdownRenderer {
|
||||
@@ -39,9 +44,16 @@ impl MarkdownRenderer {
|
||||
size: TextSize::Base,
|
||||
sections: None,
|
||||
code: None,
|
||||
base: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder relative picture addresses start from.
|
||||
pub fn base(mut self, folder: impl Into<Arc<Path>>) -> Self {
|
||||
self.base = Some(folder.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// The body's size; headings keep theirs.
|
||||
pub fn text_size(mut self, size: TextSize) -> Self {
|
||||
self.size = size;
|
||||
@@ -78,6 +90,7 @@ struct Draw<'a> {
|
||||
sections: &'a Option<Entity<Sections>>,
|
||||
headings: std::cell::Cell<usize>,
|
||||
code: &'a Option<DrawCode>,
|
||||
base: &'a Option<Arc<Path>>,
|
||||
}
|
||||
|
||||
impl Draw<'_> {
|
||||
@@ -200,6 +213,9 @@ impl Draw<'_> {
|
||||
.children(self.nodes(inner, cx))
|
||||
.into_any_element(),
|
||||
Node::Code { language, text } => {
|
||||
if language.as_deref() == Some("mermaid") {
|
||||
return Diagram::new(text.clone()).into_any_element();
|
||||
}
|
||||
if let Some(draw) = self.code {
|
||||
return draw(
|
||||
self.key("code"),
|
||||
@@ -336,7 +352,7 @@ impl Draw<'_> {
|
||||
.justify_center()
|
||||
.py_2()
|
||||
.child(match Latex::parse(tex) {
|
||||
Ok(math) => math.into_any_element(),
|
||||
Ok(math) => math.display().into_any_element(),
|
||||
Err(error) => div()
|
||||
.text_color(colors.danger)
|
||||
.child(error.to_string())
|
||||
@@ -344,23 +360,11 @@ impl Draw<'_> {
|
||||
})
|
||||
.into_any_element(),
|
||||
Node::Image { alt, url, link } => {
|
||||
let picture = div()
|
||||
.id(self.key("image"))
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.p_3()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.text_color(colors.fg_muted)
|
||||
.child(
|
||||
Icon::new(IconName::Image)
|
||||
.size(IconSize::Sm)
|
||||
.color(colors.fg_subtle),
|
||||
)
|
||||
.child(div().flex_1().child(alt.clone()))
|
||||
.child(div().text_color(colors.fg_subtle).child(url.clone()));
|
||||
let picture = div().id(self.key("image")).child(Picture {
|
||||
alt: alt.clone(),
|
||||
url: url.clone(),
|
||||
base: self.base.clone(),
|
||||
});
|
||||
let Some(link) = link.clone() else {
|
||||
return picture.into_any_element();
|
||||
};
|
||||
@@ -443,6 +447,7 @@ impl RenderOnce for MarkdownRenderer {
|
||||
sections: &self.sections,
|
||||
headings: std::cell::Cell::new(0),
|
||||
code: &self.code,
|
||||
base: &self.base,
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
|
||||
@@ -301,3 +301,60 @@ fn a_selected_version_past_the_history_selects_none(cx: &mut TestAppContext) {
|
||||
});
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
/// Markdown drawn `width` points wide, its pictures read in `base`.
|
||||
struct Page {
|
||||
source: &'static str,
|
||||
base: std::path::PathBuf,
|
||||
width: f32,
|
||||
}
|
||||
|
||||
impl Render for Page {
|
||||
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.w(px(self.width))
|
||||
.child(super::MarkdownRenderer::new("page", self.source).base(self.base.as_path()))
|
||||
}
|
||||
}
|
||||
|
||||
/// A 200×100 picture drawn in a `width` column.
|
||||
fn drawn_picture(width: f32, cx: &mut TestAppContext) -> gpui::Size<Pixels> {
|
||||
let folder = std::env::temp_dir().join(format!("ely-picture-{}-{width}", std::process::id()));
|
||||
std::fs::create_dir_all(folder.join("assets")).expect("a folder");
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::new(200, 100)
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.expect("a png");
|
||||
std::fs::write(folder.join("assets/wide shot.png"), png).expect("the picture");
|
||||
cx.update(Theme::init);
|
||||
let (_, cx) = cx.add_window_view(|_, _| Page {
|
||||
source: "Before
|
||||
|
||||

|
||||
",
|
||||
base: folder.clone(),
|
||||
width,
|
||||
});
|
||||
let mut drawn = None;
|
||||
// The decode runs on the background executor.
|
||||
for _ in 0..50 {
|
||||
settle(cx);
|
||||
drawn = cx.debug_bounds("markdown-picture");
|
||||
if drawn.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
std::fs::remove_dir_all(&folder).expect("cleaned");
|
||||
drawn.expect("the picture drew").size
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_picture_beside_the_file_draws_at_its_ratio_within_the_column(cx: &mut TestAppContext) {
|
||||
let shrunk = drawn_picture(120.0, cx);
|
||||
assert_eq!(shrunk, size(px(120.0), px(60.0)), "shrunk at 2:1");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
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)));
|
||||
}
|
||||
@@ -40,6 +40,8 @@ const EN: &[(&str, &str)] = &[
|
||||
("templates.list", "Templates"),
|
||||
("templates.use", "Use template"),
|
||||
("templates.none", "No template matches"),
|
||||
("markdown.picture.web", "Not loaded from the web"),
|
||||
("markdown.picture.folder", "No folder to find it in"),
|
||||
("comments.reply", "Reply"),
|
||||
("update.title", "Software Update"),
|
||||
("update.available", "Version {version} is available."),
|
||||
@@ -151,6 +153,8 @@ const ZH_CN: &[(&str, &str)] = &[
|
||||
("templates.list", "模板"),
|
||||
("templates.use", "使用模板"),
|
||||
("templates.none", "没有匹配的模板"),
|
||||
("markdown.picture.web", "未从网络载入"),
|
||||
("markdown.picture.folder", "没有可找的文件夹"),
|
||||
("comments.reply", "回复"),
|
||||
("update.title", "软件更新"),
|
||||
("update.available", "{version} 版可以更新。"),
|
||||
|
||||
+143
-302
@@ -1,282 +1,46 @@
|
||||
use std::fmt;
|
||||
use std::{
|
||||
fmt,
|
||||
panic::{AssertUnwindSafe, catch_unwind},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, IntoElement, ParentElement, Rems, RenderOnce, Styled, Window, div, prelude::*,
|
||||
App, Asset, 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};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Node {
|
||||
Var(char),
|
||||
Sym(String),
|
||||
Op(String),
|
||||
Word(String),
|
||||
Row(Vec<Node>),
|
||||
Script {
|
||||
base: Box<Node>,
|
||||
sup: Option<Box<Node>>,
|
||||
sub: Option<Box<Node>>,
|
||||
},
|
||||
Frac(Box<Node>, Box<Node>),
|
||||
Sqrt(Box<Node>),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum MathError {
|
||||
Unknown(String),
|
||||
Unbalanced,
|
||||
MissingArgument(&'static str),
|
||||
}
|
||||
/// Why a formula did not typeset: RaTeX's own words.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MathError(SharedString);
|
||||
|
||||
impl fmt::Display for MathError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
MathError::Unknown(name) => write!(f, "unknown command \\{name}"),
|
||||
MathError::Unbalanced => write!(f, "unbalanced braces"),
|
||||
MathError::MissingArgument(name) => write!(f, "\\{name} is missing an argument"),
|
||||
}
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MathError {}
|
||||
|
||||
fn symbol(name: &str) -> Option<Node> {
|
||||
let greek = "alpha α beta β gamma γ delta δ epsilon ε zeta ζ eta η theta θ iota ι kappa κ \
|
||||
lambda λ mu μ nu ν xi ξ pi π rho ρ sigma σ tau τ upsilon υ phi φ chi χ psi ψ omega ω";
|
||||
let upper = "Gamma Γ Delta Δ Theta Θ Lambda Λ Xi Ξ Pi Π Sigma Σ Phi Φ Psi Ψ Omega Ω";
|
||||
let ops = "pm ± mp ∓ times × cdot · div ÷ leq ≤ geq ≥ neq ≠ approx ≈ equiv ≡ to → \
|
||||
rightarrow → leftarrow ← in ∈ subset ⊂ cup ∪ cap ∩";
|
||||
let plain = "infty ∞ partial ∂ nabla ∇ sum ∑ prod ∏ int ∫ forall ∀ exists ∃ ldots … cdots ⋯";
|
||||
let find = |table: &str| {
|
||||
let words: Vec<&str> = table.split_whitespace().collect();
|
||||
words
|
||||
.chunks(2)
|
||||
.find(|pair| pair[0] == name)
|
||||
.map(|pair| pair[1].to_string())
|
||||
};
|
||||
if let Some(glyph) = find(greek) {
|
||||
return glyph.chars().next().map(Node::Var);
|
||||
}
|
||||
if let Some(glyph) = find(ops) {
|
||||
return Some(Node::Op(glyph));
|
||||
}
|
||||
find(upper).or_else(|| find(plain)).map(Node::Sym)
|
||||
}
|
||||
|
||||
const FUNCTIONS: [&str; 11] = [
|
||||
"sin", "cos", "tan", "log", "ln", "exp", "lim", "max", "min", "det", "gcd",
|
||||
];
|
||||
|
||||
struct Parser {
|
||||
chars: Vec<char>,
|
||||
at: usize,
|
||||
}
|
||||
|
||||
impl Parser {
|
||||
fn peek(&self) -> Option<char> {
|
||||
self.chars.get(self.at).copied()
|
||||
}
|
||||
|
||||
fn bump(&mut self) -> Option<char> {
|
||||
let next = self.peek();
|
||||
self.at += 1;
|
||||
next
|
||||
}
|
||||
|
||||
fn row(&mut self, closed: bool) -> Result<Vec<Node>, MathError> {
|
||||
let mut row = Vec::new();
|
||||
loop {
|
||||
match self.peek() {
|
||||
None if closed => return Err(MathError::Unbalanced),
|
||||
None => return Ok(row),
|
||||
Some('}') if closed => {
|
||||
self.bump();
|
||||
return Ok(row);
|
||||
}
|
||||
Some('}') => return Err(MathError::Unbalanced),
|
||||
Some(ch) if ch.is_whitespace() => {
|
||||
self.bump();
|
||||
}
|
||||
Some(mark @ ('^' | '_')) => {
|
||||
self.bump();
|
||||
let script = Box::new(self.argument(if mark == '^' { "^" } else { "_" })?);
|
||||
let base = row.pop().unwrap_or(Node::Sym(String::new()));
|
||||
row.push(attach(base, mark == '^', script));
|
||||
}
|
||||
Some(_) => {
|
||||
let atom = self.atom()?;
|
||||
row.push(atom);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn atom(&mut self) -> Result<Node, MathError> {
|
||||
match self.bump() {
|
||||
Some('{') => Ok(Node::Row(self.row(true)?)),
|
||||
Some('\\') => self.command(),
|
||||
Some('-') => Ok(Node::Op("−".into())),
|
||||
Some(ch @ ('+' | '=' | '<' | '>')) => Ok(Node::Op(ch.to_string())),
|
||||
Some(ch) if ch.is_alphabetic() => Ok(Node::Var(ch)),
|
||||
Some(ch) => Ok(Node::Sym(ch.to_string())),
|
||||
None => Err(MathError::Unbalanced),
|
||||
}
|
||||
}
|
||||
|
||||
fn argument(&mut self, name: &'static str) -> Result<Node, MathError> {
|
||||
while self.peek().is_some_and(char::is_whitespace) {
|
||||
self.bump();
|
||||
}
|
||||
match self.peek() {
|
||||
None | Some('}') => Err(MathError::MissingArgument(name)),
|
||||
Some(_) => self.atom(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `{...}` read as plain text, spaces kept.
|
||||
fn raw_group(&mut self) -> Result<String, MathError> {
|
||||
while self.peek().is_some_and(char::is_whitespace) {
|
||||
self.bump();
|
||||
}
|
||||
if self.bump() != Some('{') {
|
||||
return Err(MathError::MissingArgument("text"));
|
||||
}
|
||||
let mut depth = 1;
|
||||
let mut text = String::new();
|
||||
loop {
|
||||
match self.bump().ok_or(MathError::Unbalanced)? {
|
||||
'{' => depth += 1,
|
||||
'}' if depth == 1 => return Ok(text),
|
||||
'}' => depth -= 1,
|
||||
_ => {}
|
||||
}
|
||||
text.push(self.chars[self.at - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
fn command(&mut self) -> Result<Node, MathError> {
|
||||
let start = self.at;
|
||||
while self.peek().is_some_and(|ch| ch.is_ascii_alphabetic()) {
|
||||
self.bump();
|
||||
}
|
||||
let name: String = self.chars[start..self.at].iter().collect();
|
||||
if name.is_empty() {
|
||||
let escaped = self.bump().ok_or(MathError::Unbalanced)?;
|
||||
return Ok(Node::Sym(if escaped == ',' {
|
||||
" ".into()
|
||||
} else {
|
||||
escaped.to_string()
|
||||
}));
|
||||
}
|
||||
match name.as_str() {
|
||||
"frac" => {
|
||||
let top = self.argument("frac")?;
|
||||
let bottom = self.argument("frac")?;
|
||||
Ok(Node::Frac(Box::new(top), Box::new(bottom)))
|
||||
}
|
||||
"sqrt" => Ok(Node::Sqrt(Box::new(self.argument("sqrt")?))),
|
||||
"text" => Ok(Node::Word(self.raw_group()?)),
|
||||
"left" | "right" => self.argument("left"),
|
||||
name if FUNCTIONS.contains(&name) => Ok(Node::Word(name.to_string())),
|
||||
name => symbol(name).ok_or_else(|| MathError::Unknown(name.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn attach(base: Node, upper: bool, script: Box<Node>) -> Node {
|
||||
match base {
|
||||
Node::Script { base, sup, sub } if upper && sup.is_none() => Node::Script {
|
||||
base,
|
||||
sup: Some(script),
|
||||
sub,
|
||||
},
|
||||
Node::Script { base, sup, sub } if !upper && sub.is_none() => Node::Script {
|
||||
base,
|
||||
sup,
|
||||
sub: Some(script),
|
||||
},
|
||||
base => {
|
||||
let (sup, sub) = if upper {
|
||||
(Some(script), None)
|
||||
} else {
|
||||
(None, Some(script))
|
||||
};
|
||||
Node::Script {
|
||||
base: Box::new(base),
|
||||
sup,
|
||||
sub,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(node: &Node, size: Rems, cx: &App) -> AnyElement {
|
||||
let rule = cx.theme().colors.fg;
|
||||
let script = size * 0.72;
|
||||
let text = |content: String| div().text_size(size).child(content);
|
||||
match node {
|
||||
Node::Var(ch) => text(ch.to_string()).italic().into_any_element(),
|
||||
Node::Sym(content) | Node::Word(content) => text(content.clone()).into_any_element(),
|
||||
Node::Op(content) => text(content.clone()).px(size * 0.22).into_any_element(),
|
||||
Node::Row(nodes) => div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.children(nodes.iter().map(|node| draw(node, size, cx)))
|
||||
.into_any_element(),
|
||||
Node::Script { base, sup, sub } => div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.child(draw(base, size, cx))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.when(sub.is_none(), |col| col.mb(size * 0.55))
|
||||
.when(sup.is_none(), |col| col.mt(size * 0.45))
|
||||
.children(sup.iter().map(|node| draw(node, script, cx)))
|
||||
.children(sub.iter().map(|node| draw(node, script, cx))),
|
||||
)
|
||||
.into_any_element(),
|
||||
Node::Frac(top, bottom) => div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.px(size * 0.12)
|
||||
.child(draw(top, size * 0.85, cx))
|
||||
.child(div().w_full().border_t_1().border_color(rule))
|
||||
.child(draw(bottom, size * 0.85, cx))
|
||||
.into_any_element(),
|
||||
Node::Sqrt(body) => div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.child(text("√".into()))
|
||||
.child(
|
||||
div()
|
||||
.border_t_1()
|
||||
.border_color(rule)
|
||||
.child(draw(body, size, cx)),
|
||||
)
|
||||
.into_any_element(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline math from a TeX subset: scripts, `\frac`, `\sqrt`, Greek, operators.
|
||||
/// TeX typeset by RaTeX, in the text's color.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Latex {
|
||||
root: Node,
|
||||
source: SharedString,
|
||||
size: TextSize,
|
||||
display: bool,
|
||||
}
|
||||
|
||||
impl Latex {
|
||||
pub fn parse(source: &str) -> Result<Self, MathError> {
|
||||
let mut parser = Parser {
|
||||
chars: source.chars().collect(),
|
||||
at: 0,
|
||||
};
|
||||
ratex_parser::parse(source).map_err(|error| MathError(error.message.into()))?;
|
||||
Ok(Self {
|
||||
root: Node::Row(parser.row(false)?),
|
||||
source: source.to_string().into(),
|
||||
size: TextSize::Lg,
|
||||
display: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -284,15 +48,107 @@ impl Latex {
|
||||
self.size = size;
|
||||
self
|
||||
}
|
||||
|
||||
/// Display style: limits above and below, larger operators.
|
||||
pub fn display(mut self) -> Self {
|
||||
self.display = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// What a typeset picture depends on.
|
||||
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
||||
struct Formula {
|
||||
source: SharedString,
|
||||
display: bool,
|
||||
rgba: u32,
|
||||
/// Font size and window scale, in hundredths.
|
||||
font: u32,
|
||||
scale: u32,
|
||||
}
|
||||
|
||||
/// A typeset formula: its picture and logical size.
|
||||
type Typeset = Result<(Arc<RenderImage>, Size<Pixels>), MathError>;
|
||||
|
||||
enum Typesetter {}
|
||||
|
||||
impl Asset for Typesetter {
|
||||
type Source = Formula;
|
||||
type Output = Typeset;
|
||||
|
||||
fn load(formula: Formula, cx: &mut App) -> impl Future<Output = Typeset> + Send + 'static {
|
||||
let renderer = cx.svg_renderer();
|
||||
async move {
|
||||
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()))?;
|
||||
Ok((picture, shape))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The formula as SVG, glyphs outlined, and 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 [r, g, b, a] = formula
|
||||
.rgba
|
||||
.to_be_bytes()
|
||||
.map(|channel| f32::from(channel) / 255.0);
|
||||
let options = LayoutOptions::default()
|
||||
.with_style(match formula.display {
|
||||
true => MathStyle::Display,
|
||||
false => MathStyle::Text,
|
||||
})
|
||||
.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 list = to_display_list(&layout(&ast, &options));
|
||||
let svg = render_to_svg(
|
||||
&list,
|
||||
&SvgOptions {
|
||||
font_size: font,
|
||||
padding: 0.0,
|
||||
stroke_width: (font / 26.0).max(0.5),
|
||||
embed_glyphs: true,
|
||||
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())
|
||||
})?;
|
||||
let shape = size(px((width * font) as f32), px((height * font) as f32));
|
||||
Ok((svg, shape))
|
||||
}
|
||||
|
||||
impl RenderOnce for Latex {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let size = theme.text_size(self.size);
|
||||
div()
|
||||
.text_color(theme.colors.fg)
|
||||
.child(draw(&self.root, size, cx))
|
||||
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()),
|
||||
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) {
|
||||
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(),
|
||||
None => div().h(font).into_any_element(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,55 +156,40 @@ impl RenderOnce for Latex {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse(source: &str) -> Result<Node, MathError> {
|
||||
Latex::parse(source).map(|latex| latex.root)
|
||||
fn formula(source: &str, display: bool) -> Formula {
|
||||
Formula {
|
||||
source: source.to_string().into(),
|
||||
display,
|
||||
rgba: 0x112233ff,
|
||||
font: 1600,
|
||||
scale: 200,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripts_attach_to_the_previous_atom() {
|
||||
let Node::Row(nodes) = parse("x_i^2 + y").unwrap() else {
|
||||
panic!("row")
|
||||
};
|
||||
assert_eq!(nodes.len(), 3);
|
||||
let Node::Script { base, sup, sub } = &nodes[0] else {
|
||||
panic!("script")
|
||||
};
|
||||
assert_eq!(**base, Node::Var('x'));
|
||||
assert_eq!(sup.as_deref(), Some(&Node::Sym("2".into())));
|
||||
assert_eq!(sub.as_deref(), Some(&Node::Var('i')));
|
||||
assert_eq!(nodes[1], Node::Op("+".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_build_structure() {
|
||||
let Node::Row(nodes) = parse(r"\frac{\alpha}{2} \sqrt{x} \sin").unwrap() else {
|
||||
panic!("row")
|
||||
};
|
||||
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!(
|
||||
matches!(&nodes[0], Node::Frac(top, _) if **top == Node::Row(vec![Node::Var('α')]))
|
||||
svg.contains("17,34,51") || svg.contains("#112233"),
|
||||
"the color: {svg}"
|
||||
);
|
||||
assert!(matches!(&nodes[1], Node::Sqrt(_)));
|
||||
assert_eq!(nodes[2], Node::Word("sin".into()));
|
||||
assert!(shape.width > px(16.) && shape.height > px(16.), "{shape:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_keeps_its_spaces() {
|
||||
let Node::Row(nodes) = parse(r"\text{hello world} x").unwrap() else {
|
||||
panic!("row")
|
||||
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_eq!(nodes[0], Node::Word("hello world".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_name_the_fault() {
|
||||
assert_eq!(parse(r"\foo"), Err(MathError::Unknown("foo".into())));
|
||||
assert_eq!(parse("{x"), Err(MathError::Unbalanced));
|
||||
assert_eq!(parse("x}"), Err(MathError::Unbalanced));
|
||||
assert_eq!(parse(r"\frac{a}"), Err(MathError::MissingArgument("frac")));
|
||||
assert_eq!(
|
||||
parse(r"\mathrm{x}"),
|
||||
Err(MathError::Unknown("mathrm".into()))
|
||||
);
|
||||
assert_eq!(parse(r"\text{a"), Err(MathError::Unbalanced));
|
||||
assert!(!error.to_string().is_empty());
|
||||
assert!(Latex::parse(r"x^{2").is_err(), "an open brace");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user