From d37d2e52378c18ea6c9df445a9866c97e4c090fc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 08:30:11 -0400 Subject: [PATCH] =?UTF-8?q?T:=20math=20expressions=20=E2=80=94=20LaTeX=20s?= =?UTF-8?q?ubset=20typeset=20to=20SVG,=20inline=20or=20framed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/mathexpr.rs | 126 ++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/lib.rs | 1 + crates/engine/src/math.rs | 717 ++++++++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 1 + docs/parity.md | 6 +- 7 files changed, 851 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/cmd/mathexpr.rs create mode 100644 crates/engine/src/math.rs diff --git a/ROADMAP.md b/ROADMAP.md index 3ed550f..596c2c5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -104,6 +104,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Keyboard: Tab / Shift+Tab hide and show all panels / all but Tools (`window.hidePanels`, `window.hidePanelsExceptTools`); Cmd+F6 / Cmd+Shift+F6 cycle documents; panel fields are reached with Tab and Escape returns to the layout. - View ▸ Rotate Spread (`view.rotateSpread`: 90° CW / CCW, 180°, clear): the view turns in quarter turns about its centre; tools, hit testing, the render texture and overlays follow; rulers hide while rotated. InDesign turns just the one spread. - Vertical type (Vertical Type Tool, Type ▸ Story Direction, `object.textFrameOptions {vertical}`, IDML StoryOrientation): lines run top to bottom and follow each other right to left; CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text). Vertical punctuation forms (`vert`), tate-chu-yoko and vertical metrics aren't there yet. +- Math expressions (Type ▸ Insert Math Expression…, `math.insert`, `math.svg`): a LaTeX subset (fractions, scripts, roots, big operators with limits, Greek, relations and operators with math spacing, `\left…\right`, `\text`) typeset by our own engine into SVG, placed inline at the cursor or as a frame; the LaTeX is kept as alt text and can be edited. - Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`). - Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. diff --git a/crates/engine/src/cmd/mathexpr.rs b/crates/engine/src/cmd/mathexpr.rs new file mode 100644 index 0000000..791a0d7 --- /dev/null +++ b/crates/engine/src/cmd/mathexpr.rs @@ -0,0 +1,126 @@ +//! Math expressions: LaTeX typeset by [`crate::math`] and placed as a graphic, inline at the +//! text insertion point (an anchored object) or as a frame. The LaTeX is kept as the object's +//! alt text, so it can be edited and exports accessibly. + +use std::sync::Arc; + +use designcraft_doc::{Asset, AssetId, Content, Graphic, Item, ItemId, Shape}; +use designcraft_geom::{Affine, Rect, shapes}; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, cmd, has_doc, str_param}; +use crate::{Result, Session}; + +fn math_item(d: &mut designcraft_doc::Document, latex: &str, size: f64, lid: designcraft_doc::LayerId, at: (f64, f64)) -> Item { + let (svg, (w, h)) = crate::math::to_svg(latex, size); + let aid = AssetId(d.alloc()); + d.assets.insert( + aid, + Arc::new(Asset { page: 0, id: aid, name: "math.svg".into(), mime: "image/svg+xml".into(), link: None, data: Arc::new(svg), pixels: None }), + ); + let id = ItemId(d.alloc()); + let r = Rect::new(at.0, at.1, at.0 + w, at.1 + h); + let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r)); + it.content = + Content::Graphic(Graphic { asset: aid, size: (w, h), xf: Affine::translate(at), auto_fit: Default::default(), fit_align: 4, crop: [0.0; 4] }); + it.alt_text = latex.to_string(); + it.name = "Math".into(); + it +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "math.insert", + "Insert Math Expression…", + ["Type"], + None, + "{latex, size?: points (12), x?, y?, spread?} — at the text insertion point (inline), else as a frame; or {id, latex} to edit one", + has_doc, + insert + ), + cmd!(query "math.svg", "Math Expression SVG", [], None, "{latex, size?} → {svg, width, height} — typeset without placing", has_doc, |_s, p| { + let latex = str_param(p, "latex").ok_or_else(|| bad("math.svg", "`latex` required"))?; + let (svg, (w, h)) = crate::math::to_svg(latex, p.get("size").and_then(Value::as_f64).unwrap_or(12.0)); + Ok(json!({"svg": String::from_utf8_lossy(&svg), "width": w, "height": h})) + }), + ] +} + +fn insert(s: &mut Session, p: &Value) -> Result { + let latex = str_param(p, "latex").ok_or_else(|| bad("math.insert", "`latex` required"))?.to_string(); + let size = p.get("size").and_then(Value::as_f64).unwrap_or(12.0).clamp(2.0, 400.0); + let lid = s.doc()?.active_layer; + // Edit an existing expression in place. + if let Some(id) = super::id_param(p, "id") { + return s.edit(|d, _| { + let old = d.item(id).ok_or_else(|| bad("math.insert", "no such object"))?; + let at = old.bounds(); + let it = math_item(d, &latex, size, lid, (at.x0, at.y0)); + let target = d.item_mut(id).ok_or_else(|| bad("math.insert", "no such object"))?; + target.path = it.path; + target.content = it.content; + target.alt_text = latex.clone(); + Ok(json!({"id": id.0})) + }); + } + let text = s.doc()?.selection.text.filter(|t| t.cell.is_none()); + let spread = super::spread_param(p, "spread"); + let (px, py) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)); + s.edit(|d, sel| { + if let Some(t) = text { + let it = math_item(d, &latex, size, lid, (0.0, 0.0)); + let r = t.range(); + if let Some(st) = d.story_mut(t.story) { + st.delete(r.start.min(st.len())..r.end.min(st.len())); + } + let end = super::anchored::anchor_items(d, t.story, r.start, vec![it], &Default::default())?; + sel.text = Some(designcraft_doc::TextSel { anchor: end, focus: end, ..t }); + return Ok(json!({"inline": true, "pos": end})); + } + let pr = d.spread(spread).and_then(|sp| sp.pages.first()).map(|pg| pg.margin_rect()).unwrap_or(Rect::new(36.0, 36.0, 300.0, 300.0)); + let it = math_item(d, &latex, size, lid, (px.unwrap_or(pr.x0), py.unwrap_or(pr.y0))); + let id = it.id; + d.insert_item(spread, it, None)?; + sel.items = vec![id]; + Ok(json!({"id": id.0})) + }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn math_places_inline_and_as_frames() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("math.insert", &json!({"latex": r"\frac{a}{b} + \sqrt{x}", "x": 100, "y": 100})).unwrap(); + let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap()); + let d = s.doc().unwrap().doc.clone(); + let it = d.item(id).unwrap(); + assert_eq!(it.alt_text, r"\frac{a}{b} + \sqrt{x}"); + assert!(it.bounds().height() > 20.0, "a fraction is tall"); + // It draws: dark pixels inside the frame. + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + let img = rr.render_page(&d, &s.cache, 0, 2.0, false, &Default::default()).unwrap(); + let b = it.bounds(); + let dark = (b.x0 as u32 * 2..b.x1 as u32 * 2) + .flat_map(|x| (b.y0 as u32 * 2..b.y1 as u32 * 2).map(move |y| (x, y))) + .filter(|&(x, y)| img.pixel(x, y)[0] < 100) + .count(); + assert!(dark > 30, "{dark}"); + // Edit in place. + s.execute("math.insert", &json!({"id": id.0, "latex": "x"})).unwrap(); + assert!(s.doc().unwrap().doc.item(id).unwrap().bounds().height() < b.height()); + // Inline at the insertion point. + let f = s.execute("frame.create", &json!({"rect": [72, 300, 400, 400], "content": "text", "text": "Area: "})).unwrap(); + s.execute("text.select", &json!({"story": f["story"], "anchor": 6, "focus": 6})).unwrap(); + assert_eq!(s.execute("math.insert", &json!({"latex": r"\pi r^2"})).unwrap()["inline"], true); + let st = &s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(f["story"].as_u64().unwrap())]; + assert_eq!(st.objects.len(), 1); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 11c419c..6caad6b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -28,6 +28,7 @@ mod linked; mod links; mod liquid; mod lists; +mod mathexpr; mod media; mod notes; mod object; @@ -163,6 +164,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(library::specs()); v.extend(lists::specs()); v.extend(linked::specs()); + v.extend(mathexpr::specs()); v.extend(media::specs()); v.extend(liquid::specs()); v.extend(xml::specs()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 98170e5..7b5fa99 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -8,6 +8,7 @@ pub mod cmd; pub mod links; +pub mod math; pub mod recovery; pub mod sample; pub mod script; diff --git a/crates/engine/src/math.rs b/crates/engine/src/math.rs new file mode 100644 index 0000000..97327ed --- /dev/null +++ b/crates/engine/src/math.rs @@ -0,0 +1,717 @@ +//! Math expressions: a LaTeX subset typeset into boxes and written as SVG (text in the bundled +//! serif, rules and radicals as paths). Covers fractions, super/subscripts, square and n-th roots, +//! big operators with limits, Greek letters, common symbols, `\text{}`, `\mathrm{}`, spacing +//! commands and `\left( … \right)`. + +use std::fmt::Write as _; + +use designcraft_fonts::{FontDb, FontFace}; + +/// A positioned piece of the formula (baseline-relative: y grows downwards). +#[derive(Clone, Debug, PartialEq)] +enum Elem { + Text { + x: f64, + y: f64, + s: String, + size: f64, + italic: bool, + }, + Rule { + x: f64, + y: f64, + w: f64, + h: f64, + }, + /// A polyline (radical sign), stroke width `w`. + Line { + pts: Vec<(f64, f64)>, + w: f64, + }, +} + +/// A laid-out box: width, height above and depth below the baseline. +#[derive(Clone, Debug, Default)] +struct MBox { + w: f64, + asc: f64, + desc: f64, + els: Vec, +} + +impl MBox { + fn shifted(mut self, dx: f64, dy: f64) -> Vec { + for e in &mut self.els { + match e { + Elem::Text { x, y, .. } | Elem::Rule { x, y, .. } => { + *x += dx; + *y += dy; + } + Elem::Line { pts, .. } => { + for p in pts { + p.0 += dx; + p.1 += dy; + } + } + } + } + self.els + } +} + +#[derive(Clone, Debug, PartialEq)] +enum Node { + /// A character run: identifiers italic, numbers and symbols upright. + Sym(String, Class), + Group(Vec), + Frac(Box, Box), + Sqrt(Option>, Box), + Scripts { + base: Box, + sup: Option>, + sub: Option>, + }, + Text(String), + Space(f64), + /// A big operator (∑ ∏ ∫): drawn larger, limits as scripts. + Big(String), + /// `\left( … \right)`: delimiters sized to the content. + Fenced(String, Box, String), +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Class { + Ident, + Number, + /// Binary operator (+ − × ·): medium space both sides. + Bin, + /// Relation (= < ≤ →): thick space both sides. + Rel, + Punct, + Plain, +} + +fn symbol(name: &str) -> Option<(&'static str, Class)> { + use Class::*; + Some(match name { + "alpha" => ("α", Ident), + "beta" => ("β", Ident), + "gamma" => ("γ", Ident), + "delta" => ("δ", Ident), + "epsilon" | "varepsilon" => ("ε", Ident), + "zeta" => ("ζ", Ident), + "eta" => ("η", Ident), + "theta" => ("θ", Ident), + "iota" => ("ι", Ident), + "kappa" => ("κ", Ident), + "lambda" => ("λ", Ident), + "mu" => ("μ", Ident), + "nu" => ("ν", Ident), + "xi" => ("ξ", Ident), + "pi" => ("π", Ident), + "rho" => ("ρ", Ident), + "sigma" => ("σ", Ident), + "tau" => ("τ", Ident), + "phi" | "varphi" => ("φ", Ident), + "chi" => ("χ", Ident), + "psi" => ("ψ", Ident), + "omega" => ("ω", Ident), + "Gamma" => ("Γ", Plain), + "Delta" => ("Δ", Plain), + "Theta" => ("Θ", Plain), + "Lambda" => ("Λ", Plain), + "Pi" => ("Π", Plain), + "Sigma" => ("Σ", Plain), + "Phi" => ("Φ", Plain), + "Psi" => ("Ψ", Plain), + "Omega" => ("Ω", Plain), + "times" => ("×", Bin), + "cdot" => ("·", Bin), + "pm" => ("±", Bin), + "mp" => ("∓", Bin), + "div" => ("÷", Bin), + "le" | "leq" => ("≤", Rel), + "ge" | "geq" => ("≥", Rel), + "neq" | "ne" => ("≠", Rel), + "approx" => ("≈", Rel), + "equiv" => ("≡", Rel), + "to" | "rightarrow" => ("→", Rel), + "leftarrow" => ("←", Rel), + "Rightarrow" => ("⇒", Rel), + "in" => ("∈", Rel), + "infty" => ("∞", Plain), + "partial" => ("∂", Plain), + "nabla" => ("∇", Plain), + "ldots" | "dots" => ("…", Plain), + "cdots" => ("⋯", Plain), + "prime" => ("′", Plain), + "degree" => ("°", Plain), + _ => return None, + }) +} + +fn big_op(name: &str) -> Option<&'static str> { + Some(match name { + "sum" => "∑", + "prod" => "∏", + "int" => "∫", + "oint" => "∮", + _ => return None, + }) +} + +struct Parser<'a> { + s: &'a [char], + i: usize, +} + +impl Parser<'_> { + fn peek(&self) -> Option { + self.s.get(self.i).copied() + } + fn skip_ws(&mut self) { + while self.peek().is_some_and(char::is_whitespace) { + self.i += 1; + } + } + fn command(&mut self) -> String { + let mut name = String::new(); + if let Some(c) = self.peek() + && !c.is_ascii_alphabetic() + { + self.i += 1; + return c.to_string(); + } + while let Some(c) = self.peek().filter(char::is_ascii_alphabetic) { + name.push(c); + self.i += 1; + } + name + } + /// Raw text of a `{…}` argument. + fn raw_group(&mut self) -> String { + self.skip_ws(); + if self.peek() != Some('{') { + return self + .peek() + .map(|c| { + self.i += 1; + c.to_string() + }) + .unwrap_or_default(); + } + self.i += 1; + let mut depth = 1; + let mut out = String::new(); + while let Some(c) = self.peek() { + self.i += 1; + match c { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + break; + } + } + _ => {} + } + out.push(c); + } + out + } + fn arg(&mut self) -> Node { + self.skip_ws(); + if self.peek() == Some('{') { + self.i += 1; + let g = self.list(Some('}')); + Node::Group(g) + } else { + self.atom().unwrap_or(Node::Group(vec![])) + } + } + fn delimiter(&mut self) -> String { + self.skip_ws(); + match self.peek() { + Some('\\') => { + self.i += 1; + match self.command().as_str() { + "{" => "{".into(), + "}" => "}".into(), + "|" => "‖".into(), + "langle" => "⟨".into(), + "rangle" => "⟩".into(), + _ => String::new(), + } + } + Some('.') => { + self.i += 1; + String::new() + } + Some(c) => { + self.i += 1; + c.to_string() + } + None => String::new(), + } + } + fn atom(&mut self) -> Option { + self.skip_ws(); + let c = self.peek()?; + self.i += 1; + Some(match c { + '{' => Node::Group(self.list(Some('}'))), + '\\' => { + let name = self.command(); + match name.as_str() { + "frac" | "dfrac" | "tfrac" => { + let a = self.arg(); + let b = self.arg(); + Node::Frac(Box::new(a), Box::new(b)) + } + "sqrt" => { + self.skip_ws(); + let idx = (self.peek() == Some('[')).then(|| { + self.i += 1; + let mut s = String::new(); + while let Some(c) = self.peek() { + self.i += 1; + if c == ']' { + break; + } + s.push(c); + } + Box::new(parse(&s)) + }); + let a = self.arg(); + Node::Sqrt(idx, Box::new(a)) + } + "text" | "mathrm" | "textrm" | "operatorname" => Node::Text(self.raw_group()), + "sin" | "cos" | "tan" | "log" | "ln" | "exp" | "lim" | "max" | "min" => Node::Text(name), + "," => Node::Space(3.0 / 18.0), + ":" | ">" => Node::Space(4.0 / 18.0), + ";" => Node::Space(5.0 / 18.0), + "quad" => Node::Space(1.0), + "qquad" => Node::Space(2.0), + "!" => Node::Space(-3.0 / 18.0), + " " => Node::Space(0.25), + "left" => { + let open = self.delimiter(); + let body = self.list_until_right(); + let close = self.delimiter(); + Node::Fenced(open, Box::new(Node::Group(body)), close) + } + "{" => Node::Sym("{".into(), Class::Punct), + "}" => Node::Sym("}".into(), Class::Punct), + "%" | "$" | "#" | "&" | "_" => Node::Sym(name, Class::Plain), + n => match (big_op(n), symbol(n)) { + (Some(op), _) => Node::Big(op.into()), + (None, Some((s, cl))) => Node::Sym(s.into(), cl), + _ => Node::Text(format!("\\{n}")), + }, + } + } + c if c.is_ascii_digit() || c == '.' => { + let mut s = c.to_string(); + while let Some(d) = self.peek().filter(|d| d.is_ascii_digit() || *d == '.') { + s.push(d); + self.i += 1; + } + Node::Sym(s, Class::Number) + } + c if c.is_alphabetic() => Node::Sym(c.to_string(), Class::Ident), + '+' => Node::Sym("+".into(), Class::Bin), + '-' => Node::Sym("−".into(), Class::Bin), + '*' => Node::Sym("∗".into(), Class::Bin), + '=' | '<' | '>' => Node::Sym(c.to_string(), Class::Rel), + ',' | ';' => Node::Sym(c.to_string(), Class::Punct), + '\'' => Node::Sym("′".into(), Class::Plain), + c => Node::Sym(c.to_string(), Class::Plain), + }) + } + fn list(&mut self, end: Option) -> Vec { + let mut out: Vec = Vec::new(); + loop { + self.skip_ws(); + match self.peek() { + None => break, + Some(c) if Some(c) == end => { + self.i += 1; + break; + } + Some('^') | Some('_') => { + let up = self.peek() == Some('^'); + self.i += 1; + let a = self.arg(); + let base = out.pop().unwrap_or(Node::Group(vec![])); + let node = match base { + Node::Scripts { base, sup, sub } => { + if up { + Node::Scripts { base, sup: Some(Box::new(a)), sub } + } else { + Node::Scripts { base, sup, sub: Some(Box::new(a)) } + } + } + b => Node::Scripts { base: Box::new(b), sup: up.then(|| Box::new(a.clone())), sub: (!up).then(|| Box::new(a)) }, + }; + out.push(node); + } + Some(_) => { + if let Some(a) = self.atom() { + out.push(a); + } + } + } + } + out + } + fn list_until_right(&mut self) -> Vec { + let mut out = Vec::new(); + loop { + self.skip_ws(); + if self.peek().is_none() { + break; + } + if self.s[self.i..].starts_with(&['\\', 'r', 'i', 'g', 'h', 't']) { + self.i += 6; + break; + } + let mut p = Parser { s: self.s, i: self.i }; + let one = p.list_one(); + self.i = p.i; + out.extend(one); + } + out + } + /// One atom plus any scripts on it. + fn list_one(&mut self) -> Vec { + let mut out = Vec::new(); + if let Some(a) = self.atom() { + out.push(a); + } + loop { + self.skip_ws(); + match self.peek() { + Some('^') | Some('_') => { + let up = self.peek() == Some('^'); + self.i += 1; + let a = self.arg(); + let base = out.pop().unwrap_or(Node::Group(vec![])); + out.push(match base { + Node::Scripts { base, sub, .. } if up => Node::Scripts { base, sup: Some(Box::new(a)), sub }, + Node::Scripts { base, sup, .. } => Node::Scripts { base, sup, sub: Some(Box::new(a)) }, + b => Node::Scripts { base: Box::new(b), sup: up.then(|| Box::new(a.clone())), sub: (!up).then(|| Box::new(a)) }, + }); + } + _ => break, + } + } + out + } +} + +fn parse(src: &str) -> Node { + let chars: Vec = src.chars().collect(); + let mut p = Parser { s: &chars, i: 0 }; + Node::Group(p.list(None)) +} + +/// Typesetting fonts and metrics. +struct Fonts { + upright: std::sync::Arc, + italic: std::sync::Arc, + fallback: std::sync::Arc, +} + +impl Fonts { + fn face(&self, c: char, italic: bool) -> &FontFace { + let f = if italic { &self.italic } else { &self.upright }; + if f.glyph_for(c) != 0 { f } else { &self.fallback } + } + fn width(&self, s: &str, size: f64, italic: bool) -> f64 { + s.chars() + .map(|c| { + let f = self.face(c, italic); + f.advance(f.glyph_for(c)) / f.units_per_em().max(1.0) * size + }) + .sum() + } +} + +const FAMILY: &str = designcraft_fonts::DEFAULT_FAMILY; +const FALLBACK: &str = "Source Sans 3"; + +fn text_box(f: &Fonts, s: &str, size: f64, italic: bool) -> MBox { + let w = f.width(s, size, italic); + MBox { w, asc: size * 0.72, desc: size * 0.22, els: vec![Elem::Text { x: 0.0, y: 0.0, s: s.to_string(), size, italic }] } +} + +fn hbox(parts: Vec<(MBox, f64)>) -> MBox { + // (box, space before) laid out along the baseline. + let mut out = MBox::default(); + let mut x = 0.0; + for (b, before) in parts { + x += before; + out.asc = out.asc.max(b.asc); + out.desc = out.desc.max(b.desc); + let w = b.w; + out.els.extend(b.shifted(x, 0.0)); + x += w; + } + out.w = x.max(0.0); + out +} + +fn layout(f: &Fonts, n: &Node, size: f64) -> MBox { + let axis = size * 0.25; + match n { + Node::Sym(s, cl) => text_box(f, s, size, *cl == Class::Ident), + Node::Text(s) => text_box(f, s, size, false), + Node::Space(em) => MBox { w: em * size, ..Default::default() }, + Node::Group(list) => { + let mut parts = Vec::new(); + let mut prev: Option = None; + for (k, item) in list.iter().enumerate() { + let cl = match item { + Node::Sym(_, c) => Some(*c), + Node::Scripts { base, .. } => match &**base { + Node::Sym(_, c) => Some(*c), + _ => None, + }, + _ => None, + }; + // A leading + or − (or after another operator) is a sign, not a binary operator. + let cl = if cl == Some(Class::Bin) && (k == 0 || matches!(prev, Some(Class::Bin | Class::Rel | Class::Punct))) { + Some(Class::Plain) + } else { + cl + }; + let gap = |c: Option| match c { + Some(Class::Bin) => size * 4.0 / 18.0, + Some(Class::Rel) => size * 5.0 / 18.0, + _ => 0.0, + }; + let mut before = gap(cl).max(gap(prev)); + if k == 0 { + before = 0.0; + } + if prev == Some(Class::Punct) { + before = before.max(size * 3.0 / 18.0); + } + parts.push((layout(f, item, size), before)); + prev = cl; + } + hbox(parts) + } + Node::Frac(a, b) => { + let s2 = size * 0.85; + let (na, nb) = (layout(f, a, s2), layout(f, b, s2)); + let w = na.w.max(nb.w) + size * 0.2; + let t = (size * 0.06).max(0.4); + let gap = size * 0.15; + let num_base = -(axis + t / 2.0 + gap + na.desc); + let den_base = -axis + t / 2.0 + gap + nb.asc; + let mut out = MBox { w, asc: -num_base + na.asc, desc: den_base + nb.desc, els: Vec::new() }; + out.els.push(Elem::Rule { x: 0.0, y: -axis - t / 2.0, w, h: t }); + let (wa, wb) = (na.w, nb.w); + out.els.extend(na.shifted((w - wa) / 2.0, num_base)); + out.els.extend(nb.shifted((w - wb) / 2.0, den_base)); + out + } + Node::Sqrt(idx, a) => { + let inner = layout(f, a, size); + let t = (size * 0.05).max(0.4); + let gap = size * 0.12; + let top = inner.asc + gap + t; + let bottom = inner.desc; + let hook = size * 0.55; + let mut out = MBox { w: hook + inner.w + size * 0.1, asc: top, desc: bottom, els: Vec::new() }; + // The radical: a short rise, the long stroke down, then up to the bar. + out.els.push(Elem::Line { + pts: vec![(0.0, -axis * 0.6), (hook * 0.25, -axis), (hook * 0.55, bottom), (hook, -top + t / 2.0), (out.w, -top + t / 2.0)], + w: t, + }); + let iw = inner.w; + let _ = iw; + out.els.extend(inner.shifted(hook, 0.0)); + if let Some(i) = idx { + let ib = layout(f, i, size * 0.55); + let (ia, iw2) = (ib.asc, ib.w); + out.els.extend(ib.shifted((hook * 0.3 - iw2).max(0.0) - 0.0, -axis - size * 0.1)); + out.asc = out.asc.max(axis + size * 0.1 + ia); + } + out + } + Node::Big(op) => { + let mut b = text_box(f, op, size * 1.5, false); + b.asc = size * 1.05; + b.desc = size * 0.35; + for e in &mut b.els { + if let Elem::Text { y, .. } = e { + // Centre the glyph on the math axis. + *y = size * 0.32; + } + } + b + } + Node::Scripts { base, sup, sub } => { + let big = matches!(&**base, Node::Big(_)); + let b = layout(f, base, size); + let s2 = size * 0.7; + let (bw, basc, bdesc) = (b.w, b.asc, b.desc); + let mut out = MBox { w: bw, asc: basc, desc: bdesc, els: b.els }; + if big { + // Limits above and below. + let mut w = bw; + if let Some(sp) = sup { + let sb = layout(f, sp, s2); + let y = -basc - size * 0.1 - sb.desc; + w = w.max(sb.w); + let (sw, sa) = (sb.w, sb.asc); + out.els.extend(sb.shifted((bw - sw) / 2.0, y)); + out.asc = out.asc.max(-y + sa); + } + if let Some(sbn) = sub { + let sb = layout(f, sbn, s2); + let y = bdesc + size * 0.1 + sb.asc; + w = w.max(sb.w); + let (sw, sd) = (sb.w, sb.desc); + out.els.extend(sb.shifted((bw - sw) / 2.0, y)); + out.desc = out.desc.max(y + sd); + } + // Centre the operator over the widest limit. + let dx = (w - bw) / 2.0; + let els = std::mem::take(&mut out.els); + out = MBox { w, asc: out.asc, desc: out.desc, els: MBox { els, ..Default::default() }.shifted(dx, 0.0) }; + return out; + } + let mut w = bw; + if let Some(sp) = sup { + let sb = layout(f, sp, s2); + let y = -(size * 0.42).max(basc - sb.asc * 0.6); + w = w.max(bw + sb.w + size * 0.05); + let sa = sb.asc; + out.els.extend(sb.shifted(bw + size * 0.05, y)); + out.asc = out.asc.max(-y + sa); + } + if let Some(sbn) = sub { + let sb = layout(f, sbn, s2); + let y = (size * 0.2).max(bdesc * 0.8); + w = w.max(bw + sb.w + size * 0.05); + let sd = sb.desc; + out.els.extend(sb.shifted(bw + size * 0.05, y)); + out.desc = out.desc.max(y + sd); + } + out.w = w; + out + } + Node::Fenced(open, body, close) => { + let inner = layout(f, body, size); + let h = (inner.asc + inner.desc).max(size); + let k = h / size; + let ds = size * k.max(1.0); + let mk = |s: &str| -> MBox { + if s.is_empty() { + return MBox::default(); + } + let mut b = text_box(f, s, ds, false); + // Scaled delimiters centred on the content. + let mid = (inner.desc - inner.asc) / 2.0; + for e in &mut b.els { + if let Elem::Text { y, .. } = e { + *y = mid + ds * 0.25; + } + } + b.asc = inner.asc.max(size * 0.72); + b.desc = inner.desc.max(size * 0.22); + b + }; + let (l, r) = (mk(open), mk(close)); + hbox(vec![(l, 0.0), (inner, size * 0.05), (r, size * 0.05)]) + } + } +} + +/// Typeset `latex` at `size` points: SVG bytes and the size in points. +pub fn to_svg(latex: &str, size: f64) -> (Vec, (f64, f64)) { + let db = FontDb::global(); + let f = Fonts { upright: db.face(FAMILY, "Regular"), italic: db.face(FAMILY, "Italic"), fallback: db.face(FALLBACK, "Regular") }; + let b = layout(&f, &parse(latex), size); + let pad = size * 0.1; + let (w, h) = (b.w + 2.0 * pad, b.asc + b.desc + 2.0 * pad); + let base = pad + b.asc; + let mut svg = String::new(); + let _ = write!(svg, ""); + let esc = |s: &str| s.replace('&', "&").replace('<', "<").replace('>', ">"); + for e in &b.els { + match e { + Elem::Text { x, y, s, size, italic } => { + // Per character, so each uses a face that has it. + let mut cx = *x; + for c in s.chars() { + let face = f.face(c, *italic); + let fam = if std::ptr::eq(face, &*f.fallback) { FALLBACK } else { FAMILY }; + let it = *italic && !std::ptr::eq(face, &*f.fallback); + let _ = write!( + svg, + "{}", + pad + cx, + base + y, + if it { " font-style=\"italic\"" } else { "" }, + esc(&c.to_string()) + ); + cx += f.width(&c.to_string(), *size, *italic); + } + } + Elem::Rule { x, y, w: rw, h: rh } => { + let _ = write!(svg, "", pad + x, base + y); + } + Elem::Line { pts, w: lw } => { + let d: Vec = pts.iter().map(|(x, y)| format!("{:.3},{:.3}", pad + x, base + y)).collect(); + let _ = write!( + svg, + "", + d.join(" ") + ); + } + } + } + svg.push_str(""); + (svg.into_bytes(), (w, h)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_the_common_constructs() { + let n = parse(r"\frac{a^2+b}{\sqrt[3]{x}}"); + let Node::Group(v) = n else { panic!() }; + assert!(matches!(&v[0], Node::Frac(_, _))); + let n = parse(r"\sum_{i=1}^{n} i^2 \le \alpha"); + let Node::Group(v) = n else { panic!() }; + assert!(matches!(&v[0], Node::Scripts { base, sup: Some(_), sub: Some(_) } if matches!(**base, Node::Big(_)))); + assert!(v.iter().any(|x| *x == Node::Sym("≤".into(), Class::Rel))); + assert!(v.iter().any(|x| *x == Node::Sym("α".into(), Class::Ident))); + let n = parse(r"\left( \frac{1}{2} \right) + \text{if } x"); + let Node::Group(v) = n else { panic!() }; + assert!(matches!(&v[0], Node::Fenced(o, _, c) if o == "(" && c == ")")); + assert!(v.contains(&Node::Text("if ".into()))); + } + + #[test] + fn layout_sizes_and_svg() { + let (svg, (w, h)) = to_svg(r"E = mc^2", 12.0); + let s = String::from_utf8(svg).unwrap(); + assert!(s.starts_with("E<") && s.contains(">2<")); + assert!(w > 30.0 && h > 10.0, "{w} {h}"); + // A fraction is taller than its numerator alone; a big operator with limits taller still. + let (_, (_, h1)) = to_svg("x", 12.0); + let (_, (_, h2)) = to_svg(r"\frac{x}{y}", 12.0); + let (_, (_, h3)) = to_svg(r"\sum_{i=0}^{n}", 12.0); + assert!(h2 > h1 * 1.4 && h3 > h2, "{h1} {h2} {h3}"); + // The SVG parses and rasterises. + let (svg, _) = to_svg(r"\sqrt{a^2+b^2} \ne \pi", 14.0); + assert!(designcraft_images::svg_tree(&svg).is_some()); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 8c4db5f..4502d93 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -374,6 +374,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "-", "cmd:footnote.insert", "cmd:endnote.insert", + "cmd:math.insert", ">Story Direction", "cmd:object.textFrameOptions|Horizontal|{\"vertical\": false}", "cmd:object.textFrameOptions|Vertical|{\"vertical\": true}", diff --git a/docs/parity.md b/docs/parity.md index 9c0f9a7..d924cd0 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -11,7 +11,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia |---|---|---|---|---|---| | P0 | 102 | 100 | 2 | 0 | 99% | | P1 | 97 | 94 | 3 | 0 | 98% | -| P2 | 76 | 43 | 31 | 2 | 77% | +| P2 | 76 | 44 | 31 | 1 | 78% | | Area | Parity | |---|---| @@ -23,7 +23,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Fill, stroke, colour | 99% | | Effects & transparency | 75% | | Placing & links | 91% | -| Type & text frames | 97% | +| Type & text frames | 99% | | Typography | 98% | | Styles | 97% | | Tables | 98% | @@ -213,7 +213,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print) | Type & text frames | Autocorrect / Dynamic spelling | P2 | D | | Type & text frames | Drag & drop text editing | P1 | D | | Type & text frames | Paste text with/without formatting | P0 | D | -| Type & text frames | Math expressions | P2 | M | +| Type & text frames | Math expressions | P2 | D | | Type & text frames | Right-to-left / World-Ready composer | P2 | P | | Typography | Character attributes | P0 | D | | Typography | Underline/Strikethrough options | P1 | D |