T: math expressions — LaTeX subset typeset to SVG, inline or framed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
77fc006b02
commit
d37d2e5237
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<CommandSpec> {
|
||||
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<Value> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
pub mod cmd;
|
||||
pub mod links;
|
||||
pub mod math;
|
||||
pub mod recovery;
|
||||
pub mod sample;
|
||||
pub mod script;
|
||||
|
|
|
|||
|
|
@ -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<Elem>,
|
||||
}
|
||||
|
||||
impl MBox {
|
||||
fn shifted(mut self, dx: f64, dy: f64) -> Vec<Elem> {
|
||||
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<Node>),
|
||||
Frac(Box<Node>, Box<Node>),
|
||||
Sqrt(Option<Box<Node>>, Box<Node>),
|
||||
Scripts {
|
||||
base: Box<Node>,
|
||||
sup: Option<Box<Node>>,
|
||||
sub: Option<Box<Node>>,
|
||||
},
|
||||
Text(String),
|
||||
Space(f64),
|
||||
/// A big operator (∑ ∏ ∫): drawn larger, limits as scripts.
|
||||
Big(String),
|
||||
/// `\left( … \right)`: delimiters sized to the content.
|
||||
Fenced(String, Box<Node>, 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<char> {
|
||||
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<Node> {
|
||||
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<char>) -> Vec<Node> {
|
||||
let mut out: Vec<Node> = 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<Node> {
|
||||
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<Node> {
|
||||
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<char> = 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<FontFace>,
|
||||
italic: std::sync::Arc<FontFace>,
|
||||
fallback: std::sync::Arc<FontFace>,
|
||||
}
|
||||
|
||||
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<Class> = 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<Class>| 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<u8>, (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, "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w:.2}pt\" height=\"{h:.2}pt\" viewBox=\"0 0 {w:.3} {h:.3}\">");
|
||||
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,
|
||||
"<text x=\"{:.3}\" y=\"{:.3}\" font-family=\"'{fam}'\" font-size=\"{size:.3}\"{}>{}</text>",
|
||||
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, "<rect x=\"{:.3}\" y=\"{:.3}\" width=\"{rw:.3}\" height=\"{rh:.3}\"/>", pad + x, base + y);
|
||||
}
|
||||
Elem::Line { pts, w: lw } => {
|
||||
let d: Vec<String> = pts.iter().map(|(x, y)| format!("{:.3},{:.3}", pad + x, base + y)).collect();
|
||||
let _ = write!(
|
||||
svg,
|
||||
"<polyline points=\"{}\" fill=\"none\" stroke=\"black\" stroke-width=\"{lw:.3}\" stroke-linejoin=\"round\"/>",
|
||||
d.join(" ")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
svg.push_str("</svg>");
|
||||
(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("<svg") && s.contains("font-style=\"italic\">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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue