mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 10:03:51 +00:00
* Add craft-math: the equation tree with OMML, MathML, LaTeX and UnicodeMath
The math tree, LaTeX, linear-format, symbol and tree-walk modules come
from craft-wordmodel (storytold/craft-libs#963, WordCraft's crates/doc at
07f97737) unchanged, with the run colour (Rgb) and the symbol-font tables
they use. MathML is WordCraft's crates/formats/src/mathml.rs and OMML is
WordCraft's crates/docx/src/read/math.rs and write/math.rs (06c2ae6f /
07f97737), moved onto craft-xml. The HTML-specific MathML tests stay in
WordCraft; the rest moved with the code.
mathml::parse and omml::parse/read_str/write_str are new: string entry
points for callers that have no tree of their own.
* craft-math: read MathLive's and other spellings of LaTeX math commands
\abs{...} (an absolute value), \lvert \rvert \lVert \rVert, \nparallel,
\backprime, \infinity, \cross, \differentialD, \exponentialE and
\imaginaryI (MathLive's d, upright e and i), \emph, \textsf, \hbox,
\mathnormal and \underleftarrow / \underrightarrow /
\underleftrightarrow were unknown and read as literal text. They are read
as equaSHion's typed-input parser reads them (storytold/craft-libs#29).
* craft-math: equaSHion's equation corpus as test data
corpus/equations.tsv, corpus/latex.tsv and the OMML samples of
storytold/craft-libs#29 (7a6ef6b9), unmodified.
* craft-math: corpus round trips, GridCraft's OMML cases, entry points and limits tested; README
The corpus equations go through OMML, MathML and LaTeX and back; the OMML
samples are written back as kept and from the tree. GridCraft's OMML to
LaTeX test cases (crates/xlsx/src/omml.rs) give LaTeX that reads back the
same through the tree. omml::parse and read_str are tested against other
prefixes, the strict namespace, kept OMML, junk, the element cap and deep
nesting.
---------
Co-authored-by: shuff57 <62350898+shuff57@users.noreply.github.com>
174 lines
6.2 KiB
Rust
174 lines
6.2 KiB
Rust
//! An sRGB colour, the one type of colour every Craft document model and this crate share.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// An sRGB colour.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)]
|
|
pub struct Rgb(pub u8, pub u8, pub u8);
|
|
|
|
impl<'de> Deserialize<'de> for Rgb {
|
|
/// Reads what it writes (`[r, g, b]`) and also a string [`Rgb::parse`] reads, so the colours in
|
|
/// `CharProps` / `ParaProps` take the `RRGGBB` that `para.borders` and `table.shading` take (#865).
|
|
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
|
use serde::de::{Error, SeqAccess, Unexpected, Visitor};
|
|
struct V;
|
|
impl<'de> Visitor<'de> for V {
|
|
type Value = Rgb;
|
|
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
f.write_str("`[r, g, b]`, `RRGGBB` or `#RRGGBB`")
|
|
}
|
|
fn visit_str<E: Error>(self, s: &str) -> Result<Rgb, E> {
|
|
Rgb::parse(s).ok_or_else(|| E::invalid_value(Unexpected::Str(s), &self))
|
|
}
|
|
fn visit_seq<A: SeqAccess<'de>>(self, mut a: A) -> Result<Rgb, A::Error> {
|
|
let mut c = [0u8; 3];
|
|
for (i, x) in c.iter_mut().enumerate() {
|
|
*x = a.next_element()?.ok_or_else(|| A::Error::invalid_length(i, &self))?;
|
|
}
|
|
if a.next_element::<serde::de::IgnoredAny>()?.is_some() {
|
|
return Err(A::Error::invalid_length(4, &self));
|
|
}
|
|
Ok(Rgb(c[0], c[1], c[2]))
|
|
}
|
|
}
|
|
d.deserialize_any(V)
|
|
}
|
|
}
|
|
|
|
impl Rgb {
|
|
pub const BLACK: Rgb = Rgb(0, 0, 0);
|
|
pub const WHITE: Rgb = Rgb(255, 255, 255);
|
|
/// `RRGGBB` (no `#`).
|
|
pub fn hex(self) -> String {
|
|
format!("{:02X}{:02X}{:02X}", self.0, self.1, self.2)
|
|
}
|
|
/// Parse `RRGGBB` or `#RRGGBB`.
|
|
pub fn parse(s: &str) -> Option<Rgb> {
|
|
let s = s.trim();
|
|
let s = s.strip_prefix('#').unwrap_or(s);
|
|
if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
|
|
return None;
|
|
}
|
|
let p = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
|
|
Some(Rgb(p(0)?, p(2)?, p(4)?))
|
|
}
|
|
/// This colour with the DrawingML colour modifier `name` (`lumMod`, `lumOff`, `tint`,
|
|
/// `shade`, `satMod`, ECMA-376 §20.1.2.3) at `v` (1.0 = 100%); other names leave it
|
|
/// unchanged.
|
|
pub fn modified(self, name: &str, v: f64) -> Rgb {
|
|
match name {
|
|
"lumMod" => adjust_lum(self, |l| l * v),
|
|
"lumOff" => adjust_lum(self, |l| l + v),
|
|
"tint" => mix(self, 255.0, v),
|
|
"shade" => mix(self, 0.0, v),
|
|
"satMod" => {
|
|
let (h, sat, l) = to_hsl(self);
|
|
from_hsl(h, (sat * v).clamp(0.0, 1.0), l)
|
|
}
|
|
_ => self,
|
|
}
|
|
}
|
|
/// The colour with hue `h` (0..1 of a turn), saturation `s` and lightness `l` (0..1).
|
|
pub fn from_hsl(h: f64, s: f64, l: f64) -> Rgb {
|
|
from_hsl(h, s, l)
|
|
}
|
|
/// This colour with its HSL luminance changed by `f`.
|
|
pub fn with_lum(self, f: impl Fn(f64) -> f64) -> Rgb {
|
|
adjust_lum(self, f)
|
|
}
|
|
pub fn luma(self) -> f32 {
|
|
0.299 * self.0 as f32 + 0.587 * self.1 as f32 + 0.114 * self.2 as f32
|
|
}
|
|
}
|
|
|
|
/// `tint` (towards `to` = 255) and `shade` (towards 0): `v` is the share of the original colour kept.
|
|
fn mix(c: Rgb, to: f64, v: f64) -> Rgb {
|
|
let f = |x: u8| (x as f64 * v + to * (1.0 - v)).round().clamp(0.0, 255.0) as u8;
|
|
Rgb(f(c.0), f(c.1), f(c.2))
|
|
}
|
|
|
|
/// `c` with its HSL luminance changed by `f`.
|
|
fn adjust_lum(c: Rgb, f: impl Fn(f64) -> f64) -> Rgb {
|
|
let (h, s, l) = to_hsl(c);
|
|
from_hsl(h, s, f(l).clamp(0.0, 1.0))
|
|
}
|
|
|
|
fn to_hsl(c: Rgb) -> (f64, f64, f64) {
|
|
let (r, g, b) = (c.0 as f64 / 255.0, c.1 as f64 / 255.0, c.2 as f64 / 255.0);
|
|
let (max, min) = (r.max(g).max(b), r.min(g).min(b));
|
|
let l = (max + min) / 2.0;
|
|
if max == min {
|
|
return (0.0, 0.0, l);
|
|
}
|
|
let d = max - min;
|
|
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
|
|
let h = if max == r {
|
|
(g - b) / d + if g < b { 6.0 } else { 0.0 }
|
|
} else if max == g {
|
|
(b - r) / d + 2.0
|
|
} else {
|
|
(r - g) / d + 4.0
|
|
};
|
|
(h / 6.0, s, l)
|
|
}
|
|
|
|
fn from_hsl(h: f64, s: f64, l: f64) -> Rgb {
|
|
let byte = |v: f64| (v * 255.0).round().clamp(0.0, 255.0) as u8;
|
|
if s == 0.0 {
|
|
return Rgb(byte(l), byte(l), byte(l));
|
|
}
|
|
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
|
|
let p = 2.0 * l - q;
|
|
let channel = |t: f64| {
|
|
let t = t.rem_euclid(1.0);
|
|
let v = if t < 1.0 / 6.0 {
|
|
p + (q - p) * 6.0 * t
|
|
} else if t < 0.5 {
|
|
q
|
|
} else if t < 2.0 / 3.0 {
|
|
p + (q - p) * (2.0 / 3.0 - t) * 6.0
|
|
} else {
|
|
p
|
|
};
|
|
byte(v)
|
|
};
|
|
Rgb(channel(h + 1.0 / 3.0), channel(h), channel(h - 1.0 / 3.0))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn rgb_hex() {
|
|
assert_eq!(Rgb(255, 0, 16).hex(), "FF0010");
|
|
assert_eq!(Rgb::parse("#FF0010"), Some(Rgb(255, 0, 16)));
|
|
assert_eq!(Rgb::parse("zzzzzz"), None);
|
|
assert_eq!(Rgb::parse("é1234"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn rgb_parse_rejects_non_hex() {
|
|
assert_eq!(Rgb::parse("+1+2+3"), None);
|
|
assert_eq!(Rgb::parse("-1-2-3"), None);
|
|
assert_eq!(Rgb::parse("##ff0000"), None);
|
|
assert_eq!(Rgb::parse("ff00 0"), None);
|
|
assert_eq!(Rgb::parse("#ff0000"), Some(Rgb(255, 0, 0)));
|
|
assert_eq!(Rgb::parse("ff0000"), Some(Rgb(255, 0, 0)));
|
|
}
|
|
|
|
/// #865: an [`Rgb`] reads `RRGGBB` / `#RRGGBB` as well as the `[r, g, b]` it still writes.
|
|
#[test]
|
|
fn rgb_reads_hex_and_keeps_its_serialized_form() {
|
|
let read = |j: &str| serde_json::from_str::<Rgb>(j);
|
|
let ink = Rgb(0x1B, 0x16, 0x11);
|
|
assert_eq!(serde_json::to_string(&ink).unwrap(), "[27,22,17]", "written as before");
|
|
for j in ["[27,22,17]", r#""1B1611""#, r##""#1b1611""##] {
|
|
assert_eq!(read(j).unwrap(), ink, "{j}");
|
|
}
|
|
for bad in ["[27,22]", "[27,22,17,0]", "[27,22,300]", "[-1,0,0]", r#""auto""#, r#""1B161""#, r#""""#, "7", "null", r#"{"r":1}"#] {
|
|
assert!(read(bad).is_err(), "{bad}");
|
|
}
|
|
}
|
|
}
|