Documents: \( pairs stay in one paragraph and off addresses; formulas place at wraps
- Pairs pair inside one paragraph, heading or cell; links' addresses, images, code and <code>/<kbd> text stay literal. - Adjacent formulas stay two; a parting space is dropped after parsing. - Stray and escaped dollars, in or out of a formula, stay dollars. - A formula's node spans its delimiters; its value is the written TeX. - A formula wrapped whole draws at its own line's start. - A failed inline formula shows its reason after the TeX. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
7 files changed
+524
-131
No files matched your search
@@ -1,4 +1,6 @@
|
|||||||
use std::{borrow::Cow, ops::Range};
|
use std::ops::Range;
|
||||||
|
|
||||||
|
mod tex;
|
||||||
|
|
||||||
use gpui::App;
|
use gpui::App;
|
||||||
use markdown::{Constructs, ParseOptions, mdast};
|
use markdown::{Constructs, ParseOptions, mdast};
|
||||||
@@ -24,68 +26,11 @@ pub(crate) fn tree(text: &str) -> mdast::Node {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn parsed(text: &str, options: &ParseOptions) -> mdast::Node {
|
fn parsed(text: &str, options: &ParseOptions) -> mdast::Node {
|
||||||
let text = tex_math(text, options);
|
let swapped = tex::swapped(text, options);
|
||||||
markdown::to_mdast(&text, options).expect("markdown without MDX always parses")
|
let mut root =
|
||||||
}
|
markdown::to_mdast(&swapped.text, options).expect("markdown without MDX always parses");
|
||||||
|
tex::mend(&mut root, &swapped);
|
||||||
/// `\(` and `\[` delimiters as `$`, byte for byte.
|
root
|
||||||
fn tex_math<'a>(text: &'a str, options: &ParseOptions) -> Cow<'a, str> {
|
|
||||||
if !text.contains("\\(") && !text.contains("\\[") {
|
|
||||||
return Cow::Borrowed(text);
|
|
||||||
}
|
|
||||||
let root = markdown::to_mdast(text, options).expect("markdown without MDX always parses");
|
|
||||||
let mut nodes = Vec::new();
|
|
||||||
walk(&root, &mut nodes);
|
|
||||||
let mut kept = vec![false; text.len()];
|
|
||||||
for node in nodes {
|
|
||||||
use mdast::Node::{Code, Html, InlineCode, InlineMath, Math, Toml, Yaml};
|
|
||||||
if matches!(
|
|
||||||
node,
|
|
||||||
Code(_) | InlineCode(_) | Math(_) | InlineMath(_) | Html(_) | Yaml(_) | Toml(_)
|
|
||||||
) {
|
|
||||||
kept[span(node)].fill(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let bytes = text.as_bytes();
|
|
||||||
let mut out = bytes.to_vec();
|
|
||||||
let mut open: Option<(usize, u8)> = None;
|
|
||||||
let mut at = 0;
|
|
||||||
while at + 1 < bytes.len() {
|
|
||||||
match bytes[at] {
|
|
||||||
// A blank line ends a paragraph and its formula.
|
|
||||||
b'\n' if blank_after(&bytes[at + 1..]) => open = None,
|
|
||||||
b'\\' if !kept[at] => {
|
|
||||||
match (bytes[at + 1], open) {
|
|
||||||
(b'(', None) => open = Some((at, b')')),
|
|
||||||
(b'[', None) => open = Some((at, b']')),
|
|
||||||
(close, Some((start, wanted)))
|
|
||||||
if close == wanted && at > start + 2 && !text[start..at].contains('$') =>
|
|
||||||
{
|
|
||||||
out[start..start + 2].copy_from_slice(b"$ ");
|
|
||||||
out[at..at + 2].copy_from_slice(b" $");
|
|
||||||
open = None;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
// An escaped backslash escapes nothing after it.
|
|
||||||
at += 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
at += 1;
|
|
||||||
}
|
|
||||||
match out == bytes {
|
|
||||||
true => Cow::Borrowed(text),
|
|
||||||
false => Cow::Owned(String::from_utf8(out).expect("ASCII swaps keep UTF-8")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the line `rest` starts holds only white space.
|
|
||||||
fn blank_after(rest: &[u8]) -> bool {
|
|
||||||
rest.iter()
|
|
||||||
.take_while(|byte| **byte != b'\n')
|
|
||||||
.all(u8::is_ascii_whitespace)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An inline formula's TeX: inside `$` runs or `\(` pairs.
|
/// An inline formula's TeX: inside `$` runs or `\(` pairs.
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
//! `\(…\)` and `\[…\]` read as math between dollars.
|
||||||
|
|
||||||
|
use std::{borrow::Cow, ops::Range};
|
||||||
|
|
||||||
|
use markdown::{ParseOptions, mdast, unist::Position};
|
||||||
|
|
||||||
|
use super::{span, walk};
|
||||||
|
use crate::documents::tree::{Style, html};
|
||||||
|
|
||||||
|
/// Bytes a literal dollar may stand in as while parsing.
|
||||||
|
const STAND_INS: std::ops::RangeInclusive<u8> = 0x01..=0x08;
|
||||||
|
|
||||||
|
/// One pair's delimiters as written.
|
||||||
|
pub(super) struct Pair {
|
||||||
|
open: Range<usize>,
|
||||||
|
close: Range<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The swapped text and what to put back after parsing.
|
||||||
|
pub(super) struct Swapped<'a> {
|
||||||
|
pub text: Cow<'a, str>,
|
||||||
|
pairs: Vec<Pair>,
|
||||||
|
/// Spaces that only part two dollars.
|
||||||
|
gaps: Vec<usize>,
|
||||||
|
/// The byte every other dollar was written as.
|
||||||
|
stand_in: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `text` with each pair's delimiters as dollars, byte for byte.
|
||||||
|
pub(super) fn swapped<'a>(text: &'a str, options: &ParseOptions) -> Swapped<'a> {
|
||||||
|
let stand_in = STAND_INS
|
||||||
|
.clone()
|
||||||
|
.find(|byte| !text.as_bytes().contains(byte));
|
||||||
|
let none = || Swapped {
|
||||||
|
text: Cow::Borrowed(text),
|
||||||
|
pairs: Vec::new(),
|
||||||
|
gaps: Vec::new(),
|
||||||
|
stand_in: 0,
|
||||||
|
};
|
||||||
|
if !text.contains("\\(") && !text.contains("\\[") {
|
||||||
|
return none();
|
||||||
|
}
|
||||||
|
let Some(stand_in) = stand_in else {
|
||||||
|
log::warn!("markdown: \\( reads as text; every stand-in byte is used");
|
||||||
|
return none();
|
||||||
|
};
|
||||||
|
let root = markdown::to_mdast(text, options).expect("markdown without MDX always parses");
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
walk(&root, &mut nodes);
|
||||||
|
let mut kept = vec![false; text.len()];
|
||||||
|
let (mut pairs, mut dollars) = (Vec::new(), Vec::new());
|
||||||
|
for node in nodes {
|
||||||
|
use mdast::Node::{Heading, Paragraph, TableCell};
|
||||||
|
if matches!(node, Paragraph(_) | Heading(_) | TableCell(_)) {
|
||||||
|
keep(node, text, &mut kept);
|
||||||
|
paired(text, span(node), &kept, &mut pairs, &mut dollars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pairs.is_empty() {
|
||||||
|
return none();
|
||||||
|
}
|
||||||
|
let (out, gaps) = written(text, &pairs, &dollars, stand_in);
|
||||||
|
Swapped {
|
||||||
|
text: Cow::Owned(String::from_utf8(out).expect("ASCII swaps keep UTF-8")),
|
||||||
|
pairs,
|
||||||
|
gaps,
|
||||||
|
stand_in,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks what in `container` is not prose: code, tags, addresses.
|
||||||
|
fn keep(container: &mdast::Node, text: &str, kept: &mut [bool]) {
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
walk(container, &mut nodes);
|
||||||
|
let mut code: Option<(String, usize)> = None;
|
||||||
|
for node in &nodes[1..] {
|
||||||
|
use mdast::Node::*;
|
||||||
|
let whole = span(node);
|
||||||
|
match node {
|
||||||
|
InlineCode(_) | InlineMath(_) | Image(_) | ImageReference(_) | FootnoteReference(_) => {
|
||||||
|
kept[whole].fill(true)
|
||||||
|
}
|
||||||
|
Link(_) | LinkReference(_) => {
|
||||||
|
kept[whole.clone()].fill(true);
|
||||||
|
// Bracketed text is prose; its address is not.
|
||||||
|
let last = node.children().and_then(|children| children.last());
|
||||||
|
if text[whole.clone()].starts_with('[')
|
||||||
|
&& let Some(last) = last
|
||||||
|
{
|
||||||
|
// A text's span skips a leading escape: start at `[`.
|
||||||
|
kept[whole.start + 1..span(last).end].fill(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Html(_) => {
|
||||||
|
kept[whole.clone()].fill(true);
|
||||||
|
let Some(tag) = html::tag(&text[whole.clone()]) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match &code {
|
||||||
|
Some((name, from)) if tag.closing && tag.name == *name => {
|
||||||
|
kept[*from..whole.start].fill(true);
|
||||||
|
code = None;
|
||||||
|
}
|
||||||
|
None if !tag.closing && html::inline_style(&tag.name) == Some(Style::Code) => {
|
||||||
|
code = Some((tag.name, whole.end));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An unclosed `<code>` runs to its block's end.
|
||||||
|
if let Some((_, from)) = code {
|
||||||
|
kept[from..span(container).end].fill(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pairs and bare dollars in `within`; pairs cross nothing kept.
|
||||||
|
fn paired(
|
||||||
|
text: &str,
|
||||||
|
within: Range<usize>,
|
||||||
|
kept: &[bool],
|
||||||
|
pairs: &mut Vec<Pair>,
|
||||||
|
dollars: &mut Vec<usize>,
|
||||||
|
) {
|
||||||
|
let bytes = text.as_bytes();
|
||||||
|
let mut open: Option<(usize, u8)> = None;
|
||||||
|
let mut at = within.start;
|
||||||
|
while at < within.end {
|
||||||
|
if kept[at] {
|
||||||
|
open = None;
|
||||||
|
} else if bytes[at] == b'$' {
|
||||||
|
dollars.push(at);
|
||||||
|
} else if bytes[at] == b'\\' && at + 1 < within.end {
|
||||||
|
match (bytes[at + 1], open) {
|
||||||
|
(b'(', None) => open = Some((at, b')')),
|
||||||
|
(b'[', None) => open = Some((at, b']')),
|
||||||
|
(close, Some((start, wanted))) if close == wanted && !kept[at + 1] => {
|
||||||
|
if !bytes[start + 2..at].iter().all(u8::is_ascii_whitespace) {
|
||||||
|
pairs.push(Pair {
|
||||||
|
open: start..start + 2,
|
||||||
|
close: at..at + 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
open = None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
// An escaped byte escapes nothing after it.
|
||||||
|
at += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
at += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The swapped bytes; a space parts dollars that would touch.
|
||||||
|
fn written(text: &str, pairs: &[Pair], dollars: &[usize], stand_in: u8) -> (Vec<u8>, Vec<usize>) {
|
||||||
|
let mut out = text.as_bytes().to_vec();
|
||||||
|
for at in dollars {
|
||||||
|
out[*at] = stand_in;
|
||||||
|
}
|
||||||
|
let mut gaps = Vec::new();
|
||||||
|
for (ix, pair) in pairs.iter().enumerate() {
|
||||||
|
for byte in &mut out[pair.open.end..pair.close.start] {
|
||||||
|
if *byte == b'$' {
|
||||||
|
*byte = stand_in;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let open = match pair.open.start.checked_sub(1).map(|before| out[before]) {
|
||||||
|
Some(b'$') => {
|
||||||
|
gaps.push(pair.open.start);
|
||||||
|
*b" $"
|
||||||
|
}
|
||||||
|
_ => *b"$ ",
|
||||||
|
};
|
||||||
|
out[pair.open.clone()].copy_from_slice(&open);
|
||||||
|
let after = pair.close.end;
|
||||||
|
let opens_next = pairs
|
||||||
|
.get(ix + 1)
|
||||||
|
.is_some_and(|next| next.open.start == after);
|
||||||
|
let close = match opens_next || out.get(after) == Some(&b'$') {
|
||||||
|
true => {
|
||||||
|
gaps.push(pair.close.start + 1);
|
||||||
|
*b"$ "
|
||||||
|
}
|
||||||
|
false => *b" $",
|
||||||
|
};
|
||||||
|
out[pair.close.clone()].copy_from_slice(&close);
|
||||||
|
}
|
||||||
|
(out, gaps)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts TeX, spans and dollars back; drops parting spaces.
|
||||||
|
pub(super) fn mend(root: &mut mdast::Node, swapped: &Swapped) {
|
||||||
|
if swapped.pairs.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let mdast::Node::InlineMath(math) = root
|
||||||
|
&& let Some(position) = &mut math.position
|
||||||
|
&& let Some(pair) =
|
||||||
|
(swapped.pairs.iter()).find(|pair| pair.open.contains(&position.start.offset))
|
||||||
|
{
|
||||||
|
math.value = written_tex(&math.value, pair, swapped, position);
|
||||||
|
position.start.column -= position.start.offset - pair.open.start;
|
||||||
|
position.start.offset = pair.open.start;
|
||||||
|
position.end.column += pair.close.end - position.end.offset;
|
||||||
|
position.end.offset = pair.close.end;
|
||||||
|
}
|
||||||
|
if let Some(children) = root.children_mut() {
|
||||||
|
children.iter_mut().for_each(|child| trim(child, swapped));
|
||||||
|
children.retain(|child| !matches!(child, mdast::Node::Text(text) if text.value.is_empty()));
|
||||||
|
children.iter_mut().for_each(|child| mend(child, swapped));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The TeX as written, from the value markdown-rs read.
|
||||||
|
fn written_tex(value: &str, pair: &Pair, swapped: &Swapped, at: &Position) -> String {
|
||||||
|
let bytes = swapped.text.as_bytes();
|
||||||
|
// A pad: the space a delimiter writes inside its dollar.
|
||||||
|
let pads = (
|
||||||
|
at.start.offset == pair.open.start,
|
||||||
|
at.end.offset == pair.close.end,
|
||||||
|
);
|
||||||
|
let ends = (
|
||||||
|
pads.0 || bytes[pair.open.end] == b' ',
|
||||||
|
pads.1 || bytes[pair.close.start - 1] == b' ',
|
||||||
|
);
|
||||||
|
// With spaces at both ends, markdown-rs trimmed one each.
|
||||||
|
let mut tex = match ends {
|
||||||
|
(true, true) => format!(" {value} "),
|
||||||
|
_ => value.to_string(),
|
||||||
|
};
|
||||||
|
if pads.1 {
|
||||||
|
tex.pop();
|
||||||
|
}
|
||||||
|
if pads.0 {
|
||||||
|
tex.remove(0);
|
||||||
|
}
|
||||||
|
tex.replace(char::from(swapped.stand_in), "$")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores a text's dollars; drops a parting space at its ends.
|
||||||
|
fn trim(node: &mut mdast::Node, swapped: &Swapped) {
|
||||||
|
let mdast::Node::Text(text) = node else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
text.value = text.value.replace(char::from(swapped.stand_in), "$");
|
||||||
|
let Some(position) = &mut text.position else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if swapped.gaps.contains(&position.start.offset) && text.value.starts_with(' ') {
|
||||||
|
text.value.remove(0);
|
||||||
|
position.start.offset += 1;
|
||||||
|
position.start.column += 1;
|
||||||
|
}
|
||||||
|
if swapped.gaps.contains(&(position.end.offset - 1)) && text.value.ends_with(' ') {
|
||||||
|
text.value.pop();
|
||||||
|
position.end.offset -= 1;
|
||||||
|
position.end.column -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
use markdown::mdast::Node;
|
||||||
|
|
||||||
|
use crate::documents::markdown::{options, span, tree, walk};
|
||||||
|
|
||||||
|
/// Each formula's TeX and span, and the text left.
|
||||||
|
fn read(source: &str) -> (Vec<(String, std::ops::Range<usize>)>, String) {
|
||||||
|
let root = tree(source);
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
walk(&root, &mut nodes);
|
||||||
|
let mut maths = Vec::new();
|
||||||
|
let mut text = String::new();
|
||||||
|
for node in nodes {
|
||||||
|
match node {
|
||||||
|
Node::InlineMath(math) => maths.push((math.value.clone(), span(node))),
|
||||||
|
Node::Text(written) => text.push_str(&written.value),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(maths, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tex(source: &str) -> Vec<String> {
|
||||||
|
read(source).0.into_iter().map(|(tex, _)| tex).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn untouched(source: &str) {
|
||||||
|
let plain = markdown::to_mdast(source, &options()).unwrap();
|
||||||
|
assert_eq!(tree(source), plain, "{source:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pair_spans_its_delimiters_and_keeps_its_tex() {
|
||||||
|
assert_eq!(
|
||||||
|
read(r"a \(x\) b"),
|
||||||
|
(vec![("x".into(), 2..7)], "a b".into())
|
||||||
|
);
|
||||||
|
assert_eq!(tex(r"\( x \)"), [" x "]);
|
||||||
|
assert_eq!(tex(r"\(x \)\(y\)"), ["x ", "y"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adjacent_formulas_stay_two() {
|
||||||
|
for (source, maths) in [
|
||||||
|
(r"\(x\)\(y\)", vec!["x", "y"]),
|
||||||
|
(r"$x$\(y\)", vec!["x", "y"]),
|
||||||
|
(r"\(y\)$x$", vec!["y", "x"]),
|
||||||
|
(r"\[x\]\(y\)", vec!["x", "y"]),
|
||||||
|
] {
|
||||||
|
let (found, text) = read(source);
|
||||||
|
let found: Vec<_> = found.into_iter().map(|(tex, _)| tex).collect();
|
||||||
|
assert_eq!(found, maths, "{source:?}");
|
||||||
|
assert_eq!(text, "", "no parting space shows: {source:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dollars_stay_dollars() {
|
||||||
|
assert_eq!(tex(r"\(\$5\)"), [r"\$5"]);
|
||||||
|
assert_eq!(tex(r"\[a \$ b\]"), [r"a \$ b"]);
|
||||||
|
assert_eq!(tex(r"\(a$b\)"), ["a$b"]);
|
||||||
|
assert_eq!(
|
||||||
|
read(r"costs $5 and \(x\)"),
|
||||||
|
(vec![("x".into(), 13..18)], "costs $5 and ".into()),
|
||||||
|
"a stray dollar does not pair with a formula"
|
||||||
|
);
|
||||||
|
assert_eq!(read(r"\$\(x\)").1, "$", "an escaped dollar before");
|
||||||
|
assert_eq!(read(r"\(x\)\$").1, "$", "an escaped dollar after");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn addresses_keep_their_parentheses() {
|
||||||
|
let link = r"[go](https://e.test/\(x\))";
|
||||||
|
untouched(link);
|
||||||
|
untouched(r".png)");
|
||||||
|
untouched("[go][a\\(x\\)]\n\n[a\\(x\\)]: /u");
|
||||||
|
untouched(r"<https://e.test/\(x\)>");
|
||||||
|
assert_eq!(tex(r"[\(x\)](https://e.test)"), ["x"], "link text is prose");
|
||||||
|
untouched(r"[a\(b](c)\)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn code_and_tags_stay_literal() {
|
||||||
|
untouched(r"a <code>\(x\)</code> b");
|
||||||
|
untouched(r"a <kbd>\(x\)</kbd> b");
|
||||||
|
untouched(r"a <code>\(x\) b");
|
||||||
|
untouched(r"`\(x\)`");
|
||||||
|
untouched(r"\(a `b` c\)");
|
||||||
|
assert_eq!(tex(r"<b>\(x\)</b>"), ["x"], "other tags hold prose");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pair_stays_in_one_block() {
|
||||||
|
untouched("- \\(a\n- b\\)");
|
||||||
|
untouched("\\(a\r\rb\\)");
|
||||||
|
untouched("\\(a\r\n\r\nb\\)");
|
||||||
|
untouched("# \\(a\nb\\)");
|
||||||
|
untouched("| \\(a | b\\) |\n| - | - |");
|
||||||
|
assert_eq!(tex("> \\(a\n> b\\)"), ["a\nb"], "a quote's lines");
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use std::{path::Path, rc::Rc, sync::Arc};
|
use std::{ops::Range, path::Path, rc::Rc, sync::Arc};
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
|
||||||
@@ -171,10 +171,13 @@ fn highlight(style: &Style, colors: &Palette, rule: Pixels) -> HighlightStyle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `inline` as text; `hidden` ranges lose their highlight.
|
/// Highlights laid over a text's own.
|
||||||
|
type Over = Vec<(Range<usize>, HighlightStyle)>;
|
||||||
|
|
||||||
|
/// `inline` as text; hidden ranges lose highlight, `over` adds some.
|
||||||
fn drawn_text(
|
fn drawn_text(
|
||||||
inline: &Inline,
|
inline: &Inline,
|
||||||
hidden: &[std::ops::Range<usize>],
|
(hidden, over): (&[Range<usize>], Over),
|
||||||
links: Option<ElementId>,
|
links: Option<ElementId>,
|
||||||
on_link: &Option<OnLink>,
|
on_link: &Option<OnLink>,
|
||||||
cx: &App,
|
cx: &App,
|
||||||
@@ -185,6 +188,7 @@ fn drawn_text(
|
|||||||
let styles: Vec<_> = (inline.styles.iter())
|
let styles: Vec<_> = (inline.styles.iter())
|
||||||
.filter(|(range, _)| !hidden.contains(range))
|
.filter(|(range, _)| !hidden.contains(range))
|
||||||
.map(|(range, style)| (range.clone(), highlight(style, colors, rule)))
|
.map(|(range, style)| (range.clone(), highlight(style, colors, rule)))
|
||||||
|
.chain(over)
|
||||||
.collect();
|
.collect();
|
||||||
let styled = StyledText::new(inline.text.clone()).with_highlights(crate::editor::stack(styles));
|
let styled = StyledText::new(inline.text.clone()).with_highlights(crate::editor::stack(styles));
|
||||||
let layout = styled.layout().clone();
|
let layout = styled.layout().clone();
|
||||||
@@ -233,7 +237,8 @@ impl Draw<'_> {
|
|||||||
fn inline(&self, inline: &Inline, cx: &App) -> AnyElement {
|
fn inline(&self, inline: &Inline, cx: &App) -> AnyElement {
|
||||||
let links = (!inline.links.is_empty()).then(|| self.key("links"));
|
let links = (!inline.links.is_empty()).then(|| self.key("links"));
|
||||||
if !inline.styles.iter().any(|(_, style)| *style == Style::Math) {
|
if !inline.styles.iter().any(|(_, style)| *style == Style::Math) {
|
||||||
let (drawn, layout) = drawn_text(inline, &[], links, self.hooks.on_link, cx);
|
let (drawn, layout) =
|
||||||
|
drawn_text(inline, (&[], Vec::new()), links, self.hooks.on_link, cx);
|
||||||
let at = move |position| {
|
let at = move |position| {
|
||||||
layout
|
layout
|
||||||
.index_for_position(position)
|
.index_for_position(position)
|
||||||
|
|||||||
@@ -3,14 +3,15 @@
|
|||||||
use std::{cell::RefCell, ops::Range, rc::Rc, sync::Arc};
|
use std::{cell::RefCell, ops::Range, rc::Rc, sync::Arc};
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Bounds, Corners, Element, ElementId, GlobalElementId, InspectorElementId,
|
AnyElement, App, Bounds, Corners, Element, ElementId, GlobalElementId, HighlightStyle,
|
||||||
IntoElement, LayoutId, Pixels, Point, RenderImage, SharedString, Size, TextLayout,
|
InspectorElementId, IntoElement, LayoutId, Pixels, Point, RenderImage, SharedString, Size,
|
||||||
TextStyleRefinement, Window, point,
|
TextLayout, TextStyleRefinement, Window, point,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{OnLink, drawn_text};
|
use super::{OnLink, drawn_text};
|
||||||
use crate::{
|
use crate::{
|
||||||
documents::tree::{Inline, Style},
|
documents::tree::{Inline, Style},
|
||||||
|
theme::ActiveTheme,
|
||||||
typography::typeset,
|
typography::typeset,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,6 +37,8 @@ pub(super) struct MathText {
|
|||||||
text: Option<AnyElement>,
|
text: Option<AnyElement>,
|
||||||
/// Each picture, its size, and the drawn span it covers.
|
/// Each picture, its size, and the drawn span it covers.
|
||||||
pictures: Vec<(Arc<RenderImage>, Size<Pixels>, Range<usize>)>,
|
pictures: Vec<(Arc<RenderImage>, Size<Pixels>, Range<usize>)>,
|
||||||
|
/// One glue character's width.
|
||||||
|
glue: Pixels,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MathText {
|
impl MathText {
|
||||||
@@ -65,6 +68,7 @@ impl MathText {
|
|||||||
shown,
|
shown,
|
||||||
text: None,
|
text: None,
|
||||||
pictures: Vec::new(),
|
pictures: Vec::new(),
|
||||||
|
glue: Pixels::ZERO,
|
||||||
};
|
};
|
||||||
(text, at)
|
(text, at)
|
||||||
}
|
}
|
||||||
@@ -100,14 +104,14 @@ fn back(swaps: &[Swap], at: usize) -> usize {
|
|||||||
(at as isize + shift) as usize
|
(at as isize + shift) as usize
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `inline` with each swapped span drawn as `glue[ix]` glue.
|
/// `inline` with each swapped span drawn as `drawn_as[ix]`.
|
||||||
fn shown(inline: &Inline, swaps: &mut [Swap], glue: &[usize]) -> Inline {
|
fn shown(inline: &Inline, swaps: &mut [Swap], drawn_as: &[String]) -> Inline {
|
||||||
let mut text = String::new();
|
let mut text = String::new();
|
||||||
let mut from = 0;
|
let mut from = 0;
|
||||||
for ((written, drawn), count) in swaps.iter_mut().zip(glue) {
|
for ((written, drawn), with) in swaps.iter_mut().zip(drawn_as) {
|
||||||
text.push_str(&inline.text[from..written.start]);
|
text.push_str(&inline.text[from..written.start]);
|
||||||
let start = text.len();
|
let start = text.len();
|
||||||
text.extend(std::iter::repeat_n(GLUE, *count));
|
text.push_str(with);
|
||||||
*drawn = start..text.len();
|
*drawn = start..text.len();
|
||||||
from = written.end;
|
from = written.end;
|
||||||
}
|
}
|
||||||
@@ -166,9 +170,8 @@ impl Element for MathText {
|
|||||||
.map(|(range, _)| range.clone())
|
.map(|(range, _)| range.clone())
|
||||||
.collect();
|
.collect();
|
||||||
maths.sort_by_key(|range| range.start);
|
maths.sort_by_key(|range| range.start);
|
||||||
let mut swaps = Vec::new();
|
let (mut swaps, mut drawn_as) = (Vec::new(), Vec::new());
|
||||||
let mut counts = Vec::new();
|
let (mut pictures, mut errors) = (Vec::new(), Vec::new());
|
||||||
let mut pictures = Vec::new();
|
|
||||||
for (ix, range) in maths.into_iter().enumerate() {
|
for (ix, range) in maths.into_iter().enumerate() {
|
||||||
let source = SharedString::from(self.inline.text[range.clone()].to_string());
|
let source = SharedString::from(self.inline.text[range.clone()].to_string());
|
||||||
match typeset(
|
match typeset(
|
||||||
@@ -179,22 +182,42 @@ impl Element for MathText {
|
|||||||
cx,
|
cx,
|
||||||
) {
|
) {
|
||||||
Some(Ok((image, size))) => {
|
Some(Ok((image, size))) => {
|
||||||
counts.push((size.width / width).ceil().max(1.0) as usize);
|
let count = (size.width / width).ceil().max(1.0) as usize;
|
||||||
pictures.push((image, size));
|
drawn_as.push(GLUE.to_string().repeat(count));
|
||||||
|
pictures.push((image, size, swaps.len()));
|
||||||
swaps.push((range, 0..0));
|
swaps.push((range, 0..0));
|
||||||
}
|
}
|
||||||
// The formula is the user's text: not logged.
|
// The formula is the user's text: not logged.
|
||||||
Some(Err(_)) => log::warn!("markdown: an inline formula shows as TeX, unset"),
|
Some(Err(error)) => {
|
||||||
|
log::warn!("markdown: an inline formula shows as TeX, unset");
|
||||||
|
drawn_as.push(format!(" ({error})"));
|
||||||
|
errors.push(swaps.len());
|
||||||
|
swaps.push((range.end..range.end, 0..0));
|
||||||
|
}
|
||||||
None => {}
|
None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let inline = shown(&self.inline, &mut swaps, &counts);
|
let inline = shown(&self.inline, &mut swaps, &drawn_as);
|
||||||
let hidden: Vec<Range<usize>> = swaps.iter().map(|(_, drawn)| drawn.clone()).collect();
|
let hidden: Vec<Range<usize>> = (pictures.iter())
|
||||||
let (mut text, layout) =
|
.map(|(_, _, ix)| swaps[*ix].1.clone())
|
||||||
drawn_text(&inline, &hidden, self.links.clone(), &self.on_link, cx);
|
.collect();
|
||||||
|
let danger = HighlightStyle {
|
||||||
|
color: Some(cx.theme().colors.danger),
|
||||||
|
..HighlightStyle::default()
|
||||||
|
};
|
||||||
|
let said = (errors.iter())
|
||||||
|
.map(|ix| (swaps[*ix].1.clone(), danger))
|
||||||
|
.collect();
|
||||||
|
let (mut text, layout) = drawn_text(
|
||||||
|
&inline,
|
||||||
|
(&hidden, said),
|
||||||
|
self.links.clone(),
|
||||||
|
&self.on_link,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
let tallest = pictures
|
let tallest = pictures
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(_, size)| size.height)
|
.map(|(_, size, _)| size.height)
|
||||||
.fold(Pixels::ZERO, Pixels::max);
|
.fold(Pixels::ZERO, Pixels::max);
|
||||||
// A tall formula opens the paragraph's lines.
|
// A tall formula opens the paragraph's lines.
|
||||||
let taller = (tallest > style.line_height_in_pixels(rem)).then(|| TextStyleRefinement {
|
let taller = (tallest > style.line_height_in_pixels(rem)).then(|| TextStyleRefinement {
|
||||||
@@ -202,9 +225,10 @@ impl Element for MathText {
|
|||||||
..TextStyleRefinement::default()
|
..TextStyleRefinement::default()
|
||||||
});
|
});
|
||||||
let id = window.with_text_style(taller, |window| text.request_layout(window, cx));
|
let id = window.with_text_style(taller, |window| text.request_layout(window, cx));
|
||||||
self.pictures = (pictures.into_iter().zip(&hidden))
|
self.pictures = (pictures.into_iter().zip(hidden))
|
||||||
.map(|((image, size), drawn)| (image, size, drawn.clone()))
|
.map(|((image, size, _), drawn)| (image, size, drawn))
|
||||||
.collect();
|
.collect();
|
||||||
|
self.glue = width;
|
||||||
*self.shown.borrow_mut() = Some(Shown { layout, swaps });
|
*self.shown.borrow_mut() = Some(Shown { layout, swaps });
|
||||||
self.text = Some(text);
|
self.text = Some(text);
|
||||||
(id, ())
|
(id, ())
|
||||||
@@ -244,18 +268,9 @@ impl Element for MathText {
|
|||||||
};
|
};
|
||||||
let line = layout.line_height();
|
let line = layout.line_height();
|
||||||
for (image, size, drawn) in &self.pictures {
|
for (image, size, drawn) in &self.pictures {
|
||||||
let (Some(start), Some(end)) = (
|
let Some((start, room)) = place(layout, drawn, self.glue) else {
|
||||||
layout.position_for_index(drawn.start),
|
|
||||||
layout.position_for_index(drawn.end),
|
|
||||||
) else {
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
// Glue at a line's end ends there.
|
|
||||||
let room = if end.y == start.y {
|
|
||||||
end.x - start.x
|
|
||||||
} else {
|
|
||||||
size.width
|
|
||||||
};
|
|
||||||
let origin = start + point((room - size.width) / 2.0, (line - size.height) / 2.0);
|
let origin = start + point((room - size.width) / 2.0, (line - size.height) / 2.0);
|
||||||
let bounds = Bounds {
|
let bounds = Bounds {
|
||||||
origin,
|
origin,
|
||||||
@@ -270,6 +285,22 @@ impl Element for MathText {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where glue `drawn` starts, and how wide it runs.
|
||||||
|
fn place(
|
||||||
|
layout: &TextLayout,
|
||||||
|
drawn: &Range<usize>,
|
||||||
|
glue: Pixels,
|
||||||
|
) -> Option<(Point<Pixels>, Pixels)> {
|
||||||
|
let last = layout.position_for_index(drawn.end - GLUE.len_utf8())?;
|
||||||
|
let first = layout.position_for_index(drawn.start)?;
|
||||||
|
// A wrap's index reads as the previous line's end.
|
||||||
|
let start = match first.y == last.y {
|
||||||
|
true => first,
|
||||||
|
false => point(layout.bounds().origin.x, last.y),
|
||||||
|
};
|
||||||
|
Some((start, last.x + glue - start.x))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -282,7 +313,7 @@ mod tests {
|
|||||||
..Inline::default()
|
..Inline::default()
|
||||||
};
|
};
|
||||||
let mut swaps = vec![(2..5, 0..0)];
|
let mut swaps = vec![(2..5, 0..0)];
|
||||||
let drawn = shown(&inline, &mut swaps, &[4]);
|
let drawn = shown(&inline, &mut swaps, &[GLUE.to_string().repeat(4)]);
|
||||||
assert_eq!(drawn.text, format!("a {} b", GLUE.to_string().repeat(4)));
|
assert_eq!(drawn.text, format!("a {} b", GLUE.to_string().repeat(4)));
|
||||||
let glue = GLUE.len_utf8() * 4;
|
let glue = GLUE.len_utf8() * 4;
|
||||||
assert_eq!(swaps[0].1, 2..2 + glue);
|
assert_eq!(swaps[0].1, 2..2 + glue);
|
||||||
@@ -296,60 +327,107 @@ mod tests {
|
|||||||
assert_eq!(forward(&swaps, 6), 3 + glue);
|
assert_eq!(forward(&swaps, 6), 3 + glue);
|
||||||
}
|
}
|
||||||
|
|
||||||
type Found = Rc<
|
type Read = Box<dyn Fn(Point<Pixels>) -> usize>;
|
||||||
RefCell<
|
type Found = Rc<RefCell<Option<(Rc<RefCell<Option<Shown>>>, Read)>>>;
|
||||||
Option<(
|
|
||||||
Rc<RefCell<Option<Shown>>>,
|
|
||||||
Box<dyn Fn(Point<Pixels>) -> usize>,
|
|
||||||
)>,
|
|
||||||
>,
|
|
||||||
>;
|
|
||||||
|
|
||||||
struct Probe(Found);
|
/// Draws `text` with `math` a formula, `width` wide.
|
||||||
|
struct Probe {
|
||||||
|
text: &'static str,
|
||||||
|
math: Range<usize>,
|
||||||
|
width: f32,
|
||||||
|
found: Found,
|
||||||
|
}
|
||||||
|
|
||||||
impl gpui::Render for Probe {
|
impl gpui::Render for Probe {
|
||||||
fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
|
||||||
use gpui::{ParentElement as _, Styled as _};
|
use gpui::{ParentElement as _, Styled as _};
|
||||||
let inline = Inline {
|
let inline = Inline {
|
||||||
text: "a x^2 b".into(),
|
text: self.text.into(),
|
||||||
styles: vec![(2..5, Style::Math)],
|
styles: vec![(self.math.clone(), Style::Math)],
|
||||||
..Inline::default()
|
..Inline::default()
|
||||||
};
|
};
|
||||||
let (text, at) = MathText::new("probe".into(), inline, None, None);
|
let (text, at) = MathText::new("probe".into(), inline, None, None);
|
||||||
*self.0.borrow_mut() = Some((text.shown.clone(), Box::new(at)));
|
*self.found.borrow_mut() = Some((text.shown.clone(), Box::new(at)));
|
||||||
gpui::div().text_size(gpui::px(16.0)).child(text)
|
let shell = gpui::div()
|
||||||
|
.w(gpui::px(self.width))
|
||||||
|
.text_size(gpui::px(16.0));
|
||||||
|
shell.child(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
fn laid(
|
||||||
fn a_formula_draws_over_glue_and_reads_back(cx: &mut gpui::TestAppContext) {
|
text: &'static str,
|
||||||
|
math: Range<usize>,
|
||||||
|
width: f32,
|
||||||
|
cx: &mut gpui::TestAppContext,
|
||||||
|
) -> Found {
|
||||||
cx.update(crate::theme::Theme::init);
|
cx.update(crate::theme::Theme::init);
|
||||||
let found = Found::default();
|
let found = Found::default();
|
||||||
let held = found.clone();
|
let held = found.clone();
|
||||||
let (_, cx) = cx.add_window_view(move |_, _| Probe(held));
|
let (_, cx) = cx.add_window_view(move |_, _| Probe {
|
||||||
|
text,
|
||||||
|
math,
|
||||||
|
width,
|
||||||
|
found: held,
|
||||||
|
});
|
||||||
for _ in 0..4 {
|
for _ in 0..4 {
|
||||||
cx.run_until_parked();
|
cx.run_until_parked();
|
||||||
cx.update(|window, _| window.refresh());
|
cx.update(|window, _| window.refresh());
|
||||||
}
|
}
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_formula_draws_over_glue_and_reads_back(cx: &mut gpui::TestAppContext) {
|
||||||
|
let found = laid("a x^2 b", 2..5, 400.0, cx);
|
||||||
let found = found.borrow();
|
let found = found.borrow();
|
||||||
let (shown, at) = found.as_ref().expect("drawn");
|
let (shown, at) = found.as_ref().expect("drawn");
|
||||||
let shown = shown.borrow();
|
let shown = shown.borrow();
|
||||||
let Shown { layout, swaps } = shown.as_ref().expect("laid out");
|
let Shown { layout, swaps } = shown.as_ref().expect("laid out");
|
||||||
assert_eq!(swaps.len(), 1, "the formula typeset in place");
|
assert_eq!(swaps.len(), 1, "the formula typeset in place");
|
||||||
let drawn = swaps[0].1.clone();
|
let drawn = &swaps[0].1;
|
||||||
let (start, end) = (
|
let at_x = |ix| layout.position_for_index(ix).expect("on its line").x;
|
||||||
layout.position_for_index(drawn.start),
|
let glue = at_x(drawn.end) - at_x(drawn.end - GLUE.len_utf8());
|
||||||
layout.position_for_index(drawn.end),
|
let (start, room) = place(layout, drawn, glue).expect("placed");
|
||||||
);
|
assert!(room > gpui::px(10.0), "glue holds the picture's width");
|
||||||
let (start, end) = (start.expect("its start"), end.expect("its end"));
|
let middle = point(start.x + room / 2.0, start.y + layout.line_height() / 2.0);
|
||||||
assert!(
|
|
||||||
end.x - start.x > gpui::px(10.0),
|
|
||||||
"glue holds the picture's width"
|
|
||||||
);
|
|
||||||
let middle = point(
|
|
||||||
(start.x + end.x) / 2.0,
|
|
||||||
start.y + layout.line_height() / 2.0,
|
|
||||||
);
|
|
||||||
assert_eq!(at(middle), 2, "a press on the picture is the formula");
|
assert_eq!(at(middle), 2, "a press on the picture is the formula");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_formula_wrapped_whole_draws_on_its_line(cx: &mut gpui::TestAppContext) {
|
||||||
|
let text = "aaaaaaaaaa x^2+y^2=r^2 tail";
|
||||||
|
let found = laid(text, 11..22, 160.0, cx);
|
||||||
|
let found = found.borrow();
|
||||||
|
let (shown, at) = found.as_ref().expect("drawn");
|
||||||
|
let shown = shown.borrow();
|
||||||
|
let Shown { layout, swaps } = shown.as_ref().expect("laid out");
|
||||||
|
let drawn = &swaps[0].1;
|
||||||
|
let line = layout.line_height();
|
||||||
|
let last = layout.position_for_index(drawn.end - GLUE.len_utf8());
|
||||||
|
assert_eq!(last.expect("its glue").y, layout.bounds().top() + line);
|
||||||
|
let (start, _) = place(layout, drawn, gpui::px(0.0)).expect("placed");
|
||||||
|
assert_eq!(
|
||||||
|
start,
|
||||||
|
point(layout.bounds().left(), layout.bounds().top() + line),
|
||||||
|
"the picture starts the second line"
|
||||||
|
);
|
||||||
|
assert_eq!(at(start + point(gpui::px(2.0), line / 2.0)), 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_failed_formula_says_why_after_its_tex(cx: &mut gpui::TestAppContext) {
|
||||||
|
let found = laid("a x^2^3 b", 2..7, 400.0, cx);
|
||||||
|
let found = found.borrow();
|
||||||
|
let (shown, _) = found.as_ref().expect("drawn");
|
||||||
|
let shown = shown.borrow();
|
||||||
|
let Shown { layout, swaps } = shown.as_ref().expect("laid out");
|
||||||
|
assert_eq!(swaps[0].0, 7..7, "the TeX stays; the reason follows");
|
||||||
|
let text = layout.wrapped_text();
|
||||||
|
assert!(
|
||||||
|
text.starts_with("a x^2^3 (") && text.ends_with(") b"),
|
||||||
|
"{text:?}"
|
||||||
|
);
|
||||||
|
assert!(text.contains(" at "), "where RaTeX stopped: {text:?}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@ use super::markdown::{span, tree, walk};
|
|||||||
|
|
||||||
mod blocks;
|
mod blocks;
|
||||||
mod emoji;
|
mod emoji;
|
||||||
mod html;
|
pub(crate) mod html;
|
||||||
mod html_block;
|
mod html_block;
|
||||||
mod sources;
|
mod sources;
|
||||||
mod stack;
|
mod stack;
|
||||||
@@ -318,7 +318,7 @@ impl<'a> Reader<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Text split at its images; lone `$$` or `\[` math is block.
|
/// Text splits at images; a lone formula is block.
|
||||||
fn paragraph(&self, node: &mdast::Node, children: &[mdast::Node], out: &mut Vec<Node>) {
|
fn paragraph(&self, node: &mdast::Node, children: &[mdast::Node], out: &mut Vec<Node>) {
|
||||||
if let [mdast::Node::InlineMath(math)] = children
|
if let [mdast::Node::InlineMath(math)] = children
|
||||||
&& ["$$", "\\["]
|
&& ["$$", "\\["]
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ fn framed(svg: &str, width: f32, height: f32) -> Result<(String, Size<Pixels>),
|
|||||||
Ok((wrapped(svg, [left, top, w, h])?, size(px(w), px(h))))
|
Ok((wrapped(svg, [left, top, w, h])?, size(px(w), px(h))))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `svg`'s drawing under a root viewing `[left, top, w, h]` in px.
|
/// `svg` drawn in a px root viewing `[left, top, w, h]`.
|
||||||
fn wrapped(svg: &str, [left, top, w, h]: [f32; 4]) -> Result<String, MathError> {
|
fn wrapped(svg: &str, [left, top, w, h]: [f32; 4]) -> Result<String, MathError> {
|
||||||
let open = svg
|
let open = svg
|
||||||
.find('>')
|
.find('>')
|
||||||
|
|||||||
Reference in new issue
Block a user