diff --git a/Cargo.lock b/Cargo.lock index 3a66892d8..43093fd2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1065,6 +1065,8 @@ dependencies = [ "base64 0.23.1", "chrono", "craft-cfb", + "craft-drawingml", + "craft-math", "craft-offcrypto", "craft-opc", "craft-wordmodel", @@ -1657,6 +1659,16 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-pptx" +version = "0.1.0" +dependencies = [ + "craft-drawingml", + "kurbo 0.13.1", + "serde", + "serde_json", +] + [[package]] name = "craft-prc" version = "0.1.0" @@ -1711,6 +1723,7 @@ dependencies = [ "encoding_rs", "flate2", "proptest", + "rayon", "serde", "thiserror 2.0.21", ] @@ -1765,7 +1778,9 @@ name = "craft-rtf" version = "0.1.0" dependencies = [ "craft-codepage", + "craft-mime", "encoding_rs", + "proptest", ] [[package]] @@ -1872,6 +1887,7 @@ dependencies = [ "craft-xml", "proptest", "serde", + "serde_json", ] [[package]] @@ -1991,6 +2007,7 @@ dependencies = [ name = "craft-webp" version = "0.1.0" dependencies = [ + "craft-video", "image", "thiserror 2.0.21", ] @@ -2018,6 +2035,7 @@ name = "craft-wordmodel" version = "0.1.0" dependencies = [ "base64 0.23.1", + "craft-math", "log", "proptest", "serde", diff --git a/Cargo.toml b/Cargo.toml index 30072815d..c9c524fe5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,6 +87,7 @@ craft-metafile = { path = "crates/metafile" } craft-offcrypto = { path = "crates/offcrypto" } craft-opc = { path = "crates/opc" } craft-ora = { path = "crates/ora" } +craft-pptx = { path = "crates/pptx" } craft-penpot = { path = "crates/penpot" } craft-pcraft-format = { path = "crates/pcraft-format" } craft-plotter = { path = "crates/plotter" } diff --git a/README.md b/README.md index 6c6f169f5..5df977463 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,9 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " Math { - Math { nodes: merge_runs(arg(e, 0)), omml: e.to_xml(), jc, linear: false } -} - -/// A colour every math run shares, taken off the runs: it is the equation's own colour, as -/// [`write_equation`](crate::write::math::write_equation) spreads it (#1467). The source OMML -/// is kept: the writer replaces it only when the colour changes. -pub fn lift_color(math: &mut Math) -> Option { - let (mut shared, mut uniform) = (None, true); - craft_wordmodel::math_tree::each_run_mut(&mut math.nodes, &mut |r| match shared { - None => shared = Some(r.color), - Some(c) => uniform &= c == r.color, - }); - let c = shared.flatten().filter(|_| uniform)?; - craft_wordmodel::math_tree::each_run_mut(&mut math.nodes, &mut |r| r.color = None); - Some(c) -} - -/// `m:oMathParaPr/m:jc`. -pub fn para_jc(para: &El) -> MathJc { - match para.child("m:oMathParaPr").and_then(|p| p.child("m:jc")).and_then(|j| j.attr("m:val")) { - Some("left") => MathJc::Left, - Some("right") => MathJc::Right, - Some("center") => MathJc::Center, - _ => MathJc::CenterGroup, - } -} - -/// The settings part's `m:mathPr`: how display equations wrap. -pub fn read_math_pr(e: &El) -> MathProps { - let mut m = MathProps::default(); - let pr = Some(e); - m.brk_bin = match val(pr, "m:brkBin") { - Some("after") => BrkBin::After, - Some("repeat") => BrkBin::Repeat, - _ => BrkBin::Before, - }; - m.brk_bin_sub = match val(pr, "m:brkBinSub") { - Some("-+") => BrkBinSub::MinusPlus, - Some("+-") => BrkBinSub::PlusMinus, - _ => BrkBinSub::MinusMinus, - }; - if let Some(v) = e.child("m:wrapIndent").and_then(|c| crate::units::tw(c, "m:val")).filter(|v| v.is_finite() && *v >= 0.0) { - m.wrap_indent = v.min(1584.0); - } - m.wrap_right = prop(pr, "m:wrapRight", false); - m -} - -/// OMML on/off property: absent `m:val` = on. -fn on(e: &El) -> bool { - match e.attr("m:val") { - None => true, - Some(v) => !matches!(v.trim(), "0" | "false" | "off" | "False" | "FALSE"), - } -} - -/// An on/off property `name` in the properties element `pr` (absent = `default`). -fn prop(pr: Option<&El>, name: &str, default: bool) -> bool { - pr.and_then(|p| p.child(name)).map(on).unwrap_or(default) -} - -/// A character property (`m:chr`, `m:begChr`…): `None` when absent, `Some(None)` when empty. -fn chr(pr: Option<&El>, name: &str) -> Option> { - let c = pr?.child(name)?; - match c.attr("m:val") { - Some(v) => Some(v.chars().next()), - None => Some(None), - } -} - -fn val<'a>(pr: Option<&'a El>, name: &str) -> Option<&'a str> { - pr.and_then(|p| p.child(name)).and_then(|c| c.attr("m:val")) -} - -/// The argument held by child `name` of `e`. -fn sub_arg(e: &El, name: &str, depth: usize) -> Arg { - e.child(name).map(|c| arg(c, depth + 1)).unwrap_or_default() -} - -/// Read the math content of an element (`m:oMath`, `m:e`, `m:num`…). -fn arg(e: &El, depth: usize) -> Arg { - let mut out = Vec::new(); - if depth > MAX_DEPTH { - return out; - } - for k in e.els() { - node(k, depth, &mut out); +/// This reader's tree as `craft-xml`'s, which `craft_math::omml` reads. Both use the canonical +/// prefixes (`m:`, `w:`, `mc:`). +// shortcut: copies the equation's subtree; goes when the reader reads craft_xml::El directly. +fn to_craft(e: &El) -> craft_xml::El { + let mut out = craft_xml::El { name: e.name.clone(), attrs: e.attrs.clone(), children: Vec::with_capacity(e.kids.len()) }; + for k in &e.kids { + out.children.push(match k { + Node::El(c) => craft_xml::Node::El(to_craft(c)), + Node::Text(t) => craft_xml::Node::Text(t.clone()), + }); } out } -fn node(k: &El, depth: usize, out: &mut Arg) { - let d = depth + 1; - match k.name.as_str() { - "m:r" => { - if let Some(r) = run(k) { - out.push(MNode::Run(r)); - } - } - // Word puts ordinary runs in math zones for normal text in some files. - "w:r" => { - let text = run_text(k); - if !text.is_empty() { - let mut r = MRun { text, nor: true, sty: Some(MSty::Plain), ..Default::default() }; - wrpr(k.child("w:rPr"), &mut r); - out.push(MNode::Run(r)); - } - } - "m:f" => { - let pr = k.child("m:fPr"); - let kind = match val(pr, "m:type") { - Some("skw") => FracKind::Skewed, - Some("lin") => FracKind::Linear, - Some("noBar") => FracKind::NoBar, - _ => FracKind::Bar, - }; - out.push(MNode::Frac { kind, num: sub_arg(k, "m:num", d), den: sub_arg(k, "m:den", d) }); - } - "m:sSup" => out.push(MNode::Script { kind: ScriptKind::Sup, base: sub_arg(k, "m:e", d), sub: Vec::new(), sup: sub_arg(k, "m:sup", d) }), - "m:sSub" => out.push(MNode::Script { kind: ScriptKind::Sub, base: sub_arg(k, "m:e", d), sub: sub_arg(k, "m:sub", d), sup: Vec::new() }), - "m:sSubSup" => { - out.push(MNode::Script { kind: ScriptKind::SubSup, base: sub_arg(k, "m:e", d), sub: sub_arg(k, "m:sub", d), sup: sub_arg(k, "m:sup", d) }) - } - "m:sPre" => { - out.push(MNode::Script { kind: ScriptKind::Pre, base: sub_arg(k, "m:e", d), sub: sub_arg(k, "m:sub", d), sup: sub_arg(k, "m:sup", d) }) - } - "m:rad" => { - let pr = k.child("m:radPr"); - out.push(MNode::Rad { deg: sub_arg(k, "m:deg", d), deg_hide: prop(pr, "m:degHide", false), e: sub_arg(k, "m:e", d) }); - } - "m:nary" => { - let pr = k.child("m:naryPr"); - let chr = chr(pr, "m:chr").map(|c| c.unwrap_or('∫')).unwrap_or('∫'); - let lim_loc = match val(pr, "m:limLoc") { - Some("undOvr") => Some(LimLoc::UndOvr), - Some("subSup") => Some(LimLoc::SubSup), - _ => None, - }; - out.push(MNode::Nary { - chr, - lim_loc, - grow: prop(pr, "m:grow", false), - sub_hide: prop(pr, "m:subHide", false), - sup_hide: prop(pr, "m:supHide", false), - sub: sub_arg(k, "m:sub", d), - sup: sub_arg(k, "m:sup", d), - e: sub_arg(k, "m:e", d), - }); - } - "m:d" => { - let pr = k.child("m:dPr"); - let beg = chr(pr, "m:begChr").unwrap_or(Some('(')); - let end = chr(pr, "m:endChr").unwrap_or(Some(')')); - let sep = chr(pr, "m:sepChr").unwrap_or(Some('|')); - let elems: Vec = k.children("m:e").map(|e| arg(e, d)).collect(); - out.push(MNode::Delim { - beg, - end, - sep, - grow: prop(pr, "m:grow", true), - shp_match: val(pr, "m:shp") == Some("match"), - elems: if elems.is_empty() { vec![Vec::new()] } else { elems }, - }); - } - "m:func" => out.push(MNode::Func { name: sub_arg(k, "m:fName", d), e: sub_arg(k, "m:e", d) }), - "m:limLow" => out.push(MNode::Lim { upper: false, e: sub_arg(k, "m:e", d), lim: sub_arg(k, "m:lim", d) }), - "m:limUpp" => out.push(MNode::Lim { upper: true, e: sub_arg(k, "m:e", d), lim: sub_arg(k, "m:lim", d) }), - "m:acc" => { - let c = chr(k.child("m:accPr"), "m:chr").map(|c| c.unwrap_or('\u{302}')).unwrap_or('\u{302}'); - out.push(MNode::Acc { chr: c, e: sub_arg(k, "m:e", d) }); - } - "m:bar" => out.push(MNode::Bar { top: val(k.child("m:barPr"), "m:pos") == Some("top"), e: sub_arg(k, "m:e", d) }), - "m:borderBox" => { - let pr = k.child("m:borderBoxPr"); - out.push(MNode::BorderBox { - hide: [prop(pr, "m:hideTop", false), prop(pr, "m:hideBot", false), prop(pr, "m:hideLeft", false), prop(pr, "m:hideRight", false)], - strike: [ - prop(pr, "m:strikeH", false), - prop(pr, "m:strikeV", false), - prop(pr, "m:strikeBLTR", false), - prop(pr, "m:strikeTLBR", false), - ], - e: sub_arg(k, "m:e", d), - }); - } - "m:box" => out.push(MNode::Boxed { e: sub_arg(k, "m:e", d) }), - "m:groupChr" => { - let pr = k.child("m:groupChrPr"); - let c = chr(pr, "m:chr").map(|c| c.unwrap_or('\u{23DF}')).unwrap_or('\u{23DF}'); - out.push(MNode::GroupChr { chr: c, top: val(pr, "m:pos") == Some("top"), e: sub_arg(k, "m:e", d) }); - } - "m:eqArr" => out.push(MNode::EqArr { rows: k.children("m:e").map(|e| arg(e, d)).collect() }), - "m:m" => { - let mut col_jc = Vec::new(); - if let Some(mcs) = k.child("m:mPr").and_then(|p| p.child("m:mcs")) { - for mc in mcs.children("m:mc") { - let pr = mc.child("m:mcPr"); - let jc = match val(pr, "m:mcJc") { - Some("left") => ColJc::Left, - Some("right") => ColJc::Right, - _ => ColJc::Center, - }; - let count = val(pr, "m:count").and_then(|c| c.trim().parse::().ok()).unwrap_or(1).clamp(1, 64); - for _ in 0..count { - if col_jc.len() < 64 { - col_jc.push(jc); - } - } - } - } - let rows = k.children("m:mr").take(1000).map(|r| r.children("m:e").take(64).map(|e| arg(e, d)).collect()).collect(); - out.push(MNode::Matrix { rows, col_jc }); - } - "m:phant" => { - let pr = k.child("m:phantPr"); - out.push(MNode::Phant { - show: prop(pr, "m:show", true), - zero_wid: prop(pr, "m:zeroWid", false), - zero_asc: prop(pr, "m:zeroAsc", false), - zero_desc: prop(pr, "m:zeroDesc", false), - e: sub_arg(k, "m:e", d), - }); - } - // Tracked insertions show; deletions don't. Hyperlinks, simple fields and direction - // wrappers (EG_PContentMath, §22.1.2.77) keep their runs (#1273). - "w:ins" | "w:moveTo" | "w:smartTag" | "w:customXml" | "w:hyperlink" | "w:fldSimple" | "w:dir" | "w:bdo" | "m:oMath" if depth <= MAX_DEPTH => { - for c in k.els() { - node(c, d, out); - } - } - // Markup compatibility: the math choice (we read math), else the fallback. - "mc:AlternateContent" if depth <= MAX_DEPTH => { - if let Some(c) = k.child("mc:Choice").or_else(|| k.child("mc:Fallback")) { - for c in c.els() { - node(c, d, out); - } - } - } - "w:sdt" => { - if let Some(c) = k.child("w:sdtContent") { - for c in c.els() { - node(c, d, out); - } - } - } - _ => {} - } +/// An `m:oMath` element as an equation (structure plus its source XML). +pub fn read_omath(e: &El, jc: MathJc) -> Math { + craft_math::omml::read_omath(&to_craft(e), jc) } -/// `m:t` (and `w:t`) text of a run, with symbol characters (`w:sym`) and special hyphens. -/// Text in a symbol font (Symbol, Wingdings…) becomes its Unicode equivalent: the math font has -/// no glyphs at those private-use codes (#1273). -fn run_text(r: &El) -> String { - let font = r.child("w:rPr").and_then(|p| p.child("w:rFonts")).and_then(|f| f.attr("w:ascii").or_else(|| f.attr("w:hAnsi"))).unwrap_or(""); - let mut s = String::new(); - for c in r.els() { - match c.name.as_str() { - "m:t" | "w:t" => s.push_str(&symbol_fonts::map_text(font, &c.text())), - "w:tab" => s.push('\t'), - "w:noBreakHyphen" => s.push('\u{2011}'), - "w:sym" => { - let ch = c.attr("w:char").and_then(|h| u32::from_str_radix(h.trim(), 16).ok()).and_then(char::from_u32).filter(|c| *c as u32 >= 0x20); - if let Some(ch) = ch { - let f = c.attr("w:font").unwrap_or(font); - s.push(symbol_fonts::symbol_char(f, ch).unwrap_or(ch)); - } - } - _ => {} - } - } - s +/// The colour every run of `math` shares, taken off the runs (the run around the equation +/// carries it). +pub fn lift_color(math: &mut Math) -> Option { + craft_math::omml::lift_color(math) } -fn run(r: &El) -> Option { - let text = run_text(r); - if text.is_empty() { - return None; - } - let mut out = MRun { text, ..Default::default() }; - if let Some(pr) = r.child("m:rPr") { - out.sty = match pr.child("m:sty").and_then(|s| s.attr("m:val")) { - Some("p") => Some(MSty::Plain), - Some("b") => Some(MSty::Bold), - Some("i") => Some(MSty::Italic), - Some("bi") => Some(MSty::BoldItalic), - _ => None, - }; - out.scr = match pr.child("m:scr").and_then(|s| s.attr("m:val")) { - Some("script") => MScr::Script, - Some("fraktur") => MScr::Fraktur, - Some("double-struck") => MScr::DoubleStruck, - Some("sans-serif") => MScr::SansSerif, - Some("monospace") => MScr::Monospace, - _ => MScr::Roman, - }; - out.nor = pr.child("m:nor").is_some_and(on); - out.lit = pr.child("m:lit").is_some_and(on); - if let Some(b) = pr.child("m:brk") { - let aln = b.attr("m:alnAt").and_then(|v| v.trim().parse::().ok()).unwrap_or(0); - out.brk = Some(aln.min(255) as u8); - } - } - wrpr(r.child("w:rPr"), &mut out); - Some(out) +/// The justification an `m:oMathPara` gives its equations. +pub fn para_jc(para: &El) -> MathJc { + craft_math::omml::para_jc(&to_craft(para)) } -/// The run formatting a math run takes from `w:rPr`: size, colour, normal-text font and weight. -fn wrpr(pr: Option<&El>, out: &mut MRun) { - let Some(pr) = pr else { return }; - if let Some(sz) = pr.child("w:sz").and_then(|s| s.attr("w:val")).and_then(|v| v.trim().parse::().ok()) { - let pt = sz / 2.0; - if pt.is_finite() && (1.0..=1638.0).contains(&pt) { - out.size = Some(pt); - } - } - if let Some(c) = pr.child("w:color").and_then(|c| c.attr("w:val")).and_then(Rgb::parse) { - out.color = Some(c); - } - if out.nor { - // Symbol-font text was mapped to Unicode: it takes the text font. - if let Some(f) = - pr.child("w:rFonts").and_then(|f| f.attr("w:ascii").or_else(|| f.attr("w:hAnsi"))).filter(|f| !symbol_fonts::is_symbol_font(f)) - { - out.font = Some(f.to_string()); - } - let bold = pr.child("w:b").is_some_and(crate::units::on_off); - let italic = pr.child("w:i").is_some_and(crate::units::on_off); - if bold || italic { - out.sty = Some(match (bold, italic) { - (true, true) => MSty::BoldItalic, - (true, false) => MSty::Bold, - _ => MSty::Italic, - }); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn omath(inner: &str) -> El { - let x = format!( - r#"{inner}"# - ); - crate::xml::parse(x.as_bytes()).unwrap() - } - - #[test] - fn reads_fraction_radical_and_scripts() { - let e = omath( - r#"x=-b±b2-4ac2a"#, - ); - let m = read_omath(&e, MathJc::CenterGroup); - assert_eq!(m.nodes.len(), 2); - let MNode::Frac { num, den, kind } = &m.nodes[1] else { panic!("{:#?}", m.nodes) }; - assert_eq!(*kind, FracKind::Bar); - assert_eq!(den, &vec![MNode::Run(MRun::new("2a"))]); - let MNode::Rad { deg_hide, e, .. } = &num[1] else { panic!() }; - assert!(*deg_hide); - assert!(matches!(e[0], MNode::Script { kind: ScriptKind::Sup, .. })); - assert_eq!(craft_wordmodel::math::to_linear(&m.nodes), "x=(-b±√(b^2-4ac))/2a"); - assert!(m.omml.starts_with("")); - } - - #[test] - fn reads_nary_delims_matrix_and_defaults() { - let e = omath( - r#"iiab"#, - ); - let m = read_omath(&e, MathJc::CenterGroup); - let MNode::Nary { chr, lim_loc, .. } = &m.nodes[0] else { panic!() }; - assert_eq!((*chr, *lim_loc), ('∑', Some(LimLoc::UndOvr))); - let MNode::Delim { beg, end, sep, elems, .. } = &m.nodes[1] else { panic!() }; - assert_eq!((*beg, *end, *sep, elems.len()), (Some('['), None, Some('|'), 2)); - assert!(matches!(m.nodes[2], MNode::Nary { chr: '∫', .. })); - assert!(matches!(&m.nodes[3], MNode::Matrix { rows, .. } if rows.len() == 1 && rows[0].len() == 2)); - } - - /// Every run's text in the tree, in reading order. - fn texts(m: &mut Math) -> Vec { - let mut out = Vec::new(); - craft_wordmodel::math_tree::each_run_mut(&mut m.nodes, &mut |r| out.push(r.text.clone())); - out - } - - fn t(s: &str) -> String { - format!("{s}") - } - - /// #1273: one equation using every Office Math element of ECMA-376 §22.1 (each argument - /// holding a distinct marker) keeps every argument's text: nothing is silently dropped. - #[test] - fn every_omml_element_keeps_its_content() { - let parts = [ - format!("{}", t("A1")), - format!("{}", t("B1")), - format!("{}", t("C1")), - format!("{}", t("D1")), - format!("{}{}", t("E1"), t("E2")), - format!("{}{}", t("F1"), t("F2")), - format!("{}{}", t("G1"), t("G2")), - format!("{}{}", t("H1"), t("H2")), - format!("{}", t("I1")), - format!("{}{}", t("J1"), t("J2")), - format!("{}{}", t("K1"), t("K2")), - format!("{}{}{}", t("L1"), t("L2"), t("L3")), - format!( - "{}{}{}", - t("M1"), - t("M2"), - t("M3") - ), - format!("{}", t("N1")), - format!("{}{}", t("O1"), t("O2")), - format!("{}{}{}", t("P1"), t("P2"), t("P3")), - format!("{}{}", t("Q1"), t("Q2")), - format!("{}{}{}", t("R1"), t("R2"), t("R3")), - format!("{}{}", t("S1"), t("S2")), - // Run-level wrappers allowed in math (EG_PContentMath): hyperlinks, simple fields, - // tracked insertions, content controls, markup compatibility. - format!("{}", t("T1")), - format!("{}", t("T2")), - format!("{}", t("T3")), - format!("{}", t("T4")), - format!( - "{}picture", - t("T5") - ), - "T6".to_string(), - ]; - let x = format!( - r#"{}"#, - parts.concat() - ); - let e = crate::xml::parse(x.as_bytes()).unwrap(); - let mut m = read_omath(&e, MathJc::CenterGroup); - let got = texts(&mut m).concat(); - let markers = [ - "A1", "B1", "C1", "D1", "E1", "E2", "F1", "F2", "G1", "G2", "H1", "H2", "I1", "J1", "J2", "K1", "K2", "L1", "L2", "L3", "M1", "M2", "M3", - "N1", "O1", "O2", "P1", "P2", "P3", "Q1", "Q2", "R1", "R2", "R3", "S1", "S2", "T1", "T2", "T3", "T4", "T5", "T6", - ]; - for k in markers { - assert!(got.contains(k), "{k} dropped from {got}"); - } - assert!(!got.contains("picture"), "the math choice wins over the fallback: {got}"); - } - - /// #1273: deleted text stays out; symbol characters and symbol-font text become Unicode. - #[test] - fn symbols_and_deletions_in_math() { - let e = omath( - r#"xgone"#, - ); - let mut m = read_omath(&e, MathJc::CenterGroup); - let got = texts(&mut m).concat(); - assert_eq!(got, "xαβ\u{2011}γ"); - let mut font = None; - craft_wordmodel::math_tree::each_run_mut(&mut m.nodes, &mut |r| { - if r.nor { - font = Some(r.font.clone()); - } - }); - assert_eq!(font, Some(None), "mapped symbol text takes the text font"); - } - - /// Hostile nesting through the wrappers is bounded (no stack overflow, no panic). - #[test] - fn deeply_nested_wrappers_are_bounded() { - // Deeper than the math limit, within the XML parser's own depth limit. - let n = 120; - let inner = format!("{}{}{}", "".repeat(n), t("deep"), "".repeat(n)); - let e = omath(&inner); - let _ = read_omath(&e, MathJc::CenterGroup); - let inner = format!( - "{}{}{}", - "".repeat(n), - t("deep"), - "".repeat(n) - ); - let e = omath(&inner); - let _ = read_omath(&e, MathJc::CenterGroup); - } +/// `m:mathPr` in the settings part. +pub fn read_math_pr(e: &El) -> MathProps { + craft_math::omml::read_math_pr(&to_craft(e)) } diff --git a/crates/docx/src/write/math.rs b/crates/docx/src/write/math.rs index 571c5978f..d22d548ef 100644 --- a/crates/docx/src/write/math.rs +++ b/crates/docx/src/write/math.rs @@ -1,486 +1,25 @@ -//! [`craft_wordmodel::math`] tree → Office Math (OMML). +//! Office Math (OMML) written with `craft_math::omml`. -use craft_wordmodel::math::{ - Arg, BrkBin, BrkBinSub, ColJc, FracKind, LimLoc, MAX_DEPTH, MAX_MATRIX_COLS, MAX_MATRIX_ROWS, MNode, MRun, MScr, MSty, Math, MathJc, MathProps, - ScriptKind, parse_linear, -}; +use craft_wordmodel::math::{Math, MathProps}; use craft_wordmodel::props::Rgb; use crate::xml::W; /// Write an equation: `m:oMath`, inside `m:oMathPara` for a display equation. The source OMML /// is written back unchanged when the equation came from a file. `color` (the equation's run -/// colour) goes on every math run without its own: OMML has no equation-level colour (#1467). +/// colour) goes on every math run without its own: OMML has no equation-level colour. pub fn write_equation(w: &mut W, linear: &str, display: bool, math: &Math, color: Option) { - let tinted; - let math = if color == omml_color(&math.omml) { - math - } else { - // The kept source XML has the old colour baked in: write from the nodes instead. - let mut m = math.clone(); - if m.nodes.is_empty() { - m.nodes = parse_linear(linear); - } - if let Some(c) = color { - craft_wordmodel::math_tree::each_run_mut(&mut m.nodes, &mut |r| { - r.color.get_or_insert(c); - }); - } - m.omml.clear(); - tinted = m; - &tinted - }; - if display { - w.open("m:oMathPara", &[]); - let jc = match math.jc { - MathJc::CenterGroup => None, - MathJc::Center => Some("center"), - MathJc::Left => Some("left"), - MathJc::Right => Some("right"), - }; - if let Some(jc) = jc { - w.open("m:oMathParaPr", &[]); - w.empty("m:jc", &[("m:val", jc)]); - w.close("m:oMathParaPr"); - } - } - if !math.omml.is_empty() && math.omml.starts_with(" Option { - if !display { - return None; - } - let [MNode::EqArr { rows }] = math.nodes.as_slice() else { return None }; - let mut out = rows.clone(); - let mut changed = false; - for row in out.iter_mut() { - if !craft_wordmodel::math::has_number_mark(row) { - continue; - } - *counter = counter.saturating_add(1); - // Nothing after the `#`: an automatic number. - let mut after = false; - let mut empty = true; - for n in row.iter() { - match n { - MNode::Run(r) if !after => { - if let Some(k) = r.text.find('#') { - after = true; - if !r.text.get(k + 1..).unwrap_or("").is_empty() { - empty = false; - } - } - } - _ if after => empty = false, - _ => {} - } - } - if empty { - let n = vec![MNode::Run(MRun::new(counter.to_string()))]; - row.push(MNode::Delim { beg: Some('('), end: Some(')'), sep: None, grow: true, shp_match: false, elems: vec![n] }); - changed = true; - } - } - changed.then(|| Math { nodes: vec![MNode::EqArr { rows: out }], omml: String::new(), ..math.clone() }) + craft_math::omml::resolve_numbers(math, display, counter) } -/// The equation colour the kept source OMML carries, as the reader lifts it. -fn omml_color(omml: &str) -> Option { - if omml.is_empty() { - return None; - } - let root = crate::xml::parse(wrap_ns(omml).as_bytes()).ok()?; - let e = root.child("m:oMath")?; - crate::read::math::lift_color(&mut crate::read::math::read_omath(e, MathJc::default())) -} - -/// The kept XML with namespace declarations, for validating it on its own. -fn wrap_ns(xml: &str) -> String { - let decl: String = crate::xml::body_ns().iter().filter(|(k, _)| k.starts_with("xmlns:")).map(|(k, v)| format!(" {k}=\"{v}\"")).collect(); - format!("{xml}") -} - -/// The settings part's `m:mathPr` (CT_MathPr order; `m:wrapIndent` and `m:wrapRight` are a choice). +/// `m:mathPr`. pub fn math_pr(w: &mut W, m: &MathProps) { - w.open("m:mathPr", &[]); - let bin = match m.brk_bin { - BrkBin::Before => "before", - BrkBin::After => "after", - BrkBin::Repeat => "repeat", - }; - val(w, "m:brkBin", bin); - let sub = match m.brk_bin_sub { - BrkBinSub::MinusMinus => "--", - BrkBinSub::MinusPlus => "-+", - BrkBinSub::PlusMinus => "+-", - }; - val(w, "m:brkBinSub", sub); - if m.wrap_right { - w.empty("m:wrapRight", &[]); - } else { - let indent = if m.wrap_indent.is_finite() { m.wrap_indent.clamp(0.0, 1584.0) } else { craft_wordmodel::math::DEFAULT_WRAP_INDENT }; - val(w, "m:wrapIndent", &crate::units::twips(indent)); - } - w.close("m:mathPr"); -} - -fn val(w: &mut W, name: &str, v: &str) { - w.empty(name, &[("m:val", v)]); -} - -fn arg(w: &mut W, name: &str, a: &[MNode], depth: usize) { - if a.is_empty() { - w.empty(name, &[]); - return; - } - w.open(name, &[]); - arg_inner(w, a, depth + 1); - w.close(name); -} - -fn arg_inner(w: &mut W, a: &[MNode], depth: usize) { - if depth > MAX_DEPTH { - return; - } - for n in a { - node(w, n, depth); - } -} - -fn ctrl(w: &mut W) { - w.open("m:ctrlPr", &[]); - w.open("w:rPr", &[]); - w.empty("w:rFonts", &[("w:ascii", "Cambria Math"), ("w:hAnsi", "Cambria Math")]); - w.empty("w:i", &[]); - w.close("w:rPr"); - w.close("m:ctrlPr"); -} - -fn char_prop(w: &mut W, name: &str, c: Option) { - let s = c.map(|c| c.to_string()).unwrap_or_default(); - val(w, name, &s); -} - -fn write_matrix(w: &mut W, n: &MNode, depth: usize) { - let MNode::Matrix { rows, col_jc } = n else { return }; - // Every row is padded to the widest one, so a ragged matrix is clamped to the reader's - // caps rather than written as rows × widest cells (#3643). - let rows = rows.get(..rows.len().min(MAX_MATRIX_ROWS)).unwrap_or_default(); - let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0).clamp(1, MAX_MATRIX_COLS); - w.open("m:m", &[]); - w.open("m:mPr", &[]); - w.open("m:mcs", &[]); - for c in 0..cols { - let jc = match col_jc.get(c).copied().unwrap_or_default() { - ColJc::Center => "center", - ColJc::Left => "left", - ColJc::Right => "right", - }; - w.open("m:mc", &[]); - w.open("m:mcPr", &[]); - val(w, "m:count", "1"); - val(w, "m:mcJc", jc); - w.close("m:mcPr"); - w.close("m:mc"); - } - w.close("m:mcs"); - ctrl(w); - w.close("m:mPr"); - for row in rows { - w.open("m:mr", &[]); - for c in 0..cols { - arg(w, "m:e", row.get(c).map(|v| v.as_slice()).unwrap_or(&[]), depth); - } - w.close("m:mr"); - } - w.close("m:m"); -} - -fn write_nary(w: &mut W, n: &MNode, depth: usize) { - let MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } = n else { return }; - w.open("m:nary", &[]); - w.open("m:naryPr", &[]); - if *chr != '∫' { - char_prop(w, "m:chr", Some(*chr)); - } - match lim_loc { - Some(LimLoc::UndOvr) => val(w, "m:limLoc", "undOvr"), - Some(LimLoc::SubSup) => val(w, "m:limLoc", "subSup"), - None => {} - } - if *grow { - val(w, "m:grow", "1"); - } - if *sub_hide { - val(w, "m:subHide", "1"); - } - if *sup_hide { - val(w, "m:supHide", "1"); - } - ctrl(w); - w.close("m:naryPr"); - arg(w, "m:sub", sub, depth); - arg(w, "m:sup", sup, depth); - arg(w, "m:e", e, depth); - w.close("m:nary"); -} - -fn node(w: &mut W, n: &MNode, depth: usize) { - match n { - MNode::Run(r) => run(w, r), - MNode::Frac { kind, num, den } => { - w.open("m:f", &[]); - w.open("m:fPr", &[]); - match kind { - FracKind::Bar => {} - FracKind::Skewed => val(w, "m:type", "skw"), - FracKind::Linear => val(w, "m:type", "lin"), - FracKind::NoBar => val(w, "m:type", "noBar"), - } - ctrl(w); - w.close("m:fPr"); - arg(w, "m:num", num, depth); - arg(w, "m:den", den, depth); - w.close("m:f"); - } - MNode::Script { kind, base, sub, sup } => { - let (el, pr) = match kind { - ScriptKind::Sup => ("m:sSup", "m:sSupPr"), - ScriptKind::Sub => ("m:sSub", "m:sSubPr"), - ScriptKind::SubSup => ("m:sSubSup", "m:sSubSupPr"), - ScriptKind::Pre => ("m:sPre", "m:sPrePr"), - }; - w.open(el, &[]); - w.open(pr, &[]); - ctrl(w); - w.close(pr); - if *kind == ScriptKind::Pre { - arg(w, "m:sub", sub, depth); - arg(w, "m:sup", sup, depth); - arg(w, "m:e", base, depth); - } else { - arg(w, "m:e", base, depth); - if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) { - arg(w, "m:sub", sub, depth); - } - if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) { - arg(w, "m:sup", sup, depth); - } - } - w.close(el); - } - MNode::Rad { deg, deg_hide, e } => { - w.open("m:rad", &[]); - w.open("m:radPr", &[]); - if *deg_hide { - val(w, "m:degHide", "1"); - } - ctrl(w); - w.close("m:radPr"); - arg(w, "m:deg", deg, depth); - arg(w, "m:e", e, depth); - w.close("m:rad"); - } - MNode::Nary { .. } => write_nary(w, n, depth), - MNode::Delim { beg, end, sep, grow, shp_match, elems } => { - w.open("m:d", &[]); - w.open("m:dPr", &[]); - if *beg != Some('(') { - char_prop(w, "m:begChr", *beg); - } - if *sep != Some('|') { - char_prop(w, "m:sepChr", *sep); - } - if *end != Some(')') { - char_prop(w, "m:endChr", *end); - } - if !grow { - val(w, "m:grow", "0"); - } - if *shp_match { - val(w, "m:shp", "match"); - } - ctrl(w); - w.close("m:dPr"); - for e in elems { - arg(w, "m:e", e, depth); - } - w.close("m:d"); - } - MNode::Func { name, e } => { - w.open("m:func", &[]); - w.open("m:funcPr", &[]); - ctrl(w); - w.close("m:funcPr"); - arg(w, "m:fName", name, depth); - arg(w, "m:e", e, depth); - w.close("m:func"); - } - MNode::Lim { upper, e, lim } => { - let (el, pr) = if *upper { ("m:limUpp", "m:limUppPr") } else { ("m:limLow", "m:limLowPr") }; - w.open(el, &[]); - w.open(pr, &[]); - ctrl(w); - w.close(pr); - arg(w, "m:e", e, depth); - arg(w, "m:lim", lim, depth); - w.close(el); - } - MNode::Acc { chr, e } => { - w.open("m:acc", &[]); - w.open("m:accPr", &[]); - char_prop(w, "m:chr", Some(*chr)); - ctrl(w); - w.close("m:accPr"); - arg(w, "m:e", e, depth); - w.close("m:acc"); - } - MNode::Bar { top, e } => { - w.open("m:bar", &[]); - w.open("m:barPr", &[]); - val(w, "m:pos", if *top { "top" } else { "bot" }); - ctrl(w); - w.close("m:barPr"); - arg(w, "m:e", e, depth); - w.close("m:bar"); - } - MNode::BorderBox { hide, strike, e } => { - w.open("m:borderBox", &[]); - w.open("m:borderBoxPr", &[]); - for (on, name) in hide.iter().zip(["m:hideTop", "m:hideBot", "m:hideLeft", "m:hideRight"]) { - if *on { - val(w, name, "1"); - } - } - for (on, name) in strike.iter().zip(["m:strikeH", "m:strikeV", "m:strikeBLTR", "m:strikeTLBR"]) { - if *on { - val(w, name, "1"); - } - } - ctrl(w); - w.close("m:borderBoxPr"); - arg(w, "m:e", e, depth); - w.close("m:borderBox"); - } - MNode::Boxed { e } => { - w.open("m:box", &[]); - w.open("m:boxPr", &[]); - ctrl(w); - w.close("m:boxPr"); - arg(w, "m:e", e, depth); - w.close("m:box"); - } - MNode::GroupChr { chr, top, e } => { - w.open("m:groupChr", &[]); - w.open("m:groupChrPr", &[]); - char_prop(w, "m:chr", Some(*chr)); - val(w, "m:pos", if *top { "top" } else { "bot" }); - val(w, "m:vertJc", if *top { "bot" } else { "top" }); - ctrl(w); - w.close("m:groupChrPr"); - arg(w, "m:e", e, depth); - w.close("m:groupChr"); - } - MNode::EqArr { rows } => { - w.open("m:eqArr", &[]); - w.open("m:eqArrPr", &[]); - // Numbered rows (`#`) stretch across the line so the number reaches the margin. - if rows.iter().any(|r| craft_wordmodel::math::has_number_mark(r)) { - val(w, "m:maxDist", "1"); - } - ctrl(w); - w.close("m:eqArrPr"); - for r in rows { - arg(w, "m:e", r, depth); - } - w.close("m:eqArr"); - } - MNode::Matrix { .. } => write_matrix(w, n, depth), - MNode::Phant { show, zero_wid, zero_asc, zero_desc, e } => { - w.open("m:phant", &[]); - w.open("m:phantPr", &[]); - val(w, "m:show", if *show { "1" } else { "0" }); - for (on, name) in [(*zero_wid, "m:zeroWid"), (*zero_asc, "m:zeroAsc"), (*zero_desc, "m:zeroDesc")] { - if on { - val(w, name, "1"); - } - } - ctrl(w); - w.close("m:phantPr"); - arg(w, "m:e", e, depth); - w.close("m:phant"); - } - } -} - -fn run(w: &mut W, r: &MRun) { - w.open("m:r", &[]); - let sty = r.sty.map(|s| match s { - MSty::Plain => "p", - MSty::Bold => "b", - MSty::Italic => "i", - MSty::BoldItalic => "bi", - }); - let scr = match r.scr { - MScr::Roman => None, - MScr::Script => Some("script"), - MScr::Fraktur => Some("fraktur"), - MScr::DoubleStruck => Some("double-struck"), - MScr::SansSerif => Some("sans-serif"), - MScr::Monospace => Some("monospace"), - }; - if sty.is_some() || scr.is_some() || r.nor || r.lit || r.brk.is_some() { - w.open("m:rPr", &[]); - if r.lit { - w.empty("m:lit", &[]); - } - if r.nor { - w.empty("m:nor", &[]); - } - if let Some(s) = scr { - val(w, "m:scr", s); - } - if let Some(s) = sty { - val(w, "m:sty", s); - } - match r.brk { - Some(0) => w.empty("m:brk", &[]), - Some(n) => w.empty("m:brk", &[("m:alnAt", &n.to_string())]), - None => {} - } - w.close("m:rPr"); - } - w.open("w:rPr", &[]); - let font = r.font.as_deref().filter(|_| r.nor).unwrap_or("Cambria Math"); - w.empty("w:rFonts", &[("w:ascii", font), ("w:hAnsi", font)]); - if let Some(c) = r.color { - w.val("w:color", &c.hex()); - } - if let Some(sz) = r.size { - let hp = (sz * 2.0).round().clamp(2.0, 3276.0) as i64; - w.val("w:sz", &hp.to_string()); - w.val("w:szCs", &hp.to_string()); - } - w.close("w:rPr"); - w.leaf("m:t", &[("xml:space", "preserve")], &r.text); - w.close("m:r"); + let mut o = craft_math::omml::W::default(); + craft_math::omml::math_pr(&mut o, m); + w.raw(&o.s); } diff --git a/crates/math/src/math_latex.rs b/crates/math/src/math_latex.rs index 5f442f437..24c0d5541 100644 --- a/crates/math/src/math_latex.rs +++ b/crates/math/src/math_latex.rs @@ -982,7 +982,12 @@ fn script_node(kind: ScriptKind, base: &[MNode], sub: &[MNode], sup: &[MNode], o braced(base, output, depth); return; } - braced(base, output, depth); + // An operator name takes its scripts directly (`\sin^{2}`): in braces it would no longer apply + // to what follows it when read back. + match base { + [MNode::Run(r)] if is_function_name(r) => run_node(r, output), + _ => braced(base, output, depth), + } if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) { output.push('_'); braced(sub, output, depth); @@ -993,6 +998,11 @@ fn script_node(kind: ScriptKind, base: &[MNode], sub: &[MNode], sup: &[MNode], o } } +/// Is `r` an operator name the writer writes as a command (`\sin`, `\log`)? +fn is_function_name(r: &MRun) -> bool { + !r.nor && r.sty == Some(MSty::Plain) && r.scr == MScr::Roman && FUNCTION_NAMES.contains(&r.text.as_str()) +} + fn radical_node(degree: &[MNode], degree_hidden: bool, expression: &[MNode], output: &mut String, depth: usize) { output.push_str("\\sqrt"); if !degree_hidden && !degree.is_empty() { @@ -1289,4 +1299,18 @@ mod tests { // The writer keeps its own names. assert_eq!(to_latex(&parse_latex(r"\infinity")), to_latex(&parse_latex(r"\infty"))); } + + /// An operator name with a script still applies to its argument when the LaTeX is read back. + #[test] + fn operator_names_take_their_scripts_directly() { + for src in [r"\sin^2\theta+\cos^2\theta=1", r"\log_2 x", r"\ln^{3}(x+1)"] { + let tree = parse_latex(src); + let tex = to_latex(&tree); + assert_eq!(crate::math::to_linear(&parse_latex(&tex)), crate::math::to_linear(&tree), "{src}: {tex}"); + assert!(!tex.contains(r"{\sin }") && !tex.contains(r"{\log }"), "{tex}"); + } + assert!(to_latex(&parse_latex(r"\sin^2\theta")).starts_with(r"\sin ^{2}")); + // Other bases keep their braces. + assert_eq!(to_latex(&parse_latex("x^2")), "{x}^{2}"); + } } diff --git a/crates/math/tests/corpus.rs b/crates/math/tests/corpus.rs index 90a7aaae7..383b77783 100644 --- a/crates/math/tests/corpus.rs +++ b/crates/math/tests/corpus.rs @@ -18,10 +18,6 @@ fn equations() -> Vec<(String, math::Arg, bool)> { v } -/// Equations whose LaTeX this crate writes in a form it reads back differently (a function -/// raised to a power: `\sin^2\theta` comes back as `(\sin)^2 θ`). Known; kept visible here. -const LATEX_ROUND_TRIP_GAPS: &[&str] = &["func"]; - #[test] fn every_corpus_equation_reads_without_unknown_commands() { for (id, tree, _) in equations() { @@ -54,10 +50,8 @@ fn every_corpus_equation_survives_mathml_and_latex() { let lin = math::to_linear(&tree); let ml = mathml::to_mathml(&tree, display, ""); assert_eq!(mathml::parse(&ml).and_then(|m| mathml::read(&m)), Some((lin.clone(), display)), "{id}: {ml}"); - if !LATEX_ROUND_TRIP_GAPS.contains(&id.as_str()) { - let tex = math_latex::to_latex(&tree); - assert_eq!(math::to_linear(&math_latex::parse_latex(&tex)), lin, "{id}: {tex}"); - } + let tex = math_latex::to_latex(&tree); + assert_eq!(math::to_linear(&math_latex::parse_latex(&tex)), lin, "{id}: {tex}"); } } diff --git a/crates/metadata/README.md b/crates/metadata/README.md index 309f29638..080f36eb8 100644 --- a/crates/metadata/README.md +++ b/crates/metadata/README.md @@ -18,8 +18,10 @@ are one crate. | `parse_iptc(&[u8]) -> Metadata` | IIM record 2: title (2:5), keywords (2:25), by-line (2:80), copyright (2:116), caption (2:120), date and time created (2:55, 2:60). | | `parse_xmp(&str) -> Result` | `Metadata`, every property (`prefix:name`, structs flattened as `a:b/c:d`) and the structured values. `parse_xmp_with` also canonicalises the caller's own namespaces. | | `write_xmp(&Metadata) -> String` | A complete `` packet. `write_xmp_with` sets the toolkit name and adds simple properties in the caller's namespace. | +| `XmpData::has_location_metadata`, `XmpData::properties_truncated` | Whether a packet names GPS or a place anywhere (array items and repeated structs included), and whether the flattened map is complete, for a caller that must prove a file carries no location. `parse_gps_position` reads an XMP coordinate pair. | +| `embedded_xmp(&Embedded)`, `parse_xmp_pair` | A JPEG's standard XMP with the Extended XMP it links (`xmpNote:HasExtendedXMP`) read as one packet; an `EmbeddedXmpError` (keeping the standard packet) when the link or the extension cannot be read whole. `extract` and `from_embedded` use it best-effort. Extended XMP: 16 MiB, 4096 chunks, only the linked GUID's chunks counted. | | `merge_xmp(existing, fresh, MergeRules)` | Updates a packet another program wrote: owned properties are replaced, everything else stays byte for byte. | -| `parse_gpx(&str) -> Result` | GPX track points; `Tracklog::locate` gives the position at a capture time. | +| `parse_gpx(&str) -> Result` | GPX track points with their track and segment; `Tracklog::locate` gives the position at a capture time, `Tracklog::only_track` one track. | | `embedded(&[u8]) -> Embedded` | Sniffs JPEG, PNG, WebP, TIFF-based files, Fujifilm RAF, Canon CR3 and JPEG XL containers and returns the Exif, XMP (and extended XMP), ICC and IPTC blocks. `jpeg_segments`, `png_chunks`, `webp_chunks` are the walkers. | | `cr3::parse_cr3` | The Canon CR3 container: `CMT1`…`CMT4`, the XMP `uuid` box, the thumbnail, the tracks. | | `jxl::metadata` | The `Exif` and `xml ` boxes of a JPEG XL container, plain or Brotli-wrapped in `brob`. | @@ -30,6 +32,10 @@ are one crate. HEIF, PSD and MRW files are read by `craft-heif`, `craft-psd` and `craft-raw`. Put the blocks they return into an `Embedded` and call `from_embedded`. +## Validation + +Only well-formed values become typed metadata; the raw property stays in `XmpData::properties`. XMP orientation must be an integral code 1 to 8; ISO and pixel dimensions exact whole numbers in range (compared as decimal digits); GPS minutes and seconds under 60, the sign from the axis letter. IPTC Time Created counts only as a whole `HHMMSS` or `HHMMSS±HHMM`. A carriage return in written text is ` `. + ## Capture time EXIF candidates are tried in order: DateTimeOriginal, DateTimeDigitized, then IFD0 DateTime. A clock @@ -50,7 +56,7 @@ A foreign namespace that uses a recognised prefix cannot supply recognised prope ## Limits -XMP: 64 levels, 200 000 nodes, 1 MiB of flattened keys and 8 MiB of opaque names per packet +XMP: 64 levels, 200 000 nodes, 512 namespace declarations in scope, 1 MiB of flattened keys and 8 MiB of opaque names per packet (`XmpError::TooComplex`). GPX: 64 levels, 5 000 000 points. MPF: 16 index entries. CR3 and JPEG XL: 4096 boxes; a `brob` box inflates to at most 32 MiB. JPEG ICC is returned only when its APP2 chunks form a complete, consistent, uniquely numbered sequence. TIFF limits are `craft-tiff`'s. No input panics: proptests feed random bytes to every container walker and random text to the XMP parser, and a sample diff --git a/crates/metadata/src/container.rs b/crates/metadata/src/container.rs index 439771b98..842020683 100644 --- a/crates/metadata/src/container.rs +++ b/crates/metadata/src/container.rs @@ -25,9 +25,18 @@ pub struct Embedded { pub xmp: Option, /// Extended XMP (JPEG only), reassembled. pub xmp_extended: Option, + /// The GUID every Extended XMP segment carries, set only when all segments share it and + /// their chunks tile exactly the full length they declare (no gap, overlap or excess). + pub xmp_extended_guid: Option, pub icc: Option>, /// IPTC-IIM stream. pub iptc: Option>, + /// True when the PNG/WebP chunk stream could not be fully inspected. + pub chunk_walk_incomplete: bool, + /// Why the Extended XMP the standard packet links to (`xmpNote:HasExtendedXMP`) could not be + /// read: its chunks are missing, truncated, too large, or do not cover their declared length + /// exactly once. [`Embedded::xmp_extended`] is `None` then. + pub xmp_extended_error: Option, } /// Sniff the container and collect its metadata blocks. Unknown containers yield an empty result. @@ -58,11 +67,14 @@ pub fn embedded(bytes: &[u8]) -> Embedded { exif: Some(bytes.to_vec()), xmp: ifd0.bytes(tags::XMP).map(|b| String::from_utf8_lossy(b).into_owned()), xmp_extended: None, + xmp_extended_guid: None, icc: ifd0.bytes(tags::ICC_PROFILE).map(|b| b.to_vec()), iptc: ifd0.value(tags::IPTC_NAA).map(|v| match v { craft_tiff::Value::Byte(b) | craft_tiff::Value::Undefined(b) => b.clone(), other => craft_tiff::writer::encode(t.order, other), }), + chunk_walk_incomplete: false, + xmp_extended_error: None, } } else { Embedded::default() @@ -74,14 +86,27 @@ const XMP_EXT_SIG: &[u8] = b"http://ns.adobe.com/xmp/extension/\0"; /// Walk JPEG markers up to SOS and collect APP1 Exif / XMP, APP2 ICC and APP13 IPTC. pub fn jpeg_segments(b: &[u8]) -> Embedded { + jpeg_segments_inner(b, false).0 +} + +/// Walk JPEG metadata and return every ordinary XMP APP1 packet in segment order, along with the +/// usual [`Embedded`] projection (which retains only the first ordinary packet). +pub fn jpeg_segments_with_all_xmp(b: &[u8]) -> (Embedded, Vec) { + jpeg_segments_inner(b, true) +} + +fn jpeg_segments_inner(b: &[u8], collect_all_xmp: bool) -> (Embedded, Vec) { let mut out = Embedded::default(); + let mut xmp_packets = Vec::new(); if !b.starts_with(&[0xff, 0xd8]) { - return out; + return (out, xmp_packets); } let mut icc_parts: [Option<&[u8]>; 255] = [None; 255]; let mut icc_total = None; let mut icc_invalid = false; let mut ext: Vec<(u32, Vec)> = Vec::new(); + // GUID and declared full length of every Extended XMP segment, to verify they belong together + let mut ext_headers: Vec<(&[u8], u32)> = Vec::new(); let mut i = 2usize; while i + 4 <= b.len() { if b[i] != 0xff { @@ -111,13 +136,22 @@ pub fn jpeg_segments(b: &[u8]) -> Embedded { match marker { 0xe1 if payload.starts_with(b"Exif\0") && out.exif.is_none() => out.exif = Some(payload.get(6..).unwrap_or(&[]).to_vec()), 0xe1 if payload.starts_with(XMP_SIG) && out.xmp.is_none() => { - out.xmp = Some(String::from_utf8_lossy(&payload[XMP_SIG.len()..]).into_owned()); + let packet = String::from_utf8_lossy(&payload[XMP_SIG.len()..]).into_owned(); + if collect_all_xmp { + xmp_packets.push(packet.clone()); + } + out.xmp = Some(packet); + } + 0xe1 if payload.starts_with(XMP_SIG) && collect_all_xmp => { + xmp_packets.push(String::from_utf8_lossy(&payload[XMP_SIG.len()..]).into_owned()); } 0xe1 if payload.starts_with(XMP_EXT_SIG) => { // GUID (32) + full length (4) + offset (4) + data let p = &payload[XMP_EXT_SIG.len()..]; if p.len() >= 40 { + let full = u32::from_be_bytes([p[32], p[33], p[34], p[35]]); let off = u32::from_be_bytes([p[36], p[37], p[38], p[39]]); + ext_headers.push((&p[..32], full)); ext.push((off, p[40..].to_vec())); } } @@ -180,11 +214,114 @@ pub fn jpeg_segments(b: &[u8]) -> Embedded { out.icc = Some(profile); } } - if !ext.is_empty() { + attach_extension(&mut out, b, ext, &ext_headers); + (out, xmp_packets) +} + +/// Put the Extended XMP of JPEG `b` into `out`: when the standard packet links one, only that GUID's +/// chunks count, checked strictly ([`extended_xmp`]); otherwise every chunk, offset-sorted. +fn attach_extension(out: &mut Embedded, b: &[u8], mut ext: Vec<(u32, Vec)>, ext_headers: &[(&[u8], u32)]) { + // a standard packet that links an extension: only that GUID's chunks count, checked strictly + let reference = (!ext.is_empty()) + .then(|| out.xmp.as_deref().and_then(|x| crate::parse_xmp(x).ok()).and_then(|d| crate::xmp::extended_xmp_reference(&d).ok().flatten())) + .flatten(); + if let Some(guid) = reference { + match extended_xmp(b, &guid) { + Ok(packet) => { + out.xmp_extended = Some(packet); + out.xmp_extended_guid = Some(guid); + } + Err(error) => out.xmp_extended_error = Some(error), + } + } else if !ext.is_empty() { ext.sort_by_key(|p| p.0); + out.xmp_extended_guid = verified_extension_guid(ext_headers, &ext); out.xmp_extended = Some(String::from_utf8_lossy(&ext.into_iter().flat_map(|p| p.1).collect::>()).into_owned()); } - out +} + +/// Most bytes an Extended XMP packet may have, and most chunks it may be split into. +const MAX_EXTENDED_XMP_BYTES: usize = 16 * 1024 * 1024; +const MAX_EXTENDED_XMP_CHUNKS: usize = 4096; + +/// The Extended XMP packet of a JPEG for `guid` (upper-case hex, from the standard packet's +/// `xmpNote:HasExtendedXMP`; Adobe XMP Specification Part 3, 1.1.3.1): the APP1 chunks carrying +/// that GUID, which must all declare one full length of at most [`MAX_EXTENDED_XMP_BYTES`] and +/// together cover it exactly once, in at most [`MAX_EXTENDED_XMP_CHUNKS`] complete segments. +/// Chunks of any other GUID are not looked at, so they cannot exhaust the limits. +fn extended_xmp(b: &[u8], guid: &str) -> Result { + let mut chunks: Vec<(u32, &[u8])> = Vec::new(); + let (mut total, mut received) = (None, 0usize); + let mut i = 2usize; + while let (Some(&0xff), Some(&marker)) = (b.get(i), b.get(i + 1)) { + if marker == 0xff { + i += 1; + continue; + } + if marker == 0xd8 || (0xd0..=0xd7).contains(&marker) || marker == 0x01 { + i += 2; + continue; + } + if marker == 0xd9 || marker == 0xda { + break; + } + let Some(len) = b.get(i + 2..i + 4).map(|l| usize::from(u16::from_be_bytes([l[0], l[1]]))).filter(|&l| l >= 2) else { break }; + let (start, end) = (i + 4, i + 2 + len); + let payload = b.get(start..end.min(b.len())).unwrap_or_default(); + if let Some(p) = payload.strip_prefix(XMP_EXT_SIG).filter(|_| marker == 0xe1) + && p.get(..32).is_some_and(|g| g.eq_ignore_ascii_case(guid.as_bytes())) + { + if end > b.len() { + return Err("Extended XMP APP1 segment is truncated".into()); + } + let (Some(full), Some(offset), Some(data)) = (p.get(32..36), p.get(36..40), p.get(40..)) else { + return Err("Extended XMP APP1 header is truncated".into()); + }; + let full = u32::from_be_bytes([full[0], full[1], full[2], full[3]]); + if *total.get_or_insert(full) != full { + return Err("Extended XMP chunks declare different full lengths".into()); + } + received = received.saturating_add(data.len()); + if usize::try_from(full).map_or(true, |f| f > MAX_EXTENDED_XMP_BYTES) || received > MAX_EXTENDED_XMP_BYTES { + return Err("Extended XMP is larger than 16 MiB".into()); + } + if chunks.len() >= MAX_EXTENDED_XMP_CHUNKS { + return Err("Extended XMP has too many chunks".into()); + } + chunks.push((u32::from_be_bytes([offset[0], offset[1], offset[2], offset[3]]), data)); + } + i = end; + } + let total = total.ok_or("the referenced Extended XMP is missing")?; + chunks.sort_by_key(|c| c.0); + let mut packet = Vec::with_capacity(received); + for (offset, data) in chunks { + if usize::try_from(offset).ok() != Some(packet.len()) { + return Err("Extended XMP chunks have a gap, an overlap or a repeated offset".into()); + } + packet.extend_from_slice(data); + } + if u32::try_from(packet.len()).ok() != Some(total) || total == 0 { + return Err("Extended XMP chunks do not cover their declared full length".into()); + } + String::from_utf8(packet).map_err(|_| "Extended XMP is not UTF-8".into()) +} + +/// The GUID shared by all Extended XMP segments when their offset-sorted chunks are contiguous from +/// zero and add up to the full length every segment declares; `None` otherwise. +fn verified_extension_guid(headers: &[(&[u8], u32)], chunks_by_offset: &[(u32, Vec)]) -> Option { + let (&(guid, full), rest) = headers.split_first()?; + if rest.iter().any(|&(other_guid, other_full)| other_guid != guid || other_full != full) { + return None; + } + let mut next = 0u64; + for (offset, data) in chunks_by_offset { + if u64::from(*offset) != next { + return None; + } + next = next.checked_add(u64::try_from(data.len()).ok()?)?; + } + (next == u64::from(full)).then(|| String::from_utf8_lossy(guid).into_owned()) } /// IPTC-IIM block (resource 0x0404) from a Photoshop image-resource stream (`8BIM` records). @@ -209,10 +346,41 @@ fn photoshop_iptc(mut b: &[u8]) -> Option> { pub fn png_chunks(b: &[u8]) -> Embedded { let mut out = Embedded::default(); let mut i = 8usize; - while i + 12 <= b.len() { - let len = u32::from_be_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize; - let typ = &b[i + 4..i + 8]; - let Some(data) = b.get(i + 8..(i + 8).saturating_add(len)) else { break }; + let mut ended = false; + loop { + let Some(header_end) = i.checked_add(8).filter(|end| *end <= b.len()) else { + out.chunk_walk_incomplete = true; + break; + }; + let Some(header) = b.get(i..header_end) else { + out.chunk_walk_incomplete = true; + break; + }; + let Some(length_bytes) = header.get(..4) else { + out.chunk_walk_incomplete = true; + break; + }; + let Ok(length_bytes) = <[u8; 4]>::try_from(length_bytes) else { + out.chunk_walk_incomplete = true; + break; + }; + let len = u32::from_be_bytes(length_bytes) as usize; + let Some(data_end) = header_end.checked_add(len) else { + out.chunk_walk_incomplete = true; + break; + }; + let Some(chunk_end) = data_end.checked_add(4).filter(|end| *end <= b.len()) else { + out.chunk_walk_incomplete = true; + break; + }; + let Some(typ) = header.get(4..8) else { + out.chunk_walk_incomplete = true; + break; + }; + let Some(data) = b.get(header_end..data_end) else { + out.chunk_walk_incomplete = true; + break; + }; match typ { b"eXIf" => out.exif = Some(crate::strip_exif_header(data).to_vec()), b"iTXt" if data.starts_with(b"XML:com.adobe.xmp\0") => { @@ -227,10 +395,17 @@ pub fn png_chunks(b: &[u8]) -> Embedded { } } } - b"IEND" => break, + b"IEND" => { + ended = len == 0 && chunk_end == b.len(); + out.chunk_walk_incomplete |= !ended; + break; + } _ => {} } - i += 12 + len; + i = chunk_end; + } + if !ended { + out.chunk_walk_incomplete = true; } out } @@ -238,18 +413,69 @@ pub fn png_chunks(b: &[u8]) -> Embedded { /// WebP RIFF chunks `EXIF`, `XMP `, `ICCP`. pub fn webp_chunks(b: &[u8]) -> Embedded { let mut out = Embedded::default(); + let Some(size_bytes) = b.get(4..8) else { + out.chunk_walk_incomplete = true; + return out; + }; + let Ok(size_bytes) = <[u8; 4]>::try_from(size_bytes) else { + out.chunk_walk_incomplete = true; + return out; + }; + let riff_size = u32::from_le_bytes(size_bytes) as usize; + let Some(riff_end) = 8usize.checked_add(riff_size) else { + out.chunk_walk_incomplete = true; + return out; + }; + if riff_size < 4 || riff_end < 12 { + out.chunk_walk_incomplete = true; + return out; + } + if riff_end != b.len() { + out.chunk_walk_incomplete = true; + } + let end = riff_end.min(b.len()); let mut i = 12usize; - while i + 8 <= b.len() { - let id = &b[i..i + 4]; - let len = u32::from_le_bytes([b[i + 4], b[i + 5], b[i + 6], b[i + 7]]) as usize; - let Some(data) = b.get(i + 8..(i + 8).saturating_add(len)) else { break }; + while i < end { + let Some(header_end) = i.checked_add(8).filter(|next| *next <= end) else { + out.chunk_walk_incomplete = true; + break; + }; + let Some(header) = b.get(i..header_end) else { + out.chunk_walk_incomplete = true; + break; + }; + let Some(length_bytes) = header.get(4..8) else { + out.chunk_walk_incomplete = true; + break; + }; + let Ok(length_bytes) = <[u8; 4]>::try_from(length_bytes) else { + out.chunk_walk_incomplete = true; + break; + }; + let len = u32::from_le_bytes(length_bytes) as usize; + let Some(data_end) = header_end.checked_add(len) else { + out.chunk_walk_incomplete = true; + break; + }; + let Some(chunk_end) = data_end.checked_add(len & 1).filter(|next| *next <= end) else { + out.chunk_walk_incomplete = true; + break; + }; + let Some(id) = header.get(..4) else { + out.chunk_walk_incomplete = true; + break; + }; + let Some(data) = b.get(header_end..data_end) else { + out.chunk_walk_incomplete = true; + break; + }; match id { b"EXIF" => out.exif = Some(crate::strip_exif_header(data).to_vec()), b"XMP " => out.xmp = Some(String::from_utf8_lossy(data).into_owned()), b"ICCP" => out.icc = Some(data.to_vec()), _ => {} } - i += 8 + len + (len & 1); + i = chunk_end; } out } @@ -258,7 +484,7 @@ pub fn webp_chunks(b: &[u8]) -> Embedded { mod tests { use super::*; use crate::exif::tests::sample_exif; - use craft_tiff::ByteOrder; + use craft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value}; fn seg(marker: u8, payload: &[u8]) -> Vec { let mut v = vec![0xff, marker]; @@ -329,6 +555,150 @@ mod tests { assert_eq!(m.caption.as_deref(), Some("A caption")); } + fn jpeg_with_orientation(exif_orientation: Option, xmp_orientation: &str) -> Vec { + let mut jpeg = vec![0xff, 0xd8]; + if let Some(orientation) = exif_orientation { + let mut exif = b"Exif\0\0".to_vec(); + exif.extend(crate::exif::write_exif(&crate::Metadata { orientation: Some(orientation), ..Default::default() })); + jpeg.extend(seg(0xe1, &exif)); + } + let packet = format!( + r#""# + ); + let mut xmp = XMP_SIG.to_vec(); + xmp.extend_from_slice(packet.as_bytes()); + jpeg.extend(seg(0xe1, &xmp)); + jpeg.extend_from_slice(&[0xff, 0xd9]); + jpeg + } + + #[test] + fn embedded_xmp_orientation_requires_integral_value_and_respects_exif() { + let malformed = crate::extract(&jpeg_with_orientation(None, "6.9")); + assert_eq!(malformed.orientation, None); + + let malformed_xmp_with_exif = crate::extract(&jpeg_with_orientation(Some(crate::Orientation::Rotate180), "6.5")); + assert_eq!(malformed_xmp_with_exif.orientation.map(|orientation| orientation.to_exif()), Some(3)); + + let valid_xmp = crate::extract(&jpeg_with_orientation(None, "6")); + assert_eq!(valid_xmp.orientation.map(|orientation| orientation.to_exif()), Some(6)); + + let valid_exif = crate::extract(&jpeg_with_orientation(Some(crate::Orientation::Rotate180), "6")); + assert_eq!(valid_exif.orientation.map(|orientation| orientation.to_exif()), Some(3)); + } + + #[test] + fn jpeg_invalid_xmp_iso_stays_absent_and_exif_iso_wins() { + let make_jpeg = |exif: Option>, xmp: &str| { + let mut jpeg = vec![0xff, 0xd8]; + if let Some(exif) = exif { + let mut payload = b"Exif\0\0".to_vec(); + payload.extend(exif); + jpeg.extend(seg(0xe1, &payload)); + } + let mut payload = XMP_SIG.to_vec(); + payload.extend_from_slice(xmp.as_bytes()); + jpeg.extend(seg(0xe1, &payload)); + jpeg.extend_from_slice(&[0xff, 0xda, 0, 2, 0xff, 0xd9]); + jpeg + }; + let invalid = r#""#; + assert_eq!(crate::extract(&make_jpeg(None, invalid)).iso, None); + + let valid = r#""#; + assert_eq!(crate::extract(&make_jpeg(Some(sample_exif(ByteOrder::Big)), valid)).iso, Some(400)); + } + + #[test] + fn jpeg_rejected_xmp_dimensions_keep_exif_dimensions_and_other_metadata() { + let mut jpeg = vec![0xff, 0xd8]; + let mut exif = b"Exif\0\0".to_vec(); + exif.extend(crate::exif::tests::sample_exif(ByteOrder::Big)); + jpeg.extend(seg(0xe1, &exif)); + let packet = r#" + + "#; + let mut xmp = XMP_SIG.to_vec(); + xmp.extend_from_slice(packet.as_bytes()); + jpeg.extend(seg(0xe1, &xmp)); + jpeg.extend_from_slice(&[0xff, 0xda, 0, 2]); + + let metadata = crate::extract(&jpeg); + assert_eq!(metadata.width, Some(6000)); + assert_eq!(metadata.height, Some(4000)); + assert_eq!(metadata.title.as_deref(), Some("Retained")); + } + + #[test] + fn jpeg_rejected_xmp_dimension_falls_back_during_whole_file_extract() { + let mut jpeg = vec![0xff, 0xd8]; + let packet = r#" + + "#; + let mut xmp = XMP_SIG.to_vec(); + xmp.extend_from_slice(packet.as_bytes()); + jpeg.extend(seg(0xe1, &xmp)); + jpeg.extend_from_slice(&[0xff, 0xda, 0, 2]); + + assert_eq!(crate::extract(&jpeg).width, Some(800)); + } + + #[test] + fn jpeg_invalid_iptc_time_created_allows_xmp_and_exif_fallbacks() { + fn iim() -> Vec { + let mut data = Vec::new(); + for (dataset, value) in [(55, &b"20260101"[..]), (60, b"121314+01"), (5, b"retained title")] { + data.extend_from_slice(&[0x1c, 2, dataset]); + data.extend_from_slice(&(value.len() as u16).to_be_bytes()); + data.extend_from_slice(value); + } + data + } + + fn jpeg(xmp: &str, exif: Option<&[u8]>) -> Vec { + let mut jpeg = vec![0xff, 0xd8]; + if let Some(exif) = exif { + let mut payload = b"Exif\0\0".to_vec(); + payload.extend_from_slice(exif); + jpeg.extend_from_slice(&seg(0xe1, &payload)); + } + let mut payload = XMP_SIG.to_vec(); + payload.extend_from_slice(xmp.as_bytes()); + jpeg.extend_from_slice(&seg(0xe1, &payload)); + let mut resource = b"Photoshop 3.0\0".to_vec(); + resource.extend_from_slice(b"8BIM\x04\x04\0\0"); + let iim = iim(); + resource.extend_from_slice(&(iim.len() as u32).to_be_bytes()); + resource.extend_from_slice(&iim); + if iim.len() % 2 == 1 { + resource.push(0); + } + jpeg.extend_from_slice(&seg(0xed, &resource)); + jpeg.extend_from_slice(&[0xff, 0xda, 0, 2]); + jpeg + } + + let xmp = crate::write_xmp(&crate::Metadata { + capture_time: Some(crate::DateTime { year: 2026, month: 1, day: 2, hour: 10, offset_minutes: Some(0), ..Default::default() }), + ..Default::default() + }); + let fallback = jpeg(&xmp, None); + let embedded = embedded(&fallback); + assert!(embedded.iptc.is_some(), "raw IIM remains available"); + assert_eq!(crate::extract(&fallback).capture_time.map(|time| time.to_iso()).as_deref(), Some("2026-01-02T10:00:00+00:00")); + assert_eq!(crate::extract(&fallback).title.as_deref(), Some("retained title")); + + let mut exif_ifd = IfdBuilder::new(); + exif_ifd.set(0x9003, Value::Ascii("2026:01:03 11:12:13".into())); + let mut ifd0 = IfdBuilder::new(); + ifd0.set_child(0x8769, exif_ifd); + let exif = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); + let exif_precedence = jpeg(&xmp, Some(&exif)); + assert_eq!(crate::extract(&exif_precedence).capture_time.map(|time| time.to_iso()).as_deref(), Some("2026-01-03T11:12:13")); + } + #[test] fn jpeg_extended_xmp() { let mut j = vec![0xff, 0xd8]; @@ -341,6 +711,28 @@ mod tests { j.extend(seg(0xe1, &p)); } assert_eq!(jpeg_segments(&j).xmp_extended.as_deref(), Some("helloworld")); + assert_eq!(jpeg_segments(&j).xmp_extended_guid, Some("A".repeat(32))); + } + + /// A GUID mix, a short chunk set or a wrong declared length leaves the extension unverified. + #[test] + fn jpeg_extended_xmp_guid_needs_matching_segments_and_length() { + let build = |segments: &[(u8, u32, u32, &[u8])]| { + let mut j = vec![0xff, 0xd8]; + for &(guid, full, off, part) in segments { + let mut p = XMP_EXT_SIG.to_vec(); + p.extend_from_slice(&[guid; 32]); + p.extend_from_slice(&full.to_be_bytes()); + p.extend_from_slice(&off.to_be_bytes()); + p.extend_from_slice(part); + j.extend(seg(0xe1, &p)); + } + jpeg_segments(&j).xmp_extended_guid + }; + assert!(build(&[(b'A', 10, 0, b"hello"), (b'B', 10, 5, b"world")]).is_none(), "two GUIDs"); + assert!(build(&[(b'A', 10, 0, b"hello")]).is_none(), "half the declared length"); + assert!(build(&[(b'A', 9, 0, b"hello"), (b'A', 9, 5, b"world")]).is_none(), "longer than declared"); + assert!(build(&[(b'A', 10, 0, b"hello"), (b'A', 10, 4, b"oworl")]).is_none(), "overlapping chunks"); } #[test] @@ -404,6 +796,7 @@ mod tests { let m = crate::extract(&p); assert_eq!(m.iso, Some(400)); assert_eq!(m.label.as_deref(), Some("Red")); + assert!(!embedded(&p).chunk_walk_incomplete); let mut w = b"RIFF\0\0\0\0WEBP".to_vec(); let mut body = |id: &[u8], data: &[u8]| { @@ -419,11 +812,30 @@ mod tests { let mut ex = b"Exif\0\0".to_vec(); ex.extend_from_slice(&exif); body(b"EXIF", &ex); + let riff_size = w.len().saturating_sub(8) as u32; + w[4..8].copy_from_slice(&riff_size.to_le_bytes()); let e = webp_chunks(&w); + assert!(!e.chunk_walk_incomplete); assert_eq!(e.icc.as_deref(), Some(&b"abc"[..])); assert_eq!(crate::extract(&w).f_number, Some(2.8)); } + #[test] + fn chunk_walk_reports_truncated_png_and_webp() { + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + png.extend_from_slice(&32u32.to_be_bytes()); + png.extend_from_slice(b"iTXtshort"); + assert!(png_chunks(&png).chunk_walk_incomplete); + + let mut webp = b"RIFF\0\0\0\0WEBP".to_vec(); + webp.extend_from_slice(b"XMP "); + webp.extend_from_slice(&32u32.to_le_bytes()); + webp.extend_from_slice(b"short"); + let riff_size = webp.len().saturating_sub(8).saturating_add(27) as u32; + webp[4..8].copy_from_slice(&riff_size.to_le_bytes()); + assert!(webp_chunks(&webp).chunk_walk_incomplete); + } + #[test] fn tiff_file_is_its_own_exif() { let t = sample_exif(ByteOrder::Big); diff --git a/crates/metadata/src/datetime.rs b/crates/metadata/src/datetime.rs index 13ab90bca..9089ad437 100644 --- a/crates/metadata/src/datetime.rs +++ b/crates/metadata/src/datetime.rs @@ -107,7 +107,8 @@ impl DateTime { _ => return None, }; let (h, m) = match rest.split_once(':') { - Some((h, m)) => (num(h)?, num(m)?), + Some((h, m)) if m.len() == 2 => (num(h)?, num(m)?), + Some(_) => return None, None if rest.len() == 4 => (num(rest.get(..2)?)?, num(rest.get(2..)?)?), None => (num(rest)?, 0), }; @@ -159,8 +160,13 @@ impl DateTime { None => (t, None), }; let mut cp = clock.split(':'); - dt.hour = num(cp.next()?)?.try_into().ok()?; - dt.minute = num(cp.next()?)?.try_into().ok()?; + let hour = cp.next()?; + let minute = cp.next()?; + if hour.len() != 2 || minute.len() != 2 { + return None; + } + dt.hour = num(hour)?.try_into().ok()?; + dt.minute = num(minute)?.try_into().ok()?; if let Some(sec) = cp.next() { let (whole, fraction) = match sec.split_once('.') { Some((whole, fraction)) => { @@ -233,6 +239,14 @@ impl DateTime { mod tests { use super::*; + #[test] + fn offset_rejects_short_minute_field() { + assert_eq!(DateTime::parse_offset("+08:5"), None); + assert_eq!(DateTime::parse_offset("+1:2"), None); + assert_eq!(DateTime::parse_offset("+08:05"), Some(485)); + assert_eq!(DateTime::parse_offset("-05:30"), Some(-330)); + } + #[test] fn exif_forms() { let d = DateTime::parse_exif("2024:06:01 13:45:59").unwrap(); @@ -367,6 +381,13 @@ mod tests { } } + #[test] + fn iso_rejects_single_digit_clock_fields() { + for value in ["2024-06-01T1:02:03Z", "2024-06-01T01:2:03Z"] { + assert!(DateTime::parse_iso(value).is_none(), "accepted malformed timestamp {value:?}"); + } + } + #[test] fn iso_validates_clock_while_preserving_supported_forms() { assert_eq!(DateTime::parse_iso("2019").unwrap().to_iso(), "2019"); diff --git a/crates/metadata/src/focus.rs b/crates/metadata/src/focus.rs index b956e667e..1abf3ab4e 100644 --- a/crates/metadata/src/focus.rs +++ b/crates/metadata/src/focus.rs @@ -3,7 +3,8 @@ //! //! **Sony** (the MakerNote IFD: header-less in ARWs, after a `SONY DSC` header in JPEGs). Tag identities and value names come from the public ExifTool //! tag-name documentation (Sony table); every layout below was confirmed on real ILCE-1 (α1) and -//! ILCE-7SM3 (A7S III) ARWs, compared with ExifTool's output as a black box. No third-party code was read. +//! ILCE-7SM3 (A7S III) ARWs, compared with ExifTool's output as a black box, then on the public raw.pixls.us +//! samples of every other body in [`SONY_MODELS`]. No third-party code was read. //! //! | Tag | Layout (confirmed) | Use | //! |---|---|---| @@ -79,7 +80,63 @@ impl FocusPoint { /// Sony bodies whose focus tags have been checked against real files. Another body may store the same /// tags differently (or not mean the same by them): it shows no focus point until its files are checked. -pub const SONY_MODELS: &[&str] = &["ILCE-1", "ILCE-7SM3"]; +/// +/// The α1 and A7S III on 333 files of a photographer's library; the others on the public raw.pixls.us samples (one to +/// six ARWs per body, 103 files of the 44 bodies): every point equal to the one ExifTool's FocusLocation and +/// FocusFrameSize give (77 points, one of them rotated), every refusal (26: manual focus, Wide at the exact centre) +/// equal to what ExifTool's focus modes say, and each body's box drawn on its camera JPEG on the subject it +/// focused on (a pigeon, a face, a leaf, a chart patch). Seven of them (DSC-HX95, DSC-HX99, ILCE-6700, ILCE-7CR, +/// ILCE-7RM2, ILCE-7SM2, ILCE-9M3) only have samples without a point, read as ExifTool reads them. Not listed: the +/// bodies without `0x2027` (every NEX, SLT and DSLR-A; ILCE-3000, 3500, 5000, 5100, 6000 and 6001, QX1, 7, 7R, 7S +/// and 7M2; ILCA-68 and 77M2; RX100 to RX100 III, RX10, RX1 and RX1R; UMC-R10C), and the DSC-RX0, RX100 IV, RX100 V, +/// RX10 II, RX10 III and RX1R II, whose `0x201b` reads 0 (Manual) while the enciphered block says AF-S: ExifTool +/// doesn't decode that tag for them. +pub const SONY_MODELS: &[&str] = &[ + "DSC-HX95", + "DSC-HX99", + "DSC-RX0M2", + "DSC-RX100M5A", + "DSC-RX100M6", + "DSC-RX100M7", + "DSC-RX100M7A", + "DSC-RX10M4", + "DSC-RX1RM3", + "ILCA-99M2", + "ILCE-1", + "ILCE-1M2", + "ILCE-6100", + "ILCE-6300", + "ILCE-6400", + "ILCE-6400A", + "ILCE-6500", + "ILCE-6600", + "ILCE-6700", + "ILCE-7C", + "ILCE-7CM2", + "ILCE-7CR", + "ILCE-7M3", + "ILCE-7M4", + "ILCE-7M5", + "ILCE-7RM2", + "ILCE-7RM3", + "ILCE-7RM3A", + "ILCE-7RM4", + "ILCE-7RM4A", + "ILCE-7RM5", + "ILCE-7SM2", + "ILCE-7SM3", + "ILCE-9", + "ILCE-9M2", + "ILCE-9M3", + "ILME-FX2", + "ILME-FX3", + "ILME-FX30", + "ZV-1", + "ZV-1M2", + "ZV-E1", + "ZV-E10", + "ZV-E10M2", +]; /// Whether a camera as the catalog names it (`"SONY ILCE-1"`: the Exif Make, a space, the Model) is /// one whose files can carry a focus point, so a photo from any other camera is never opened to look. diff --git a/crates/metadata/src/focus/tests.rs b/crates/metadata/src/focus/tests.rs index 148f5ed99..e7644adff 100644 --- a/crates/metadata/src/focus/tests.rs +++ b/crates/metadata/src/focus/tests.rs @@ -261,8 +261,11 @@ fn read_exif_finds_it_through_the_maker_note_and_orientation() { // EXIF 8: sensor x becomes 1 - upright y let (cx, cy) = f.center(); assert!((cy - (1.0 - 3226.0 / 8640.0)).abs() < 1e-9 && (cx - 1980.0 / 5760.0).abs() < 1e-9, "{cx} {cy}"); - // an unconfirmed body or another maker: nothing - assert!(crate::read_exif(&sony_file("SONY", "ILCE-7M4", 1, &alpha1_entries())).focus.is_none()); + // a body checked on public samples reads the same layout + let f = crate::read_exif(&sony_file("SONY", "ZV-E10", 1, &alpha1_entries())).focus.expect("ZV-E10 focus point"); + assert!((f.center().0 - 3226.0 / 8640.0).abs() < 1e-9, "{:?}", f.center()); + // a body left out (its 0x201b is not the focus mode) or another maker: nothing + assert!(crate::read_exif(&sony_file("SONY", "DSC-RX100M4", 1, &alpha1_entries())).focus.is_none()); assert!(crate::read_exif(&sony_file("Canon", "ILCE-1", 1, &alpha1_entries())).focus.is_none()); } @@ -339,7 +342,7 @@ fn header_read_matches_the_whole_file_and_reads_little() { assert_eq!(r.focus, whole); assert!(r.bytes_read <= HEADER_PREFIX); // unsupported bodies agree too (no point either way) - let other = sony_file("SONY", "ILCE-7M4", 1, &alpha1_entries()); + let other = sony_file("SONY", "DSC-RX100M4", 1, &alpha1_entries()); assert_eq!(read(&other).focus, None); } @@ -396,8 +399,10 @@ fn the_synthetic_arw_is_laid_out_as_described() { #[test] fn cameras_that_can_have_a_point() { - assert!(camera_supported("SONY ILCE-1") && camera_supported("Sony ILCE-7SM3 ")); - for c in ["SONY ILCE-7M4", "Canon EOS R5", "", "SONY", "ILCE-1", "SON"] { + for c in ["SONY ILCE-1", "Sony ILCE-7SM3 ", "SONY ILCE-1M2", "SONY ILCE-7M4", "SONY ZV-E10", "SONY DSC-RX100M7"] { + assert!(camera_supported(c), "{c}"); + } + for c in ["SONY ILCE-7M2", "SONY DSC-RX100M4", "SONY NEX-7", "Canon EOS R5", "", "SONY", "ILCE-1", "SON", "SONY ILCE-1M"] { assert!(!camera_supported(c), "{c}"); } } diff --git a/crates/metadata/src/gpx.rs b/crates/metadata/src/gpx.rs index 691a2b7e1..c50454f6b 100644 --- a/crates/metadata/src/gpx.rs +++ b/crates/metadata/src/gpx.rs @@ -7,6 +7,10 @@ //! routes (``) carry no timeline and are ignored. The schema defines a track segment as one //! continuous span of logging, so positions are only interpolated *within* a segment, never across //! the gap where reception was lost or the logger was off. +//! +//! Each point also records which `` it came from ([`TrackPoint::track`], with the tracks' +//! ``s in [`Tracklog::names`]), so a map can draw the tracks of a file apart and tag from +//! one of them ([`Tracklog::only_track`]). use quick_xml::events::Event; @@ -15,6 +19,10 @@ use crate::{DateTime, Gps}; /// Nesting / size limits (a track log is flat; these only stop hostile input). const MAX_DEPTH: usize = 64; const MAX_POINTS: usize = 5_000_000; +/// Most tracks (``) in one file. +const MAX_TRACKS: usize = 10_000; +/// Longest track name kept (characters); the rest is cut. +const MAX_NAME_CHARS: usize = 200; /// One timed point of a track. #[derive(Clone, Copy, Debug, PartialEq)] @@ -27,6 +35,8 @@ pub struct TrackPoint { pub elevation: Option, /// Track segment number, counted across the whole file (continuous logging spans). pub segment: u32, + /// The `` it belongs to, counted from 0 in file order. + pub track: u32, } /// How a position was found by [`Tracklog::locate`]. @@ -44,6 +54,8 @@ pub struct Tracklog { pub points: Vec, /// Tracks (``) in the file. pub tracks: usize, + /// Each track's `` (empty when it has none), in file order: `names.len() == tracks`. + pub names: Vec, /// Track points without a usable time (left out of `points`). pub untimed: usize, } @@ -120,7 +132,13 @@ pub fn parse_gpx(s: &str) -> Result { } match name.as_slice() { b"gpx" if path.is_empty() => seen_gpx = true, - b"trk" if parent == Some(b"gpx") => log.tracks += 1, + b"trk" if parent == Some(b"gpx") => { + if log.tracks >= MAX_TRACKS { + return Err(GpxError::TooComplex); + } + log.tracks += 1; + log.names.push(String::new()); + } b"trkseg" if parent == Some(b"trk") && log.points.last().is_some_and(|p| p.segment == segment) => segment += 1, b"trkpt" if parent == Some(b"trkseg") => { let mut p = Pending::default(); @@ -182,6 +200,11 @@ pub fn parse_gpx(s: &str) -> Result { } } b"trkpt" => finish_point(&mut log, pt.take(), segment)?, + b"name" if parent == Some(b"trk") => { + if let Some(n) = log.names.last_mut() { + *n = text.trim().chars().take(MAX_NAME_CHARS).collect(); + } + } _ => {} } text.clear(); @@ -214,7 +237,9 @@ fn finish_point(log: &mut Tracklog, p: Option, segment: u32) -> Result< if log.points.len() >= MAX_POINTS { return Err(GpxError::TooComplex); } - log.points.push(TrackPoint { time, latitude: lat, longitude: lon, elevation: p.ele, segment }); + let track = u32::try_from(log.tracks.saturating_sub(1)).unwrap_or(u32::MAX); + let elevation = p.ele.filter(|e| e.is_finite()); + log.points.push(TrackPoint { time, latitude: lat, longitude: lon, elevation, segment, track }); Ok(()) } @@ -224,6 +249,16 @@ impl Tracklog { Some((self.points.first()?.time, self.points.last()?.time)) } + /// The points of track `index` alone (its segments as they were), or `None` when there is no + /// such track. + pub fn only_track(&self, index: usize) -> Option { + if index >= self.tracks { + return None; + } + let points = self.points.iter().filter(|p| usize::try_from(p.track).ok() == Some(index)).copied().collect(); + Some(Tracklog { points, tracks: 1, names: vec![self.names.get(index).cloned().unwrap_or_default()], untimed: 0 }) + } + /// Where the logger was at `time` (seconds since the epoch, UTC). /// /// Between two points of the same segment that are at most `max_gap` seconds apart, the position @@ -458,8 +493,8 @@ mod tests { #[test] fn interpolation_across_the_antimeridian() { - let a = TrackPoint { time: 0.0, latitude: 0.0, longitude: 179.0, elevation: None, segment: 0 }; - let b = TrackPoint { time: 10.0, latitude: 0.0, longitude: -179.0, elevation: None, segment: 0 }; + let a = TrackPoint { time: 0.0, latitude: 0.0, longitude: 179.0, elevation: None, segment: 0, track: 0 }; + let b = TrackPoint { time: 10.0, latitude: 0.0, longitude: -179.0, elevation: None, segment: 0, track: 0 }; let g = lerp(&a, &b, 0.75); assert!((g.longitude - -179.5).abs() < 1e-9, "{g:?}"); } @@ -487,5 +522,57 @@ mod tests { ); let deep = "".to_string() + &"".repeat(100); assert_eq!(parse_gpx(&deep), Err(GpxError::TooComplex)); + let many = "".to_string() + &"".repeat(MAX_TRACKS + 1) + ""; + assert_eq!(parse_gpx(&many), Err(GpxError::TooComplex)); + } + + #[test] + fn tracks_are_told_apart_with_their_names() { + let s = r#" Morning + + + + + + + "#; + let log = parse_gpx(s).unwrap(); + assert_eq!(log.tracks, 3); + assert_eq!(log.names, vec!["Morning".to_string(), String::new(), String::new()]); + // sorted by time: the third track's point comes first + assert_eq!(log.points.iter().map(|p| p.track).collect::>(), vec![2, 0, 0]); + let first = log.only_track(0).unwrap(); + assert_eq!((first.points.len(), first.tracks, first.names[0].as_str()), (2, 1, "Morning")); + assert!(log.only_track(1).unwrap().points.is_empty(), "an empty track"); + assert!(log.only_track(3).is_none()); + // a different track is a different segment: never interpolated across + assert_ne!(log.points[0].segment, log.points[1].segment); + } + + #[test] + fn hostile_points_are_left_out() { + let s = r#"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + + + + + NaN + + + + "#; + let log = parse_gpx(s).unwrap(); + assert_eq!(log.points.len(), 3, "{:?}", log.points); + assert_eq!(log.untimed, 1); + assert!(log.points.iter().all(|p| p.latitude.is_finite() && p.longitude.is_finite())); + assert_eq!((log.points[1].latitude, log.points[1].elevation), (90.0, None), "a NaN elevation is no elevation"); + // out of order in the file, in order in the log + assert_eq!((log.points[0].latitude, log.points[2].latitude), (6.0, 5.0)); + assert_eq!(log.names[0].chars().count(), MAX_NAME_CHARS); + // a single point: located at its own time only (within the gap) + let one = parse_gpx(r#""#).unwrap(); + assert_eq!(one.span(), Some((one.points[0].time, one.points[0].time))); + assert!(one.locate(one.points[0].time + 10.0, 60.0).is_some()); + assert!(one.locate(f64::NAN, 60.0).is_none()); } } diff --git a/crates/metadata/src/iptc.rs b/crates/metadata/src/iptc.rs index a398c6fc4..dbde97892 100644 --- a/crates/metadata/src/iptc.rs +++ b/crates/metadata/src/iptc.rs @@ -13,6 +13,67 @@ fn decode(b: &[u8], utf8: bool) -> String { } } +fn decimal_pair(bytes: &[u8]) -> Option { + let [tens, ones] = bytes else { return None }; + if !tens.is_ascii_digit() || !ones.is_ascii_digit() { + return None; + } + Some(u16::from(*tens - b'0') * 10 + u16::from(*ones - b'0')) +} + +fn parse_created(date: Option<&str>, time: Option<&str>) -> Option { + let date = date.filter(|value| value.len() == 8 && value.bytes().all(|byte| byte.is_ascii_digit()))?; + let time = time.filter(|value| !value.is_empty()); + if let Some(time) = time { + let bytes = time.as_bytes(); + let clock = bytes.get(..6)?; + if !clock.iter().all(|byte| byte.is_ascii_digit()) { + return None; + } + let hour = decimal_pair(clock.get(..2)?)?; + let minute = decimal_pair(clock.get(2..4)?)?; + let second = decimal_pair(clock.get(4..6)?)?; + if hour > 23 || minute > 59 || second > 59 { + return None; + } + match bytes.len() { + // IPTC-IIM permits a local wall-clock form, but without an offset + // it is ambiguous; retain the date and omit this time. + 6 => {} + 11 => { + let offset = bytes.get(6..11)?; + if !matches!(offset.first().copied(), Some(b'+' | b'-')) || !offset.get(1..)?.iter().all(|byte| byte.is_ascii_digit()) { + return None; + } + let offset_hour = decimal_pair(offset.get(1..3)?)?; + let offset_minute = decimal_pair(offset.get(3..5)?)?; + if offset_hour > 18 || offset_minute > 59 || (offset_hour == 18 && offset_minute != 0) { + return None; + } + } + _ => return None, + } + } + + let mut iso = format!("{}-{}-{}", date.get(..4)?, date.get(4..6)?, date.get(6..8)?); + if let Some(time) = time.filter(|time| time.len() == 11) { + let bytes = time.as_bytes(); + iso.push('T'); + iso.push_str(time.get(..2)?); + iso.push(':'); + iso.push_str(time.get(2..4)?); + iso.push(':'); + iso.push_str(time.get(4..6)?); + if bytes.len() == 11 { + let offset = time.get(6..)?; + iso.push_str(offset.get(..3)?); + iso.push(':'); + iso.push_str(offset.get(3..)?); + } + } + DateTime::parse_iso(&iso) +} + /// Parse an IIM stream into the fields [`Metadata`] carries (title, keywords, artist, copyright, caption, /// creation date/time). pub fn parse_iptc(b: &[u8]) -> Metadata { @@ -57,16 +118,7 @@ pub fn parse_iptc(b: &[u8]) -> Metadata { } i = at + size; } - if let Some(d) = date.filter(|d| d.len() == 8 && d.bytes().all(|c| c.is_ascii_digit())) { - let mut s = format!("{}-{}-{}", &d[..4], &d[4..6], &d[6..8]); - if let Some(t) = time.filter(|t| t.is_ascii() && t.len() >= 6 && t[..6].bytes().all(|c| c.is_ascii_digit())) { - s += &format!("T{}:{}:{}", &t[..2], &t[2..4], &t[4..6]); - if t.len() >= 11 { - s += &format!("{}:{}", &t[6..9], &t[9..11]); - } - } - m.capture_time = DateTime::parse_iso(&s); - } + m.capture_time = parse_created(date.as_deref(), time.as_deref()); m } @@ -119,6 +171,61 @@ pub(crate) mod tests { assert_eq!(m.capture_time.unwrap().to_iso(), "2021-03-04T10:11:12+01:00"); } + fn capture_time(time: Option<&[u8]>) -> Metadata { + let mut iim = Vec::new(); + ds(&mut iim, 2, 55, b"20260101"); + if let Some(time) = time { + ds(&mut iim, 2, 60, time); + } + ds(&mut iim, 2, 5, b"kept title"); + ds(&mut iim, 2, 25, b"kept keyword"); + parse_iptc(&iim) + } + + #[test] + fn iptc_time_created_rejects_incomplete_or_trailing_text() { + for time in [ + &b"121314+01"[..], + b"121314junk", + b"12A314", + b"12131499", + b"121314+0100junk", + b"121314+", + b"121314+010", + b"121314+01000", + b"12345", + b"240000", + b"250000+0100", + b"126100+0100", + b"235960", + b"235960+0000", + b"121314+1801", + ] { + let metadata = capture_time(Some(time)); + assert_eq!(metadata.capture_time, None, "accepted malformed Time Created {time:?}"); + assert_eq!(metadata.title.as_deref(), Some("kept title"), "unrelated metadata was lost for {time:?}"); + assert_eq!(metadata.keywords, ["kept keyword"], "unrelated metadata was lost for {time:?}"); + } + } + + #[test] + fn iptc_time_created_preserves_supported_forms() { + let cases: &[(Option<&[u8]>, Option<&str>)] = &[ + (None, Some("2026-01-01T00:00:00")), + (Some(b""), Some("2026-01-01T00:00:00")), + (Some(b" \t"), Some("2026-01-01T00:00:00")), + (Some(b"121314"), Some("2026-01-01T00:00:00")), + (Some(b"121314+0100"), Some("2026-01-01T12:13:14+01:00")), + (Some(b"121314-0530"), Some("2026-01-01T12:13:14-05:30")), + (Some(b"121314+1800"), Some("2026-01-01T12:13:14+18:00")), + (Some(b" 121314+0000 \0"), Some("2026-01-01T12:13:14+00:00")), + ]; + for (time, expected) in cases { + let metadata = capture_time(*time); + assert_eq!(metadata.capture_time.map(|value| value.to_iso()).as_deref(), *expected, "Time Created {time:?}"); + } + } + #[test] fn non_utf8_text_drops_trailing_nul() { let mut v = Vec::new(); diff --git a/crates/metadata/src/lib.rs b/crates/metadata/src/lib.rs index 7771934c8..1bc9660b8 100644 --- a/crates/metadata/src/lib.rs +++ b/crates/metadata/src/lib.rs @@ -36,7 +36,7 @@ pub mod tags; mod xmp; mod xmp_merge; -pub use container::{Embedded, embedded, jpeg_segments, png_chunks, webp_chunks}; +pub use container::{Embedded, embedded, jpeg_segments, jpeg_segments_with_all_xmp, png_chunks, webp_chunks}; pub use datetime::{DatePrecision, DateTime}; pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, try_read_exif_with, write_exif}; pub use focus::FocusPoint; @@ -45,7 +45,9 @@ pub use geom::{Orientation, Point, Rect}; pub use gpx::{GpxError, Match, TrackPoint, Tracklog, parse_gpx}; pub use iptc::parse_iptc; pub use tags::{TagRow, file_tag_rows, tag_rows}; -pub use xmp::{CRS_NS, XmpData, XmpError, XmpNamespace, XmpValue, XmpWrite, parse_xmp, parse_xmp_with, write_xmp, write_xmp_with}; +pub use xmp::{ + CRS_NS, XmpData, XmpError, XmpNamespace, XmpValue, XmpWrite, parse_gps_position, parse_xmp, parse_xmp_with, write_xmp, write_xmp_with, +}; pub use xmp_merge::{MergeRules, merge_xmp}; use serde::{Deserialize, Serialize}; @@ -330,7 +332,7 @@ pub fn extract(bytes: &[u8]) -> Metadata { /// "unreadable" uses [`try_read_exif`] and [`parse_xmp`] on the blocks first. pub fn from_embedded(e: &Embedded) -> Metadata { let mut m = e.exif.as_deref().map(read_exif).unwrap_or_default(); - let xmp = e.xmp.as_deref().and_then(|xmp| parse_xmp(xmp).ok()); + let xmp = embedded_xmp(e).unwrap_or_else(|error| error.standard.map(|x| *x)); // the size the Exif states: a size only the XMP states may be the upright one, so it can't tell how the photo is stored let stored = m.width.zip(m.height); // and the orientation the Exif states: the photo is displayed by that alone, so an orientation only the XMP @@ -353,6 +355,54 @@ pub fn from_embedded(e: &Embedded) -> Metadata { m } +/// The XMP of `e` read as one packet: the standard packet, with the Extended XMP it links to +/// (`xmpNote:HasExtendedXMP`) merged in (see [`xmp::parse_xmp_pair`]). An error when the standard +/// packet is not XMP, when its link is malformed, when the linked extension is missing or +/// unreadable ([`Embedded::xmp_extended_error`]), or when there is an extension nothing links to: +/// a caller that must see all of a file's metadata (to prove it carries no location, say) refuses +/// the file then. [`from_embedded`] falls back to [`EmbeddedXmpError::standard`]. +pub fn embedded_xmp(e: &Embedded) -> Result, EmbeddedXmpError> { + let fail = |standard: Option, message: String| EmbeddedXmpError { standard: standard.map(Box::new), message }; + let Some(packet) = e.xmp.as_deref() else { + return match (&e.xmp_extended, &e.xmp_extended_error) { + (None, None) => Ok(None), + _ => Err(fail(None, "Extended XMP is present without a standard packet".into())), + }; + }; + let parsed = parse_xmp(packet).map_err(|error| fail(None, format!("embedded XMP is invalid: {error}")))?; + let mut standard = parsed.clone(); + standard.properties.remove("xmpNote:HasExtendedXMP"); + standard.values.remove("xmpNote:HasExtendedXMP"); + let reference = xmp::extended_xmp_reference(&parsed).map_err(|error| fail(Some(standard.clone()), error))?; + match (reference, e.xmp_extended.as_deref()) { + (Some(guid), Some(extended)) if e.xmp_extended_guid.as_deref() == Some(guid.as_str()) => xmp::parse_xmp_pair(packet, extended, &[]) + .map(Some) + .map_err(|error| fail(Some(standard), format!("the linked Extended XMP is invalid: {error}"))), + (Some(_), _) => { + let why = e.xmp_extended_error.clone().unwrap_or_else(|| "the linked Extended XMP is missing".into()); + Err(fail(Some(standard), why)) + } + (None, Some(_)) => Err(fail(Some(standard), "Extended XMP is present but the standard packet does not link it".into())), + (None, None) => Ok(Some(standard)), + } +} + +/// Why [`embedded_xmp`] could not read a file's XMP whole. +#[derive(Clone, Debug, PartialEq)] +pub struct EmbeddedXmpError { + /// The standard packet, when it could be read: what a best-effort reader uses. + pub standard: Option>, + pub message: String, +} + +impl std::fmt::Display for EmbeddedXmpError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for EmbeddedXmpError {} + /// Turn regions drawn on a photo as it is stored onto the upright photo. MWG gives each box as a fraction of /// `AppliedToDimensions` (`drawn_on`); for a photo whose Exif `orientation` turns it a quarter turn, some apps give the /// stored, sideways size there and boxes on the stored pixels to match (Lightroom says so per box with @@ -383,6 +433,20 @@ pub fn lens_spec_name(spec: [f64; 4]) -> Option { Some(format!("{focal} mm f/{aperture}")) } +/// Whether `s` has the ` mm f/` shape written by [`lens_spec_name`]. +pub fn is_lens_spec_name(s: &str) -> bool { + let Some((focal, aperture)) = s.trim().split_once(" mm f/") else { return false }; + let is_num_or_range = |part: &str| { + let mut pieces = part.split('-'); + let Some(first) = pieces.next() else { return false }; + let second = pieces.next(); + pieces.next().is_none() + && first.parse::().is_ok_and(|v| v.is_finite() && v > 0.0) + && second.is_none_or(|b| b.parse::().is_ok_and(|v| v.is_finite() && v > 0.0)) + }; + is_num_or_range(focal) && is_num_or_range(aperture) +} + /// Lenient number parsing used by XMP/IPTC: `"28/10"`, `"2.8"`, `"+0.7"`. pub(crate) fn parse_number(s: &str) -> Option { let s = s.trim(); @@ -408,6 +472,62 @@ mod tests { assert_eq!(lens_spec_name([f64::NAN, 50.0, 1.8, 1.8]), None); } + #[test] + fn lens_spec_names_are_recognised() { + for spec in [[50.0, 50.0, 1.8, 1.8], [18.0, 135.0, 3.5, 5.6]] { + assert!(is_lens_spec_name(&lens_spec_name(spec).unwrap())); + } + assert!(is_lens_spec_name(" 24.0-70.0 mm f/2.8 ")); + for not in ["EF 50mm f/1.8", "50 mm f/", "0 mm f/2.8", "50-60-70 mm f/2.8", "nan mm f/2", "50 mm f/1.8-"] { + assert!(!is_lens_spec_name(not), "{not}"); + } + } + + #[test] + fn from_embedded_preserves_exif_iptc_xmp_precedence() { + let exif = Metadata { + make: Some("Example".into()), + model: Some("Camera".into()), + lens_model: Some("35mm F2".into()), + artist: Some("EXIF artist".into()), + orientation: Some(Orientation::from_exif(6)), + exposure_time: Some(1.0 / 125.0), + ..Default::default() + }; + let packet = write_xmp(&Metadata { + artist: Some("XMP artist".into()), + caption: Some("XMP caption".into()), + rating: Some(4), + keywords: vec!["xmp-keyword".into()], + ..Default::default() + }); + let mut iptc = Vec::new(); + for (dataset, value) in [(5u8, "IPTC title"), (120u8, "IPTC caption")] { + iptc.extend_from_slice(&[0x1c, 2, dataset]); + iptc.extend_from_slice(&u16::try_from(value.len()).unwrap().to_be_bytes()); + iptc.extend_from_slice(value.as_bytes()); + } + let blocks = Embedded { exif: Some(write_exif(&exif)), iptc: Some(iptc), xmp: Some(packet.clone()), ..Default::default() }; + let parsed = from_embedded(&blocks); + assert_eq!(parsed.make, exif.make); + assert_eq!(parsed.model, exif.model); + assert_eq!(parsed.lens_model, exif.lens_model); + assert_eq!(parsed.orientation, exif.orientation); + assert_eq!(parsed.exposure_time, exif.exposure_time); + assert_eq!(parsed.title.as_deref(), Some("IPTC title")); + assert_eq!(parsed.caption.as_deref(), Some("XMP caption")); + assert_eq!(parsed.artist.as_deref(), Some("XMP artist")); + assert_eq!(parsed.rating, Some(4)); + assert_eq!(parsed.keywords, vec!["xmp-keyword"]); + assert_eq!(blocks.xmp.as_deref(), Some(packet.as_str())); + } + + #[test] + fn from_embedded_ignores_damaged_blocks() { + let blocks = Embedded { exif: Some(vec![1, 2, 3]), iptc: Some(vec![0x1c, 2]), xmp: Some(" Vec { + let len = u16::try_from(payload.len() + 2).expect("test APP1 payload fits u16"); + let mut segment = vec![0xff, marker]; + segment.extend_from_slice(&len.to_be_bytes()); + segment.extend_from_slice(payload); + segment + } + + fn xmp_jpeg(standard: &str, guid: &str, extension: &str, chunks: &[(u32, &[u8])]) -> Vec { + let mut jpeg = vec![0xff, 0xd8]; + let mut packet = JPEG_XMP_SIG.to_vec(); + packet.extend_from_slice(standard.as_bytes()); + jpeg.extend(jpeg_segment(0xe1, &packet)); + for (offset, data) in chunks { + let mut payload = JPEG_XMP_EXT_SIG.to_vec(); + payload.extend_from_slice(guid.as_bytes()); + payload.extend_from_slice(&(extension.len() as u32).to_be_bytes()); + payload.extend_from_slice(&offset.to_be_bytes()); + payload.extend_from_slice(data); + jpeg.extend(jpeg_segment(0xe1, &payload)); + } + jpeg + } + + fn append_extended_chunk(jpeg: &mut Vec, guid: &str, total_length: u32, offset: u32, data: &[u8]) { + let mut payload = JPEG_XMP_EXT_SIG.to_vec(); + payload.extend_from_slice(guid.as_bytes()); + payload.extend_from_slice(&total_length.to_be_bytes()); + payload.extend_from_slice(&offset.to_be_bytes()); + payload.extend_from_slice(data); + jpeg.extend(jpeg_segment(0xe1, &payload)); + } + + fn xmp_document(body: &str) -> String { + format!( + r#""# + ) + } + + fn referenced_standard(guid: &str, extra: &str) -> String { + xmp_document(&format!(r#"xmlns:reference="http://ns.adobe.com/xmp/note/" reference:HasExtendedXMP="{guid}" {extra}"#)) + } + + fn split_chunks(packet: &str) -> [(u32, &[u8]); 2] { + let split = packet.len() / 2; + let (first, second) = packet.as_bytes().split_at(split); + [(split as u32, second), (0, first)] + } + + fn raf_with_jpeg(jpeg: &[u8]) -> Vec { + let offset = 120u32; + let length = u32::try_from(jpeg.len()).expect("test JPEG fits RAF length"); + let mut raf = b"FUJIFILMCCD-RAW 0201FF000000TEST".to_vec(); + raf.resize(offset as usize, 0); + raf[84..88].copy_from_slice(&offset.to_be_bytes()); + raf[88..92].copy_from_slice(&length.to_be_bytes()); + raf.extend_from_slice(jpeg); + raf + } + + #[test] + fn extended_xmp_gps_reaches_extractor() { + let guid = "A1234567890123456789012345678901"; + let extension = xmp_document(r#"xmlns:exif="http://ns.adobe.com/exif/1.0/" exif:GPSLatitude="45,0N" exif:GPSLongitude="7,0E""#); + let standard = referenced_standard(guid, ""); + let jpeg = xmp_jpeg(&standard, guid, &extension, &split_chunks(&extension)); + let gps = extract(&jpeg).gps.expect("the extension GPS reaches extraction"); + assert_eq!((gps.latitude, gps.longitude), (45.0, 7.0)); + } + + #[test] + fn extended_xmp_caption_reaches_extractor() { + let guid = "B1234567890123456789012345678901"; + let caption = "multi-chunk caption ".repeat(160); + let extension = xmp_document(&format!(r#"xmlns:dc="http://purl.org/dc/elements/1.1/">{caption}"#)); + let standard = referenced_standard(guid, ""); + let jpeg = xmp_jpeg(&standard, guid, &extension, &split_chunks(&extension)); + assert_eq!(extract(&jpeg).caption.as_deref(), Some(caption.trim())); + } + + #[test] + fn extended_xmp_split_gps_properties_are_combined() { + let guid = "C1234567890123456789012345678901"; + let standard = xmp_document(&format!( + r#"xmlns:note="http://ns.adobe.com/xmp/note/" xmlns:exif="http://ns.adobe.com/exif/1.0/" note:HasExtendedXMP="{guid}" exif:GPSLatitude="45,0N""# + )); + let extension = xmp_document(r#"xmlns:exif="http://ns.adobe.com/exif/1.0/" exif:GPSLongitude="7,0E""#); + let jpeg = xmp_jpeg(&standard, guid, &extension, &split_chunks(&extension)); + let gps = extract(&jpeg).gps.expect("GPS properties across packets compose"); + assert_eq!((gps.latitude, gps.longitude), (45.0, 7.0)); + } + + #[test] + fn extended_xmp_structured_values_are_composed() { + let guid = "F1234567890123456789012345678901"; + let standard = xmp_document(&format!( + r#"xmlns:note="http://ns.adobe.com/xmp/note/" xmlns:exif="http://ns.adobe.com/exif/1.0/" note:HasExtendedXMP="{guid}">True"# + )); + let extension = + xmp_document(r#"xmlns:exif="http://ns.adobe.com/exif/1.0/">2"#); + let jpeg = xmp_jpeg(&standard, guid, &extension, &split_chunks(&extension)); + assert_eq!(extract(&jpeg).flash.map(|flash| flash.raw), Some(17)); + } + + #[test] + fn extended_xmp_ignores_malformed_chunks_for_unreferenced_guids() { + let guid = "A1234567890123456789012345678901"; + let other_guid = "B1234567890123456789012345678901"; + let extension = xmp_document(r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Referenced title""#); + let mut jpeg = xmp_jpeg(&referenced_standard(guid, ""), guid, &extension, &split_chunks(&extension)); + let mut malformed = JPEG_XMP_EXT_SIG.to_vec(); + malformed.extend_from_slice(other_guid.as_bytes()); + jpeg.extend(jpeg_segment(0xe1, &malformed)); + + assert_eq!(extract(&jpeg).title.as_deref(), Some("Referenced title")); + assert_eq!(embedded_xmp(&embedded(&jpeg)).unwrap().unwrap().metadata.title.as_deref(), Some("Referenced title")); + } + + #[test] + fn extended_xmp_ignores_chunk_limit_for_unreferenced_guids() { + let guid = "C1234567890123456789012345678901"; + let other_guid = "D1234567890123456789012345678901"; + let extension = xmp_document(r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Referenced title""#); + let mut jpeg = xmp_jpeg(&referenced_standard(guid, ""), guid, &extension, &split_chunks(&extension)); + for _ in 0..4097 { + append_extended_chunk(&mut jpeg, other_guid, 1, 0, &[]); + } + + assert_eq!(embedded_xmp(&embedded(&jpeg)).unwrap().unwrap().metadata.title.as_deref(), Some("Referenced title")); + } + + #[test] + fn extended_xmp_ignores_received_byte_limit_for_unreferenced_guids() { + let guid = "E1234567890123456789012345678901"; + let other_guid = "F1234567890123456789012345678901"; + let extension = xmp_document(r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Referenced title""#); + let mut jpeg = xmp_jpeg(&referenced_standard(guid, ""), guid, &extension, &split_chunks(&extension)); + let data = vec![0; 65_200]; + for _ in 0..258 { + append_extended_chunk(&mut jpeg, other_guid, 65_200, 0, &data); + } + + assert_eq!(embedded_xmp(&embedded(&jpeg)).unwrap().unwrap().metadata.title.as_deref(), Some("Referenced title")); + } + + #[test] + fn extended_xmp_rejects_mismatched_guid_and_bad_coverage() { + let guid = "D1234567890123456789012345678901"; + let extension = xmp_document(r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Extension title""#); + let standard = referenced_standard(guid, r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Standard title""#); + let bytes = extension.as_bytes(); + let middle = bytes.len() / 2; + let inconsistent_lengths = { + let mut jpeg = vec![0xff, 0xd8]; + let mut standard_payload = JPEG_XMP_SIG.to_vec(); + standard_payload.extend_from_slice(standard.as_bytes()); + jpeg.extend(jpeg_segment(0xe1, &standard_payload)); + for (offset, declared, part) in + [(0u32, bytes.len() as u32, &bytes[..middle]), (middle as u32, (bytes.len() + 1) as u32, &bytes[middle..])] + { + let mut payload = JPEG_XMP_EXT_SIG.to_vec(); + payload.extend_from_slice(guid.as_bytes()); + payload.extend_from_slice(&declared.to_be_bytes()); + payload.extend_from_slice(&offset.to_be_bytes()); + payload.extend_from_slice(part); + jpeg.extend(jpeg_segment(0xe1, &payload)); + } + jpeg + }; + let oversized_length = { + let mut jpeg = vec![0xff, 0xd8]; + let mut standard_payload = JPEG_XMP_SIG.to_vec(); + standard_payload.extend_from_slice(standard.as_bytes()); + jpeg.extend(jpeg_segment(0xe1, &standard_payload)); + let mut payload = JPEG_XMP_EXT_SIG.to_vec(); + payload.extend_from_slice(guid.as_bytes()); + payload.extend_from_slice(&(16u32 * 1024 * 1024 + 1).to_be_bytes()); + payload.extend_from_slice(&0u32.to_be_bytes()); + payload.extend_from_slice(bytes); + jpeg.extend(jpeg_segment(0xe1, &payload)); + jpeg + }; + let foreign_namespace_reference = { + let foreign_standard = xmp_document(&format!( + r#"xmlns:note="urn:example:foreign-note" note:HasExtendedXMP="{guid}" xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Standard title""# + )); + xmp_jpeg(&foreign_standard, guid, &extension, &split_chunks(&extension)) + }; + let mut truncated_segment = xmp_jpeg(&standard, guid, &extension, &split_chunks(&extension)); + truncated_segment.pop(); + + let cases = [ + xmp_jpeg(&standard, "E1234567890123456789012345678901", &extension, &split_chunks(&extension)), + xmp_jpeg(&standard, guid, &extension, &[(0, &bytes[..middle]), ((middle + 1) as u32, &bytes[middle..])]), + xmp_jpeg(&standard, guid, &extension, &[(0, &bytes[..middle]), ((middle - 1) as u32, &bytes[middle..])]), + xmp_jpeg(&standard, guid, &extension, &[(0, bytes), (0, bytes)]), + xmp_jpeg(&standard, guid, &extension, &[(0, &bytes[..middle])]), + inconsistent_lengths, + oversized_length, + truncated_segment, + foreign_namespace_reference, + ]; + for (index, jpeg) in cases.iter().enumerate() { + // the last case links nothing (a foreign HasExtendedXMP): its chunks stay visible as an unlinked + // extension, as before, and embedded_xmp still refuses it below + if index + 1 < cases.len() { + assert!(embedded(jpeg).xmp_extended.is_none(), "malformed extension case {index} was exposed"); + } + assert_eq!(extract(jpeg).title.as_deref(), Some("Standard title"), "standard XMP remains readable"); + assert!(embedded_xmp(&embedded(jpeg)).is_err(), "embedded_xmp must reject malformed extension case {index}"); + let error = embedded_xmp(&embedded(jpeg)).expect_err("invalid referenced extension is reported"); + assert_eq!(error.standard.as_deref().and_then(|xmp| xmp.metadata.title.as_deref()), Some("Standard title")); + } + } + + #[test] + fn standard_only_jpeg_xmp_still_extracts() { + let standard = xmp_document(r#"xmlns:dc="http://purl.org/dc/elements/1.1/" dc:title="Standard-only title""#); + let mut jpeg = vec![0xff, 0xd8]; + let mut packet = JPEG_XMP_SIG.to_vec(); + packet.extend_from_slice(standard.as_bytes()); + jpeg.extend(jpeg_segment(0xe1, &packet)); + assert_eq!(extract(&jpeg).title.as_deref(), Some("Standard-only title")); + } + + #[test] + fn embedded_xmp_rejects_malformed_xmp_but_extract_remains_best_effort() { + let mut jpeg = vec![0xff, 0xd8]; + let mut packet = JPEG_XMP_SIG.to_vec(); + packet.extend_from_slice(b">, + /// True when flattened properties were omitted after reaching the cumulative key-size limit. + pub properties_truncated: bool, /// Every top-level property with its full structure (arrays of structs such as local /// corrections, which [`XmpData::properties`] flattens). pub values: BTreeMap, /// The `x:xmptk` attribute of `x:xmpmeta` (older packets: `x:xaptk` of `x:xapmeta`), the name /// of the program that wrote the packet. [`write_xmp_with`] writes [`XmpWrite::toolkit`] there. pub toolkit: Option, + /// True when the parsed tree, before flattening or merging repeated properties, names an Exif + /// GPS property anywhere or holds a place property (see `LOCATION_PROPERTIES`) with non-blank + /// text anywhere, including inside array items that [`XmpData::properties`] flattens to their + /// text and repeated structs that [`XmpData::values`] keeps only once. + pub names_location: bool, +} + +/// Properties that name a place; a blank one is not a location. +const LOCATION_PROPERTIES: [&str; 7] = [ + "Iptc4xmpCore:Location", + "Iptc4xmpCore:CountryCode", + "photoshop:City", + "photoshop:State", + "photoshop:Country", + "Iptc4xmpExt:LocationShown", + "Iptc4xmpExt:LocationCreated", +]; + +impl XmpData { + /// Whether the XMP inventory names GPS or a non-empty place field. LocationShown and + /// LocationCreated are IPTC Extension structures; their flattened array entries can be empty + /// strings even when their struct children contain a city or coordinates, so inspect their + /// retained structured value and flattened child fields as well. Callers that must prove + /// absence must check [`Self::properties_truncated`] first. + pub fn has_location_metadata(&self) -> bool { + const IPTC_LOCATION_STRUCTS: [&str; 2] = ["Iptc4xmpExt:LocationShown", "Iptc4xmpExt:LocationCreated"]; + // Every flattened child of one LocationShown/LocationCreated root shares its answer. + let mut checked_roots: BTreeMap<&str, bool> = BTreeMap::new(); + if self.names_location { + return true; + } + + for (key, values) in &self.properties { + let parts = key.split('/'); + if parts.clone().any(is_gps_name) { + // Even an isolated/blank GPS property is not proof that GPS was removed. + return true; + } + + let Some(location_part) = parts.clone().find(|part| LOCATION_PROPERTIES.contains(part)) else { + continue; + }; + + if values.iter().any(|value| !value.trim().is_empty()) { + return true; + } + if IPTC_LOCATION_STRUCTS.contains(&location_part) { + // The scope is the path up to the location field itself, so text elsewhere in an + // enclosing struct doesn't count as a place. + let scope_len = + key.split('/').take_while(|part| *part != location_part).map(|part| part.len() + 1).sum::() + location_part.len(); + let scope = key.get(..scope_len).unwrap_or(key); + let scope_has_text = *checked_roots.entry(scope).or_insert_with(|| self.location_scope_has_text(scope)); + if scope_has_text { + return true; + } + } + } + + false + } + + /// Whether a LocationShown/LocationCreated field (a `/`-separated path from a top-level + /// property) holds text in its retained structured value or any flattened child. `values` + /// keeps the first text-bearing occurrence across RDF descriptions, while flattened child + /// properties retain every occurrence. + fn location_scope_has_text(&self, scope: &str) -> bool { + let mut parts = scope.split('/'); + let structured = parts.next().and_then(|root| self.values.get(root)).and_then(|value| parts.try_fold(value, |value, part| value.field(part))); + if structured.is_some_and(value_has_text) { + return true; + } + let child_prefix = format!("{scope}/"); + self.properties + .range::((std::ops::Bound::Included(child_prefix.as_str()), std::ops::Bound::Unbounded)) + .take_while(|(candidate, _)| candidate.starts_with(&child_prefix)) + .any(|(_, nested_values)| nested_values.iter().any(|value| !value.trim().is_empty())) + } + + /// The GUID of the JPEG Extended XMP continuation the packet announces + /// (`xmpNote:HasExtendedXMP`), matched by local name so an undeclared or foreign prefix can't + /// hide the announcement. `Some("")` when the announcement is blank. + pub fn extended_xmp_guid(&self) -> Option<&str> { + self.properties + .iter() + .find(|(key, _)| !key.contains('/') && key.rsplit([':', '}']).next() == Some("HasExtendedXMP")) + .map(|(_, values)| values.first().map_or("", |value| value.trim())) + } } impl XmpData { @@ -143,15 +235,75 @@ impl XmpValue { } } +/// Whether a structured value holds any non-blank text. A tree larger than the parser's node +/// budget counts as text-bearing (the conservative answer for a privacy check): parsed trees are +/// capped at MAX_NODES, so only a caller-built `XmpData` can reach it, and the walk stays bounded. +fn value_has_text(value: &XmpValue) -> bool { + let mut pending = Vec::new(); + if pending.try_reserve(1).is_err() { + return true; + } + pending.push(value); + let mut visited = 0usize; + while let Some(value) = pending.pop() { + visited = visited.saturating_add(1); + if visited > MAX_NODES { + return true; + } + let children = match value { + XmpValue::Text(text) if !text.trim().is_empty() => return true, + XmpValue::Text(_) => continue, + XmpValue::Array(items) => items.len(), + XmpValue::Struct(fields) => fields.len(), + }; + if pending.len().saturating_add(children) > MAX_NODES || pending.try_reserve(children).is_err() { + return true; + } + match value { + XmpValue::Array(items) => pending.extend(items), + XmpValue::Struct(fields) => pending.extend(fields.values()), + XmpValue::Text(_) => {} + } + } + false +} + +/// Insert a property into a struct or description map, keeping an earlier text-bearing value when +/// the same name repeats with a blank one: an IPTC location struct repeated with an empty +/// `rdf:li` must not hide the place an export's Remove Location has to refuse. `has_text` +/// remembers each key's answer so every value is walked at most once. +fn insert_keeping_text(map: &mut BTreeMap, has_text: &mut BTreeMap, key: String, value: XmpValue) { + match map.entry(key) { + std::collections::btree_map::Entry::Vacant(slot) => { + slot.insert(value); + } + std::collections::btree_map::Entry::Occupied(mut slot) => { + let existing_has_text = match has_text.get(slot.key()) { + Some(known) => *known, + None => value_has_text(slot.get()), + }; + if existing_has_text { + has_text.insert(slot.key().clone(), true); + return; + } + let new_has_text = value_has_text(&value); + has_text.insert(slot.key().clone(), new_has_text); + slot.insert(value); + } + } +} + +/// A description or struct node as a map of its fields (attributes and child elements). fn node_struct(n: &Node) -> XmpValue { let mut m = BTreeMap::new(); + let mut has_text = BTreeMap::new(); for (k, v) in &n.attrs { if !is_rdf_meta_attr(k) { - m.insert(k.clone(), XmpValue::Text(v.clone())); + insert_keeping_text(&mut m, &mut has_text, k.clone(), XmpValue::Text(v.clone())); } } for c in &n.children { - m.insert(c.name.clone(), node_value(c)); + insert_keeping_text(&mut m, &mut has_text, c.name.clone(), node_value(c)); } XmpValue::Struct(m) } @@ -193,6 +345,18 @@ pub(crate) const MAX_OPAQUE_NAME_BYTES: usize = 8 * 1024 * 1024; /// retains its prefix once per property: node limits alone cannot bound that duplication. const MAX_KEY_BYTES: usize = 1 << 20; +/// Most namespace declarations in scope at once. Prefix lookup scans them, so declarations times the names of a +/// start tag is the work; real packets declare a few dozen. +const MAX_NS_DECLS: usize = 512; + +/// Refuses a scope stack holding more than [`MAX_NS_DECLS`] declarations (one count per start tag over a stack at most [`MAX_DEPTH`] deep). +pub(crate) fn check_scopes(scopes: &[Vec<(String, String)>]) -> Result<(), XmpError> { + if scopes.iter().map(Vec::len).sum::() > MAX_NS_DECLS { + return Err(XmpError::TooComplex); + } + Ok(()) +} + /// Names canonicalised by namespace URI (see [`NAMESPACES`]); conflicting foreign prefixes are opaque. pub(crate) fn canonical_name( qname: &str, @@ -265,6 +429,12 @@ fn xml_safe(s: &str) -> String { s.chars().filter(|&c| is_xml_char(c)).collect() } +/// Escaped text with each carriage return as ` `: XML end-of-line handling turns a literal one +/// into a line feed on read. +fn keep_cr(escaped: std::borrow::Cow<'_, str>) -> String { + escaped.replace('\r', " ") +} + /// Index of the `;` that ends a reference starting at `rest` (its `&`). The scan stops at the first /// byte that cannot appear in a name or a numeric reference, so zero-padded references such as /// `a` are found and each run of name bytes is read once, not once per `&`. @@ -323,6 +493,7 @@ fn parse_tree(s: &str, extra: &[XmpNamespace]) -> Result { } } scopes.push(scope); + check_scopes(&scopes)?; let name = canonical_name(&String::from_utf8_lossy(e.name().as_ref()), &scopes, extra, &mut opaque_budget)?; let attrs = raw_attrs.into_iter().map(|(k, v)| Ok((canonical_name(&k, &scopes, extra, &mut opaque_budget)?, v))).collect:: bool { matches!(k, "rdf:about" | "rdf:ID" | "rdf:nodeID" | "xml:lang" | "rdf:parseType" | "rdf:resource" | "rdf:datatype") || k.starts_with("xmlns") } -fn collect_description(d: &Node, prefix: &str, props: &mut BTreeMap>, budget: &mut usize) { +fn collect_description(d: &Node, prefix: &str, props: &mut BTreeMap>, budget: &mut usize, truncated: &mut bool) { for (k, v) in &d.attrs { + if is_rdf_meta_attr(k) { + continue; + } // the budget is checked before the path is built, not after - if is_rdf_meta_attr(k) || *budget < prefix.len().saturating_add(k.len()) { + if *budget < prefix.len().saturating_add(k.len()) { + *truncated = true; continue; } let key = format!("{prefix}{k}"); - if take_key_bytes(&key, budget) { + if take_key_bytes(&key, budget, truncated) { props.entry(key).or_default().push(v.clone()); } } for c in &d.children { - collect_property(c, prefix, props, budget); + collect_property(c, prefix, props, budget, truncated); } } /// Charge one flattened key's length to the key budget; `false` when the budget is spent (the -/// property is then left out, like other elements a limit has cut). -fn take_key_bytes(key: &str, budget: &mut usize) -> bool { +/// property is then left out, like other elements a limit has cut) and the map is marked partial. +fn take_key_bytes(key: &str, budget: &mut usize, dropped: &mut bool) -> bool { let n = key.len(); if *budget < n { + *dropped = true; return false; } *budget -= n; true } -fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap>, budget: &mut usize) { +fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap>, budget: &mut usize, truncated: &mut bool) { // a path that can't fit the budget isn't built (a long ancestor path shared by many children // would otherwise be copied for each of them after the budget is spent) if *budget < prefix.len().saturating_add(p.name.len()) { + *truncated = true; return; } let key = format!("{prefix}{}", p.name); @@ -432,7 +609,7 @@ fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap = p.attrs.iter().filter(|(k, _)| !is_rdf_meta_attr(k)).collect(); if !field_attrs.is_empty() && p.children.is_empty() && p.text.trim().is_empty() { for (k, v) in field_attrs { let full = format!("{struct_prefix}{k}"); - if take_key_bytes(&full, budget) { + if take_key_bytes(&full, budget, truncated) { props.entry(full).or_default().push(v.clone()); } } return; } let v = p.attr("rdf:resource").map(str::to_string).unwrap_or_else(|| p.text.trim().to_string()); - if take_key_bytes(&key, budget) { + if take_key_bytes(&key, budget, truncated) { props.entry(key).or_default().push(v); } } @@ -479,21 +656,95 @@ fn find_descriptions<'a>(n: &'a Node, out: &mut Vec<&'a Node>) { } } -/// XMP GPS coordinate `DDD,MM,SSk` or `DDD,MM.mmk` (k = N/S/E/W) → signed degrees. -fn parse_gps_coord(s: &str) -> Option { +/// Whether a canonical property name is an Exif GPS property. +fn is_gps_name(name: &str) -> bool { + name.starts_with("exif:GPS") || name.starts_with("exifEX:GPS") +} + +/// Whether the tree names an Exif GPS property anywhere or holds a place property with non-blank +/// text anywhere. Each node is visited at most twice (a place's subtree is scanned once and not +/// walked again), and the parser caps the tree at `MAX_NODES`. +fn tree_names_location(root: &Node) -> bool { + let mut pending = vec![root]; + while let Some(node) = pending.pop() { + if is_gps_name(&node.name) { + return true; + } + for (name, value) in &node.attrs { + if is_gps_name(name) || (LOCATION_PROPERTIES.contains(&name.as_str()) && !value.trim().is_empty()) { + return true; + } + } + if LOCATION_PROPERTIES.contains(&node.name.as_str()) { + if subtree_has_text(node) { + return true; + } + // A blank place may still enclose a GPS property. + if subtree_names_gps(node) { + return true; + } + continue; + } + pending.extend(&node.children); + } + false +} + +/// Whether a node or any descendant holds non-blank text: element text, an `rdf:resource`, or a +/// field attribute (RDF syntax attributes such as `rdf:parseType` and `xml:lang` don't count). +fn subtree_has_text(root: &Node) -> bool { + let mut pending = vec![root]; + while let Some(node) = pending.pop() { + if !node.text.trim().is_empty() { + return true; + } + if node.attrs.iter().any(|(name, value)| (name == "rdf:resource" || !is_rdf_meta_attr(name)) && !value.trim().is_empty()) { + return true; + } + pending.extend(&node.children); + } + false +} + +/// Whether a node or any descendant is an Exif GPS property, as an element or an attribute. +fn subtree_names_gps(root: &Node) -> bool { + let mut pending = vec![root]; + while let Some(node) = pending.pop() { + if is_gps_name(&node.name) || node.attrs.iter().any(|(name, _)| is_gps_name(name)) { + return true; + } + pending.extend(&node.children); + } + false +} + +/// An `exif:GPSLatitude` / `exif:GPSLongitude` pair → (latitude, longitude) in signed degrees; `None` +/// unless both parse and lie on the globe (|latitude| ≤ 90, |longitude| ≤ 180, as the Exif reader checks). +pub fn parse_gps_position(lat: &str, lon: &str) -> Option<(f64, f64)> { + let (lat, lon) = (parse_gps_coord(lat, ['N', 'S'])?, parse_gps_coord(lon, ['E', 'W'])?); + ((-90.0..=90.0).contains(&lat) && (-180.0..=180.0).contains(&lon)).then_some((lat, lon)) +} + +/// XMP GPS coordinate `DDD,MM,SSk` or `DDD,MM.mmk` → signed degrees, k being `[positive, negative]` of +/// its axis (N/S or E/W, either case). The sign comes only from the letter: a negative part +/// (`-10,30N`) is malformed. +fn parse_gps_coord(s: &str, [pos, neg]: [char; 2]) -> Option { let s = s.trim(); let dir = s.chars().last()?; let body = &s[..s.len() - dir.len_utf8()]; let parts: Vec = body.split(',').map(|p| p.trim().parse::().ok()).collect::>()?; + if parts.iter().any(|p| p.is_sign_negative()) { + return None; + } let v = match parts.as_slice() { [d] => *d, - [d, m] => d + m / 60.0, - [d, m, sec] => d + m / 60.0 + sec / 3600.0, + [d, m] if (0.0..60.0).contains(m) => d + m / 60.0, + [d, m, sec] if (0.0..60.0).contains(m) && (0.0..60.0).contains(sec) => d + m / 60.0 + sec / 3600.0, _ => return None, }; let v = match dir.to_ascii_uppercase() { - 'N' | 'E' => v, - 'S' | 'W' => -v, + c if c == pos => v, + c if c == neg => -v, _ => return None, }; v.is_finite().then_some(v) @@ -581,6 +832,183 @@ fn fmt_gps_coord(v: f64, pos: char, neg: char) -> String { format!("{},{:.6}{}", d as i64, m, if v < 0.0 { neg } else { pos }) } +// Pixel dimensions are integers. Keep decimal coefficients as digits so wide rational components +// remain exact and fractions that `f64` rounds to an integer can still be rejected. +struct ExactDecimal { + negative: bool, + coefficient: String, + exponent: i64, +} + +struct ExactRatio { + negative: bool, + numerator: String, + denominator: String, + exponent: i64, +} + +fn exact_decimal(value: &str) -> Option { + let value = value.trim(); + let (negative, value) = if let Some(value) = value.strip_prefix('-') { + (true, value) + } else if let Some(value) = value.strip_prefix('+') { + (false, value) + } else { + (false, value) + }; + let (mantissa, exponent) = if let Some((mantissa, exponent)) = value.split_once('e').or_else(|| value.split_once('E')) { + if exponent.contains('e') || exponent.contains('E') { + return None; + } + (mantissa, exponent.parse::().ok()?) + } else { + (value, 0) + }; + let (whole, fraction) = mantissa.split_once('.').unwrap_or((mantissa, "")); + if fraction.contains('.') { + return None; + } + let digits = whole.bytes().chain(fraction.bytes()); + if !digits.clone().all(|digit| digit.is_ascii_digit()) { + return None; + } + let count = whole.len().checked_add(fraction.len())?; + if count == 0 { + return None; + } + let leading = digits.clone().take_while(|digit| *digit == b'0').count(); + if leading == count { + return Some(ExactDecimal { negative, coefficient: "0".to_owned(), exponent: 0 }); + } + let trailing = digits.clone().rev().take_while(|digit| *digit == b'0').count(); + let significant = count.checked_sub(leading)?.checked_sub(trailing)?; + let mut coefficient = String::with_capacity(significant); + for digit in digits.skip(leading).take(significant) { + coefficient.push(char::from(digit)); + } + let exponent = exponent.checked_sub(i64::try_from(fraction.len()).ok()?)?.checked_add(i64::try_from(trailing).ok()?)?; + Some(ExactDecimal { negative, coefficient, exponent }) +} + +fn exact_ratio(numerator: ExactDecimal, denominator: ExactDecimal) -> Option { + if denominator.coefficient == "0" { + return None; + } + Some(ExactRatio { + negative: numerator.negative ^ denominator.negative, + numerator: numerator.coefficient, + denominator: denominator.coefficient, + exponent: numerator.exponent.checked_sub(denominator.exponent)?, + }) +} + +fn multiply_decimal_by_u32(value: &str, multiplier: u32) -> String { + if multiplier == 0 { + return "0".to_owned(); + } + let mut digits = Vec::with_capacity(value.len().saturating_add(10)); + let mut carry = 0u64; + for digit in value.bytes().rev() { + let product = u64::from(digit - b'0') * u64::from(multiplier) + carry; + digits.push(b'0' + (product % 10) as u8); + carry = product / 10; + } + while carry != 0 { + digits.push(b'0' + (carry % 10) as u8); + carry /= 10; + } + digits.reverse(); + digits.into_iter().map(char::from).collect() +} + +fn decimal_significand(value: &str) -> Option { + let mut prefix = 0u64; + let mut digits = 0u32; + for digit in value.bytes().take(17) { + prefix = prefix.checked_mul(10)?.checked_add(u64::from(digit.checked_sub(b'0')?))?; + digits = digits.checked_add(1)?; + } + let exponent = i32::try_from(digits.checked_sub(1)?).ok()?; + Some(prefix as f64 / 10.0_f64.powi(exponent)) +} + +fn compare_scaled_decimal(left: &str, left_exponent: i64, right: &str, right_exponent: i64) -> Option { + if left == "0" { + return Some(if right == "0" { std::cmp::Ordering::Equal } else { std::cmp::Ordering::Less }); + } + if right == "0" { + return Some(std::cmp::Ordering::Greater); + } + let left_magnitude = i128::try_from(left.len()).ok()?.checked_add(i128::from(left_exponent))?; + let right_magnitude = i128::try_from(right.len()).ok()?.checked_add(i128::from(right_exponent))?; + match left_magnitude.cmp(&right_magnitude) { + std::cmp::Ordering::Equal => {} + ordering => return Some(ordering), + } + let length = left.len().max(right.len()); + for (left_digit, right_digit) in left.bytes().chain(std::iter::repeat(b'0')).zip(right.bytes().chain(std::iter::repeat(b'0'))).take(length) { + match left_digit.cmp(&right_digit) { + std::cmp::Ordering::Equal => {} + ordering => return Some(ordering), + } + } + Some(std::cmp::Ordering::Equal) +} + +fn exact_ratio_to_u32(value: ExactRatio) -> Option { + if value.negative || value.denominator == "0" { + return None; + } + if value.numerator == "0" { + return Some(0); + } + if value.exponent < -128 || value.exponent > 128 { + return None; + } + let numerator_length = i128::try_from(value.numerator.len()).ok()?; + let denominator_length = i128::try_from(value.denominator.len()).ok()?; + let decimal_scale = numerator_length.checked_add(i128::from(value.exponent))?.checked_sub(denominator_length)?; + if !(0..=10).contains(&decimal_scale) { + return None; + } + // The prefix only estimates a candidate. Acceptance compares the full exact coefficients below. + let approximate = + decimal_significand(&value.numerator)? / decimal_significand(&value.denominator)? * 10.0_f64.powi(i32::try_from(decimal_scale).ok()?); + if !approximate.is_finite() { + return None; + } + let integer = approximate.round().clamp(0.0, f64::from(u32::MAX)) as u32; + let product = multiply_decimal_by_u32(&value.denominator, integer); + let ordering = if product == "0" { + compare_scaled_decimal(&value.numerator, value.exponent, "0", 0)? + } else { + let trailing_zeroes = product.bytes().rev().take_while(|digit| *digit == b'0').count(); + let product_exponent = i64::try_from(trailing_zeroes).ok()?; + compare_scaled_decimal(&value.numerator, value.exponent, product.trim_end_matches('0'), product_exponent)? + }; + (ordering == std::cmp::Ordering::Equal).then_some(integer) +} + +fn exact_u32_from_text(value: &str) -> Option { + let value = value.trim(); + let ratio = if let Some((numerator, denominator)) = value.split_once('/') { + exact_ratio(exact_decimal(numerator)?, exact_decimal(denominator)?)? + } else { + let value = exact_decimal(value)?; + ExactRatio { negative: value.negative, numerator: value.coefficient, denominator: "1".to_owned(), exponent: value.exponent } + }; + exact_ratio_to_u32(ratio) +} + +/// A positive whole-number ISO, read exactly (a fraction or an out-of-range value is not one). +fn iso_from_text(value: &str) -> Option { + exact_u32_from_text(value).filter(|&iso| iso > 0) +} + +fn dimension_candidate(first: impl Fn(&str) -> Option, key: &str) -> Option { + first(key).and_then(|value| exact_u32_from_text(&value)) +} + /// Parse an XMP packet (with or without the `` wrapper / `x:xmpmeta` element). pub fn parse_xmp(s: &str) -> Result { parse_xmp_with(s, &[]) @@ -590,18 +1018,36 @@ pub fn parse_xmp(s: &str) -> Result { pub fn parse_xmp_with(s: &str, extra: &[XmpNamespace]) -> Result { let s = s.trim_start_matches('\u{feff}'); let tree = parse_tree(s, extra)?; + let names_location = tree_names_location(&tree); let mut descs = Vec::new(); find_descriptions(&tree, &mut descs); let mut props = BTreeMap::new(); let mut values = BTreeMap::new(); + let mut properties_truncated = false; + let mut value_has_text_by_key = BTreeMap::new(); // One budget for the whole document: several descriptions cannot each spend a fresh one. let mut key_budget = MAX_KEY_BYTES; for d in descs { - collect_description(d, "", &mut props, &mut key_budget); + collect_description(d, "", &mut props, &mut key_budget, &mut properties_truncated); if let XmpValue::Struct(m) = node_struct(d) { - values.extend(m); + // Later descriptions can repeat a property too; the same rule keeps an earlier place. + for (key, value) in m { + insert_keeping_text(&mut values, &mut value_has_text_by_key, key, value); + } } } + let m = project(&props, &values); + let toolkit = tree + .children + .iter() + .find(|n| n.name == "x:xmpmeta" || n.name == "x:xapmeta") + .and_then(|n| n.attr("x:xmptk").or_else(|| n.attr("x:xaptk"))) + .map(str::to_string); + Ok(XmpData { metadata: m, properties: props, properties_truncated, values, toolkit, names_location }) +} + +/// The typed [`Metadata`] the flattened `props` and structured `values` of a packet state. +fn project(props: &BTreeMap>, values: &BTreeMap) -> Metadata { let first = |k: &str| props.get(k).and_then(|v| v.first()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); let num = |k: &str| first(k).and_then(|s| parse_number(&s)); let mut m = Metadata { @@ -620,9 +1066,9 @@ pub fn parse_xmp_with(s: &str, extra: &[XmpNamespace]) -> Result= 0.0).or_else(|| num("exif:PixelXDimension").filter(|v| *v >= 0.0)).map(|v| v as u32), - height: num("tiff:ImageLength").filter(|v| *v >= 0.0).or_else(|| num("exif:PixelYDimension").filter(|v| *v >= 0.0)).map(|v| v as u32), + orientation: num("tiff:Orientation").filter(|v| (1.0..=8.0).contains(v) && v.fract() == 0.0).map(|v| Orientation::from_exif(v as u16)), + width: dimension_candidate(first, "tiff:ImageWidth").or_else(|| dimension_candidate(first, "exif:PixelXDimension")), + height: dimension_candidate(first, "tiff:ImageLength").or_else(|| dimension_candidate(first, "exif:PixelYDimension")), title: first("dc:title"), caption: first("dc:description"), alt_text: first("Iptc4xmpCore:AltTextAccessibility"), @@ -643,7 +1089,8 @@ pub fn parse_xmp_with(s: &str, extra: &[XmpNamespace]) -> Result 0.0).map(|v| v as u32); + let iso = |key: &str| first(key).and_then(|value| iso_from_text(&value)); + m.iso = [iso("exif:ISOSpeedRatings"), iso("exifEX:PhotographicSensitivity")].into_iter().flatten().next(); if let Some(spec) = props.get("exifEX:LensSpecification").filter(|v| v.len() == 4) { let v: Vec = spec.iter().filter_map(|s| parse_number(s)).collect(); if v.len() == 4 { @@ -668,22 +1115,72 @@ pub fn parse_xmp_with(s: &str, extra: &[XmpNamespace]) -> Result Result { + let ext = parse_xmp_with(extended, extra)?; + let std = parse_xmp_with(standard, extra)?; + let mut properties = ext.properties; + properties.extend(std.properties); + let mut values = ext.values; + for (key, preferred) in std.values { + match values.get_mut(&key) { + Some(fallback) => *fallback = merge_xmp_value(&preferred, fallback), + None => { + values.insert(key, preferred); + } + } + } + properties.remove("xmpNote:HasExtendedXMP"); + values.remove("xmpNote:HasExtendedXMP"); + let metadata = project(&properties, &values); + Ok(XmpData { + metadata, + properties, + properties_truncated: std.properties_truncated || ext.properties_truncated, + values, + toolkit: std.toolkit, + names_location: std.names_location || ext.names_location, + }) +} + +/// `preferred` with what `fallback` adds: array items appended, struct fields merged. +fn merge_xmp_value(preferred: &XmpValue, fallback: &XmpValue) -> XmpValue { + match (preferred, fallback) { + (XmpValue::Array(preferred), XmpValue::Array(fallback)) => XmpValue::Array(preferred.iter().chain(fallback).cloned().collect()), + (XmpValue::Struct(preferred), XmpValue::Struct(fallback)) => { + let mut merged = fallback.clone(); + for (key, value) in preferred { + let combined = merged.get(key).map_or_else(|| value.clone(), |fallback| merge_xmp_value(value, fallback)); + merged.insert(key.clone(), combined); + } + XmpValue::Struct(merged) + } + _ => preferred.clone(), + } +} + +/// The GUID a standard packet's `xmpNote:HasExtendedXMP` names, upper-cased; `Ok(None)` without +/// one. Only the XMP Note namespace counts (a foreign `HasExtendedXMP` is not a link), and the +/// property must occur once, as 32 hexadecimal digits. +pub fn extended_xmp_reference(data: &XmpData) -> Result, String> { + let Some(values) = data.properties.get("xmpNote:HasExtendedXMP") else { return Ok(None) }; + let [value] = values.as_slice() else { + return Err("XMP HasExtendedXMP reference must occur exactly once".into()); + }; + if value.len() != 32 || !value.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err("XMP HasExtendedXMP reference must be 32 hexadecimal characters".into()); + } + Ok(Some(value.to_ascii_uppercase())) } fn frac(v: f64) -> String { @@ -759,20 +1256,24 @@ pub fn write_xmp_with(meta: &Metadata, with: &XmpWrite) -> String { x.push_str(&format!("\n", escape(xml_safe(with.toolkit)))); x.push_str(" \n"); x.push_str(" own, + "xmpNote" => None, + _ => Some(ns), + }) { x.push_str(&format!("\n xmlns:{p}=\"{}\"", escape(xml_safe(u)))); } x.push_str(">\n"); for (k, v) in &simple { - x.push_str(&format!(" <{k}>{}\n", escape(xml_safe(v)))); + x.push_str(&format!(" <{k}>{}\n", keep_cr(escape(xml_safe(v))))); } for (k, v) in with.properties.iter().filter(|_| own.is_some()) { let p = own.map_or("", |n| n.0); if !k.is_empty() && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { // element content: quotes needn't be escaped, which keeps JSON readable - x.push_str(&format!(" <{p}:{k}>{}\n", partial_escape(xml_safe(v)))); + x.push_str(&format!(" <{p}:{k}>{}\n", keep_cr(partial_escape(xml_safe(v))))); } } let array = |x: &mut String, k: &str, kind: &str, items: &[String], lang: bool| { @@ -782,9 +1283,9 @@ pub fn write_xmp_with(meta: &Metadata, with: &XmpWrite) -> String { x.push_str(&format!(" <{k}>\n \n")); for it in items { if lang { - x.push_str(&format!(" {}\n", escape(xml_safe(it)))); + x.push_str(&format!(" {}\n", keep_cr(escape(xml_safe(it))))); } else { - x.push_str(&format!(" {}\n", escape(xml_safe(it)))); + x.push_str(&format!(" {}\n", keep_cr(escape(xml_safe(it))))); } } x.push_str(&format!(" \n \n")); @@ -799,7 +1300,7 @@ pub fn write_xmp_with(meta: &Metadata, with: &XmpWrite) -> String { array(&mut x, "dc:creator", "Seq", &one(&meta.artist), false); array(&mut x, "dc:subject", "Bag", &meta.keywords, false); array(&mut x, "lr:hierarchicalSubject", "Bag", &meta.hierarchical_keywords, false); - array(&mut x, "exif:ISOSpeedRatings", "Seq", &meta.iso.map(|v| vec![v.to_string()]).unwrap_or_default(), false); + array(&mut x, "exif:ISOSpeedRatings", "Seq", &meta.iso.filter(|value| *value > 0).map(|v| vec![v.to_string()]).unwrap_or_default(), false); if let Some(s) = meta.lens_spec { array(&mut x, "exifEX:LensSpecification", "Seq", &s.iter().map(|&v| frac(v)).collect::>(), false); } diff --git a/crates/metadata/src/xmp/tests.rs b/crates/metadata/src/xmp/tests.rs index b8d5807b7..c3c3181c9 100644 --- a/crates/metadata/src/xmp/tests.rs +++ b/crates/metadata/src/xmp/tests.rs @@ -194,6 +194,109 @@ fn non_positive_iso_is_ignored() { assert_eq!(read(r#"exif:ISOSpeedRatings="200""#), Some(200)); } +fn iso_packet(attrs: &str, element: &str) -> String { + format!( + r#" + {element} + "# + ) +} + +#[test] +fn xmp_iso_rejects_fractional_values() { + let cases = [ + (iso_packet(r#"exif:ISOSpeedRatings="0.5""#, ""), "exif:ISOSpeedRatings", "0.5"), + (iso_packet(r#"exif:ISOSpeedRatings="1.0000000000000001""#, ""), "exif:ISOSpeedRatings", "1.0000000000000001"), + (iso_packet(r#"exif:ISOSpeedRatings="1.0000000000000001/1""#, ""), "exif:ISOSpeedRatings", "1.0000000000000001/1"), + (iso_packet("", "160.9"), "exif:ISOSpeedRatings", "160.9"), + (iso_packet(r#"exifEX:PhotographicSensitivity="160.9""#, ""), "exifEX:PhotographicSensitivity", "160.9"), + (iso_packet("", "0.5"), "exifEX:PhotographicSensitivity", "0.5"), + ( + iso_packet("", "160.9"), + "exifEX:PhotographicSensitivity", + "160.9", + ), + ]; + for (packet, key, raw) in cases { + let d = parse_xmp(&packet).unwrap(); + assert_eq!(d.metadata.iso, None, "{packet}"); + assert_eq!( + d.properties.get(key).map(|values| values.iter().map(String::as_str).collect::>()), + Some(vec![raw]), + "raw property changed: {packet}" + ); + assert_eq!(d.metadata.make, None); + } +} + +#[test] +fn xmp_iso_rejects_out_of_range_values() { + for value in ["4294967296", "0", "-1", "NaN", "inf", "bad"] { + let packet = iso_packet(&format!(r#"exif:ISOSpeedRatings="{value}""#), ""); + let d = parse_xmp(&packet).unwrap(); + assert_eq!(d.metadata.iso, None, "{value}"); + assert_eq!(d.properties.get("exif:ISOSpeedRatings").map(|v| v.first().map(String::as_str)), Some(Some(value))); + } + let packet = iso_packet("", "4294967296"); + assert_eq!(parse_xmp(&packet).unwrap().metadata.iso, None); +} + +#[test] +fn xmp_iso_invalid_primary_allows_fallback() { + for primary in ["0.5", "1.0000000000000001", "160.9", "4294967296", "0", "-1", "bad", "NaN"] { + let packet = iso_packet(&format!(r#"exif:ISOSpeedRatings="{primary}" exifEX:PhotographicSensitivity="200""#), ""); + assert_eq!(parse_xmp(&packet).unwrap().metadata.iso, Some(200), "{primary}"); + } +} + +#[test] +fn xmp_iso_preserves_valid_values() { + for (value, expected) in [ + ("200", 200), + ("200.0", 200), + ("2e2", 200), + ("400/2", 200), + ("1.5/0.5", 3), + ("0.3/0.1", 3), + ("1.0000000000000001/1.0000000000000001", 1), + ("1e39/1e39", 1), + ("1e39/5e38", 2), + ("1e39/2e38", 5), + ("4294967295", u32::MAX), + ] { + let packet = iso_packet(&format!(r#"exif:ISOSpeedRatings="{value}""#), ""); + assert_eq!(parse_xmp(&packet).unwrap().metadata.iso, Some(expected), "{value}"); + } + assert_eq!(parse_xmp(&iso_packet("", "640")).unwrap().metadata.iso, Some(640)); + let packet = iso_packet(r#"exif:ISOSpeedRatings="100" exifEX:PhotographicSensitivity="200""#, ""); + assert_eq!(parse_xmp(&packet).unwrap().metadata.iso, Some(100)); +} + +#[test] +fn xmp_iso_accepts_equal_coefficients_above_u128() { + let value = "340282366920938463463374607431768211456/340282366920938463463374607431768211456"; + assert_eq!(parse_xmp(&iso_packet(&format!(r#"exif:ISOSpeedRatings="{value}""#), "")).unwrap().metadata.iso, Some(1)); +} + +#[test] +fn xmp_iso_rejected_value_is_not_written() { + let packet = iso_packet(r#"exif:ISOSpeedRatings="0.5""#, r#"kept"#); + let parsed = parse_xmp(&packet).unwrap(); + assert_eq!(parsed.metadata.iso, None); + assert_eq!(parsed.metadata.title.as_deref(), Some("kept")); + let written = write_xmp(&parsed.metadata); + assert!(!written.contains("ISOSpeedRatings"), "{written}"); +} + +#[test] +fn xmp_iso_writer_omits_zero() { + let written = write_xmp(&Metadata { iso: Some(0), ..Default::default() }); + assert!(!written.contains("ISOSpeedRatings"), "{written}"); + + let written = write_xmp(&Metadata { iso: Some(200), ..Default::default() }); + assert!(written.contains("200"), "{written}"); +} + #[test] fn negative_xmp_pixel_width_is_ignored() { let read = |attrs: &str| { @@ -210,6 +313,119 @@ fn negative_xmp_pixel_width_is_ignored() { assert_eq!(read(r#"exif:PixelXDimension="-1""#), None); } +fn xmp_dimensions(body: &str) -> XmpData { + let (attributes, elements) = body.split_once('|').unwrap_or((body, "")); + let packet = format!( + r#" + {elements} + "# + ); + parse_xmp(&packet).expect("procedural XMP dimension fixture parses") +} + +#[test] +fn xmp_dimensions_reject_fractional_values() { + for (field, value) in [ + ("tiff:ImageWidth", "640.9"), + ("exif:PixelXDimension", "640.9"), + ("tiff:ImageLength", "480.9"), + ("exif:PixelYDimension", "480.9"), + ("tiff:ImageWidth", "1.0000000000000001"), + ] { + let data = xmp_dimensions(&format!(r#"{field}="{value}""#)); + assert_eq!(data.metadata.width, None, "{field}={value}"); + assert_eq!(data.metadata.height, None, "{field}={value}"); + assert_eq!(data.properties.get(field).map(Vec::as_slice), Some([value.to_owned()].as_slice()), "raw {field}={value}"); + } +} + +#[test] +fn xmp_dimensions_reject_out_of_range_values() { + for (field, value) in [ + ("tiff:ImageWidth", "4294967296"), + ("exif:PixelXDimension", "4294967296"), + ("tiff:ImageLength", "4294967296"), + ("exif:PixelYDimension", "4294967296"), + ("tiff:ImageWidth", "-1"), + ("tiff:ImageWidth", "-0"), + ("tiff:ImageWidth", "0/0"), + ("exif:PixelXDimension", "-1"), + ("tiff:ImageLength", "-1"), + ("exif:PixelYDimension", "-1"), + ("tiff:ImageWidth", "1e100000"), + ("tiff:ImageWidth", "NaN"), + ("tiff:ImageWidth", "Infinity"), + ("tiff:ImageWidth", "not-a-number"), + ] { + let data = xmp_dimensions(&format!(r#"{field}="{value}""#)); + assert_eq!(data.metadata.width, None, "{field}={value}"); + assert_eq!(data.metadata.height, None, "{field}={value}"); + assert_eq!(data.properties.get(field).map(Vec::as_slice), Some([value.to_owned()].as_slice()), "raw {field}={value}"); + } +} + +#[test] +fn xmp_dimensions_invalid_primary_allows_fallback() { + let width = xmp_dimensions(r#"tiff:ImageWidth="0.5" exif:PixelXDimension="640""#); + assert_eq!(width.metadata.width, Some(640)); + let height = xmp_dimensions(r#"tiff:ImageLength="480.9" exif:PixelYDimension="480""#); + assert_eq!(height.metadata.height, Some(480)); +} + +#[test] +fn xmp_dimensions_preserve_valid_values() { + for (field, value, expected) in [ + ("tiff:ImageWidth", "0", 0), + ("tiff:ImageWidth", "0001", 1), + ("tiff:ImageWidth", "0/1e-129", 0), + ("tiff:ImageWidth", "1e-400/1e-400", 1), + ("tiff:ImageWidth", "1e400/1e400", 1), + ("tiff:ImageWidth", "4294967295", u32::MAX), + ("exif:PixelXDimension", "640.0", 640), + ("tiff:ImageLength", "960/2", 480), + ("exif:PixelYDimension", "48e1", 480), + ("tiff:ImageWidth", "1e39/1e39", 1), + ] { + let data = xmp_dimensions(&format!(r#"{field}="{value}""#)); + let actual = if matches!(field, "tiff:ImageWidth" | "exif:PixelXDimension") { data.metadata.width } else { data.metadata.height }; + assert_eq!(actual, Some(expected), "{field}={value}"); + } + assert_eq!(xmp_dimensions(r#"tiff:ImageWidth="640" exif:PixelXDimension="800""#).metadata.width, Some(640)); + assert_eq!(xmp_dimensions(r#"tiff:ImageLength="480" exif:PixelYDimension="800""#).metadata.height, Some(480)); + let element = xmp_dimensions(r#"|640480"#); + assert_eq!(element.metadata.width, Some(640)); + assert_eq!(element.metadata.height, Some(480)); + let sequence = xmp_dimensions(r#"|640"#); + assert_eq!(sequence.metadata.width, Some(640)); +} + +#[test] +fn xmp_dimensions_preserve_large_exact_ratio() { + for coefficient in ["999999999999999999999999999999999999999".to_owned(), "9".repeat(1024)] { + let data = xmp_dimensions(&format!(r#"tiff:ImageWidth="{coefficient}/{coefficient}""#)); + assert_eq!(data.metadata.width, Some(1), "coefficient length {}", coefficient.len()); + } + let denominator = "9".repeat(1024); + let numerator = format!("1{}8", "9".repeat(1023)); + let data = xmp_dimensions(&format!(r#"tiff:ImageWidth="{numerator}/{denominator}""#)); + assert_eq!(data.metadata.width, Some(2)); + + let numerator = format!("{}8", "9".repeat(1023)); + let data = xmp_dimensions(&format!(r#"tiff:ImageWidth="{numerator}/{denominator}""#)); + assert_eq!(data.metadata.width, None); +} + +#[test] +fn xmp_dimensions_rejected_values_are_not_written() { + let data = xmp_dimensions(r#"tiff:ImageWidth="640.9" dc:title="Keep this""#); + assert_eq!(data.metadata.width, None); + assert_eq!(data.metadata.title.as_deref(), Some("Keep this")); + assert_eq!(data.properties.get("tiff:ImageWidth").map(Vec::as_slice), Some(["640.9".to_owned()].as_slice())); + let written = write_xmp(&data.metadata); + assert!(!written.contains("tiff:ImageWidth")); + assert_eq!(parse_xmp(&written).unwrap().metadata.width, None); +} + #[test] fn malformed_inputs() { assert!(parse_xmp("").is_ok() || parse_xmp("").is_err()); @@ -240,11 +456,20 @@ fn lc_extras_and_crs_prefix() { #[test] fn gps_coords() { - assert_eq!(parse_gps_coord("45,30N"), Some(45.5)); - assert_eq!(parse_gps_coord("45,30,36S"), Some(-45.51)); - assert_eq!(parse_gps_coord("45X"), None); - assert_eq!(parse_gps_coord(""), None); - assert_eq!(parse_gps_coord("é"), None); + let lat = ['N', 'S']; + assert_eq!(parse_gps_coord("45,30N", lat), Some(45.5)); + assert_eq!(parse_gps_coord("45,30,36S", lat), Some(-45.51)); + assert_eq!(parse_gps_coord("45X", lat), None); + assert_eq!(parse_gps_coord("", lat), None); + assert_eq!(parse_gps_coord("é", lat), None); + assert_eq!(parse_gps_coord("-10,30N", lat), None, "the sign is the letter's"); + assert_eq!(parse_gps_coord("10,-30N", lat), None); + assert_eq!(parse_gps_coord("10,30E", lat), None, "E is a longitude's"); + assert_eq!(parse_gps_position("10,30E", "20,15N"), None); + assert_eq!(parse_gps_position("10,30N", "20,15W"), Some((10.5, -20.25))); + assert_eq!(parse_gps_position("95,0N", "20,0E"), None, "off the globe"); + assert_eq!(parse_gps_position("10,0N", "180,0.1E"), None); + assert_eq!(parse_gps_position("90,0S", "180,0W"), Some((-90.0, -180.0))); assert_eq!(fmt_gps_coord(-45.51, 'N', 'S'), "45,30.600000S"); assert_eq!(fmt_gps_coord(10.9999999999, 'N', 'S'), "11,0.000000N"); assert_eq!(fmt_gps_coord(-0.9999999999, 'E', 'W'), "1,0.000000W"); @@ -268,6 +493,49 @@ fn xmp_gps_out_of_range_rejected() { assert_eq!(gps("90,0S", "180,0W"), Some(Gps { latitude: -90.0, longitude: -180.0, altitude: None })); } +#[test] +fn xmp_gps_component_bounds() { + let parse = |lat: &str, lon: &str| { + let x = format!( + r#""# + ); + parse_xmp(&x).unwrap() + }; + + for (lat, lon) in [ + ("45,60N", "7,15E"), + ("45,99N", "7,15E"), + ("45,0,60N", "7,15E"), + ("45,0,99N", "7,15E"), + ("45,15N", "7,60E"), + ("45,15N", "7,99E"), + ("45,15N", "7,0,60E"), + ("45,15N", "7,0,99E"), + ] { + let xmp = parse(lat, lon); + assert_eq!(xmp.metadata.gps, None, "{lat}, {lon}"); + assert_eq!(xmp.properties.get("exif:GPSLatitude").and_then(|v| v.first()).map(String::as_str), Some(lat)); + assert_eq!(xmp.properties.get("exif:GPSLongitude").and_then(|v| v.first()).map(String::as_str), Some(lon)); + } + + for (lat, lon, expected) in [ + ("45,59.5N", "7,59.5E", (45.0 + 59.5 / 60.0, 7.0 + 59.5 / 60.0)), + ("45,59,59S", "7,59,59W", (-(45.0 + 59.0 / 60.0 + 59.0 / 3600.0), -(7.0 + 59.0 / 60.0 + 59.0 / 3600.0))), + ("0,0N", "0,0E", (0.0, 0.0)), + ("90,0N", "180,0W", (90.0, -180.0)), + (" 45,30n ", " 7,15e ", (45.5, 7.25)), + ] { + let xmp = parse(lat, lon); + let gps = xmp.metadata.gps.expect("valid XMP GPS components"); + assert!((gps.latitude - expected.0).abs() < 1e-9, "{lat}: {}", gps.latitude); + assert!((gps.longitude - expected.1).abs() < 1e-9, "{lon}: {}", gps.longitude); + } + + assert_eq!(parse("95,0N", "7,0E").metadata.gps, None, "combined latitude range remains checked"); + assert_eq!(parse("45,0N", "200,0E").metadata.gps, None, "combined longitude range remains checked"); +} + /// MWG Region Guidelines v2.0, element form (`rdf:parseType="Resource"`, self-closing `Area`) — how /// Lightroom typically writes it. One named face, one untyped/unnamed region (still usable), one /// region with a description. `AppliedToDimensions` is present but irrelevant: normalized areas @@ -506,7 +774,10 @@ fn writer_drops_characters_xml_1_0_forbids() { let illegal: Vec = x.chars().filter(|&c| (c < '\u{20}' && !matches!(c, '\t' | '\n' | '\r')) || c == '\u{FFFE}' || c == '\u{FFFF}').collect(); assert!(illegal.is_empty(), "illegal XML chars in output: {illegal:?}"); - assert!(x.contains(">abcde\tf\ng\rh<"), "allowed characters must survive: {x}"); + // a carriage return is written as a reference: a literal one would read back as a line feed (XML end-of-line handling) + assert!(x.contains(">abcde\tf\ng h<"), "allowed characters must survive: {x}"); + let back = parse_xmp(&x).unwrap(); + assert_eq!(back.metadata.caption.as_deref(), Some("abcde\tf\ng\rh"), "and read back unchanged"); } #[test] @@ -544,6 +815,7 @@ fn flattened_keys_stay_within_the_cumulative_budget() { assert!(total <= MAX_KEY_BYTES, "flattened-key text {total} is over the {MAX_KEY_BYTES} budget"); assert!(d.properties.len() < 3000, "keys stop being added once the budget is spent"); assert!(!d.properties.is_empty(), "properties parsed before the budget are kept"); + assert!(d.properties_truncated, "omitted flattened keys are reported to callers"); } #[test] @@ -579,6 +851,15 @@ fn malformed_capture_time_without_fallback_is_not_projected() { assert_eq!(parsed.properties["exif:DateTimeOriginal"], vec!["2026-01-01T".to_string()]); } +#[test] +fn a_start_tag_with_thousands_of_namespace_declarations_is_refused() { + let decls: String = (0..2000).map(|i| format!(" xmlns:p{i}=\"urn:x{i}\"")).collect(); + let xmp = format!( + "" + ); + assert!(matches!(parse_xmp(&xmp), Err(XmpError::TooComplex))); +} + #[test] fn many_ampersands_before_one_semicolon_are_read_once() { // 200 000 `&` then a `;`: each `&` once searched the whole rest of the attribute for the `;` @@ -617,7 +898,7 @@ fn the_callers_namespace_is_written_and_read_only_when_passed() { let x = write_lc(&Metadata::default(), &[("flag", "pick")]); assert!(x.contains("x:xmptk=\"Editor\"")); assert!(x.contains( - "xmlns:Iptc4xmpCore=\"http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/\"\n xmlns:lc=\"http://ns.example.com/lc/1.0/\"\n xmlns:mwg-rs=" + "xmlns:Iptc4xmpCore=\"http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/\"\n xmlns:Iptc4xmpExt=\"http://iptc.org/std/Iptc4xmpExt/2008-02-29/\"\n xmlns:lc=\"http://ns.example.com/lc/1.0/\"\n xmlns:mwg-rs=" )); assert!(!x.contains("xmlns:crs")); let renamed = x.replace("xmlns:lc=", "xmlns:zz=").replace("lc:flag", "zz:flag"); @@ -671,3 +952,181 @@ fn the_toolkit_is_read_and_written_back() { let bare = r#""#; assert_eq!(crate::parse_xmp(bare).unwrap().toolkit, None); } + +fn orientation_packet(value: &str) -> String { + format!( + r#""# + ) +} + +#[test] +fn xmp_orientation_rejects_fractional_values_and_keeps_raw_properties() { + for value in ["6.5", "6.9", "1.5", "8.9", "13/2"] { + let parsed = parse_xmp(&orientation_packet(value)).unwrap(); + assert_eq!(parsed.metadata.orientation, None, "{value}"); + assert_eq!(parsed.properties["tiff:Orientation"], vec![value.to_string()], "{value}"); + assert!(!write_xmp(&parsed.metadata).contains("tiff:Orientation"), "rejected {value} must not become a written orientation"); + } +} + +#[test] +fn xmp_orientation_integral_forms_keep_their_meaning() { + for code in 1..=8 { + let parsed = parse_xmp(&orientation_packet(&code.to_string())).unwrap(); + assert_eq!(parsed.metadata.orientation.map(|orientation| orientation.to_exif()), Some(code)); + } + for value in ["6.0", "6/1", " 6 ", " 6.0 "] { + let parsed = parse_xmp(&orientation_packet(value)).unwrap(); + assert_eq!(parsed.metadata.orientation.map(|orientation| orientation.to_exif()), Some(6), "{value}"); + } +} + +#[test] +fn xmp_orientation_rejects_existing_invalid_values() { + for value in ["0", "9", "-1", "nan", "inf"] { + let parsed = parse_xmp(&orientation_packet(value)).unwrap(); + assert_eq!(parsed.metadata.orientation, None, "{value}"); + assert_eq!(parsed.properties["tiff:Orientation"], vec![value.to_string()], "{value}"); + } +} + +/// References resolve, anything else (unknown, unterminated, or a `;` too far away to end one) stays as +/// written, and a value that is nothing but `&` is handled in one pass. +#[test] +fn attribute_references_resolve_and_hostile_ones_stay_verbatim() { + assert_eq!(unescape_attr("a&bAB<>"'"), "a&bAB<>\"'"); + assert_eq!(unescape_attr("&bogus; & &#xZZ; tail&"), "&bogus; & &#xZZ; tail&"); + let far = format!("&{};", "x".repeat(40)); + assert_eq!(unescape_attr(&far), far); + let many = "&".repeat(1 << 20); + assert_eq!(unescape_attr(&many), many); + // multi-byte text around a reference is never cut inside a character + assert_eq!(unescape_attr("é&日&é;"), "é&日&é;"); + // leading zeros are valid in numeric references, however many there are + assert_eq!(unescape_attr("AB"), "AB"); + let zeros = format!("&#{};", "0".repeat(1 << 20)); + assert_eq!(unescape_attr(&zeros), zeros); +} + +/// A small packet keeps every property and is not marked truncated. +#[test] +fn a_packet_within_the_budget_keeps_a_complete_inventory() { + let d = parse_xmp(r#""#).unwrap(); + assert!(!d.properties_truncated); + assert_eq!(d.properties.len(), 1); +} + +/// A later description that repeats `LocationShown` with an empty item must not erase the city +/// an earlier description named: the packet still holds a place the export has to refuse. +#[test] +fn a_later_empty_location_keeps_an_earlier_city() { + let packet = concat!( + r#""#, + r#""#, + r#"Rome"#, + r#""#, + r#""# + ); + let parsed = parse_xmp(packet).expect("valid XMP"); + assert!(parsed.has_location_metadata(), "the earlier city is still in the packet"); + let city = parsed + .values + .get("Iptc4xmpExt:LocationShown") + .and_then(|value| value.items().first()) + .and_then(|item| item.field("Iptc4xmpExt:City")) + .and_then(XmpValue::text); + assert_eq!(city, Some("Rome")); +} + +/// GPS nested in a struct array item flattens to the item's (empty) text; the tree inventory still +/// names it. +#[test] +fn gps_inside_a_bag_item_counts_as_location() { + let packet = concat!( + r#""#, + r#""#, + r#"37,46.494N"# + ); + let parsed = parse_xmp(packet).expect("valid XMP"); + assert!(parsed.properties.keys().all(|key| !key.contains("GPS")), "flattening drops the item's fields"); + assert!(parsed.names_location); + assert!(parsed.has_location_metadata()); +} + +/// A location struct nested in another struct is judged by its own fields, not by unrelated text +/// next to it. +#[test] +fn a_nested_location_is_judged_by_its_own_fields() { + let packet = |city: &str| { + format!( + r#"Bridge{city}"# + ) + }; + assert!(!parse_xmp(&packet("")).expect("valid XMP").has_location_metadata(), "a blank place beside a title"); + assert!(parse_xmp(&packet("Rome")).expect("valid XMP").has_location_metadata()); +} + +/// A place inside a struct array item has no flattened key of its own; the tree inventory finds +/// it, and a blank one there still doesn't count. +#[test] +fn a_location_inside_an_array_item_counts_unless_blank() { + let packet = |city: &str| { + format!( + r#"{city}"# + ) + }; + let with_city = parse_xmp(&packet("Rome")).expect("valid XMP"); + assert!(with_city.properties.keys().all(|key| !key.contains("Location")), "flattening drops the item's fields"); + assert!(with_city.has_location_metadata()); + assert!(!parse_xmp(&packet(" ")).expect("valid XMP").has_location_metadata(), "a blank place is no location"); +} + +/// A struct repeated in one description keeps its first copy in `values` when that copy has text +/// elsewhere; a place only the later copy names must still be found. +#[test] +fn a_place_in_a_later_repeated_struct_counts() { + let packet = concat!( + r#""#, + r#"Bridge"#, + r#"Rome"#, + r#""# + ); + assert!(parse_xmp(packet).expect("valid XMP").has_location_metadata()); +} + +/// The same `rdf:Description` can repeat `LocationShown`; a later blank item must not replace the +/// city of the earlier one (its `rdf:li` flattens to empty text, so only the structured value has it). +#[test] +fn a_repeated_empty_location_in_one_description_keeps_the_city() { + let packet = concat!( + r#""#, + r#""#, + r#"Rome"#, + r#""#, + r#""# + ); + let parsed = parse_xmp(packet).expect("valid XMP"); + assert!(parsed.has_location_metadata(), "the earlier city is still in the packet"); + let city = parsed + .values + .get("Iptc4xmpExt:LocationShown") + .and_then(|value| value.items().first()) + .and_then(|item| item.field("Iptc4xmpExt:City")) + .and_then(XmpValue::text); + assert_eq!(city, Some("Rome")); +} + +/// One walker answers both the parser and the Remove Location check: nested text counts, blanks +/// don't, and a tree past the node budget counts as text-bearing instead of being walked in full. +#[test] +fn value_has_text_finds_nested_text_and_stops_at_the_node_budget() { + let blank = XmpValue::Array(vec![XmpValue::Text(" ".into()), XmpValue::Struct(BTreeMap::new())]); + assert!(!value_has_text(&blank)); + let nested = XmpValue::Array(vec![XmpValue::Struct(BTreeMap::from([("Iptc4xmpExt:City".to_string(), XmpValue::Text("Rome".into()))]))]); + assert!(value_has_text(&nested)); + let oversized = XmpValue::Array(vec![XmpValue::Text(String::new()); MAX_NODES + 1]); + assert!(value_has_text(&oversized), "a tree past the node budget is treated as text-bearing"); + let data = XmpData { values: BTreeMap::from([("Iptc4xmpExt:LocationShown".to_string(), nested)]), ..XmpData::default() }; + let data = XmpData { properties: BTreeMap::from([("Iptc4xmpExt:LocationShown".to_string(), vec![String::new()])]), ..data }; + assert!(data.has_location_metadata(), "the structured value's city is found"); +} diff --git a/crates/metadata/src/xmp_merge.rs b/crates/metadata/src/xmp_merge.rs index 3056225b0..33714269a 100644 --- a/crates/metadata/src/xmp_merge.rs +++ b/crates/metadata/src/xmp_merge.rs @@ -23,7 +23,7 @@ use std::collections::{BTreeSet, HashSet}; use quick_xml::events::Event; -use crate::xmp::{MAX_OPAQUE_NAME_BYTES, XmpError, XmpNamespace, canonical_name, parse_xmp_with, unescape_attr}; +use crate::xmp::{MAX_OPAQUE_NAME_BYTES, XmpError, XmpNamespace, canonical_name, check_scopes, parse_xmp_with, unescape_attr}; /// Which properties [`merge_xmp`] takes from the fresh packet. Names are canonical /// `prefix:name` (prefixes as [`crate::parse_xmp`] reports them); `prefix:*` is a whole namespace. @@ -77,6 +77,8 @@ struct Scan { descs: Vec, /// Where the first `` starts. rdf_close: Option, + /// The span of a self-closing `` (used only when there is no ``). + rdf_empty: Option<(usize, usize)>, } enum Kind { @@ -134,6 +136,7 @@ fn scan(s: &str, extra: &[XmpNamespace]) -> Result { raw_attrs.push((k, v)); } scopes.push(scope); + check_scopes(&scopes)?; let raw_name = String::from_utf8_lossy(e.name().as_ref()).into_owned(); let name = canonical_name(&raw_name, &scopes, extra, &mut opaque_budget)?; let kind = match stack.last() { @@ -150,6 +153,9 @@ fn scan(s: &str, extra: &[XmpNamespace]) -> Result { _ => Kind::Other, }; if empty { + if matches!(kind, Kind::Rdf) { + out.rdf_empty = out.rdf_empty.or(Some((before, after))); + } scopes.pop(); if let Kind::Prop { desc, start, name } = kind && let Some(d) = out.descs.get_mut(desc) @@ -215,7 +221,9 @@ pub fn merge_xmp(existing: &str, fresh: &str, rules: MergeRules) -> Result = fresh_desc.props.iter().map(|p| p.name.as_str()).collect(); @@ -263,8 +271,16 @@ pub fn merge_xmp(existing: &str, fresh: &str, rules: MergeRules) -> Result"); if written > 0 { - let at = old.get(..rdf_close).map_or(rdf_close, |h| h.trim_end().len()); - edits.push((at, at, block)); + if let Some(rdf_close) = ex.rdf_close { + let at = old.get(..rdf_close).map_or(rdf_close, |h| h.trim_end().len()); + edits.push((at, at, block)); + } else if let Some((s, e)) = ex.rdf_empty { + // a self-closing `` is opened up around the new description + let tag = old.get(s..e).ok_or_else(|| XmpError::Xml("bad span".into()))?; + let open = tag.trim_end().strip_suffix("/>").ok_or_else(|| XmpError::Xml("bad rdf:RDF tag".into()))?; + let name = tag.get(1..).and_then(|t| t.split(|c: char| c.is_whitespace() || c == '/' || c == '>').next()).unwrap_or("rdf:RDF"); + edits.push((s, e, format!("{open}>{block}\n"))); + } } edits.sort_by_key(|e| (e.0, e.1)); // overlapping edits can't happen (properties sit inside their description, which is either @@ -404,6 +420,16 @@ mod tests { assert_eq!(merge_xmp(&again, &fresh(1, &[]), RULES).unwrap(), again, "idempotent"); } + #[test] + fn a_self_closing_rdf_element_is_opened_not_replaced() { + let ex = ""; + let out = merge_xmp(ex, &fresh(3, &[]), RULES).unwrap(); + assert!(out.starts_with(""), "{out}"); + assert_eq!(parse_xmp(&out).unwrap().properties.get("xmp:Rating"), Some(&vec!["3".to_string()])); + assert!(merge_xmp("", &fresh(3, &[]), RULES).is_err(), "no RDF element at all"); + } + #[test] fn owned_if_present_and_prefix_independence() { // the existing packet binds the namespaces to other prefixes, and has a capture time diff --git a/crates/pptx/Cargo.toml b/crates/pptx/Cargo.toml new file mode 100644 index 000000000..623805a1a --- /dev/null +++ b/crates/pptx/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "craft-pptx" +description = "PresentationML (ECMA-376 part 1, chapter 19): the model of a .pptx presentation (masters, layouts, slides, shapes, placeholders, transitions and animations)" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +craft-drawingml = { workspace = true } +kurbo = { workspace = true, features = ["serde"] } +serde = { workspace = true, features = ["rc"] } +serde_json = { workspace = true } diff --git a/crates/pptx/README.md b/crates/pptx/README.md new file mode 100644 index 000000000..42cda2fc8 --- /dev/null +++ b/crates/pptx/README.md @@ -0,0 +1,21 @@ +# craft-pptx + +PresentationML (ECMA-376 part 1, chapter 19): what a `.pptx` presentation holds, as plain Rust +types with serde. The DrawingML inside it (fills, lines, text bodies, tables, charts, themes) +comes from `craft-drawingml`. + +| Item | What | Markup | +|---|---|---| +| `Presentation` | slide size, masters, slides, sections, custom shows, show settings, header and footer, properties, media, embedded fonts, table styles, tags, kept parts, drawing guides | `p:presentation`, `docProps/*`, `p:viewPr` | +| `Master`, `Layout` | theme, shapes, background, title/body/other text styles, colour map | `p:sldMaster`, `p:sldLayout` | +| `Slide` | shapes, background, transition, animations, notes, comments, colour map override | `p:sld`, `p:notes`, comments parts | +| `Shape`, `ShapeKind` | shapes, pictures, groups, connectors, tables, charts, media, ink, 3-D models, and objects kept as XML | `p:sp`, `p:pic`, `p:grpSp`, `p:cxnSp`, `p:graphicFrame`, `p:contentPart` | +| `Placeholder`, `PhType`, `placeholder` | placeholders and the rule that matches a slide placeholder to its layout and master parents | `p:ph` | +| `anim` | transitions and the flat list of animations the timing tree stands for, with the effect catalogues | `p:transition`, `p:timing` | + +Ids (`ShapeId`, `LayoutId`, `MasterId`, and `MediaId`, `SlideId` from `craft-drawingml`) are +unique across a presentation; `Presentation::alloc_id` hands out fresh ones and refuses once they +run out. `Presentation::default()` is an empty 16:9 deck with no masters: an application adds its +own starting masters and layouts. + +The reader and writer come in later changes. diff --git a/crates/pptx/src/anim.rs b/crates/pptx/src/anim.rs new file mode 100644 index 000000000..70ab71a43 --- /dev/null +++ b/crates/pptx/src/anim.rs @@ -0,0 +1,668 @@ +//! Slide transitions and object animations. +//! +//! Animations are kept as the flat, ordered list the Animation Pane shows; the PPTX writer builds +//! the nested timing tree from it and the reader flattens files back into it. + +use serde::{Deserialize, Serialize}; + +use crate::ShapeId; +use craft_drawingml::{style, text}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Transition { + /// Effect id: `none`, `fade`, `push`, `wipe`, `split`, `reveal`, `cut`, `randomBar`, `shape`, + /// `uncover`, `cover`, `flash`, `morph`, `dissolve`, `checker`, `blinds`, `clock`, `zoom`… (see + /// [`TRANSITIONS`]). + pub kind: String, + /// Effect option, e.g. direction `l`, `r`, `u`, `d`, `horz`, `vert`, `in`, `out`. + pub option: String, + pub duration_ms: u32, + pub advance_on_click: bool, + pub advance_after_ms: Option, + /// Embedded sound media. + pub sound: Option, + /// Loop Until Next Sound (`p:stSnd loop="1"`). + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub sound_loop: bool, + /// [Stop Previous Sound] (`p:endSnd`): the slide stops a sound still playing. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub stop_sound: bool, + /// Unmodelled transition XML kept for round-trip. + #[serde(skip_serializing_if = "Option::is_none")] + pub raw: Option, +} + +impl Default for Transition { + fn default() -> Self { + Transition { + kind: "none".into(), + option: String::new(), + duration_ms: 1000, + advance_on_click: true, + advance_after_ms: None, + sound: None, + sound_loop: false, + stop_sound: false, + raw: None, + } + } +} + +/// (id, label, category, default duration ms, effect options) +pub static TRANSITIONS: &[(&str, &str, &str, u32, &[&str])] = &[ + ("none", "None", "Subtle", 0, &[]), + ("morph", "Morph", "Subtle", 2000, &["objects", "words", "characters"]), + ("fade", "Fade", "Subtle", 700, &["smoothly", "throughBlack"]), + ("push", "Push", "Subtle", 1000, &["u", "d", "l", "r"]), + ("wipe", "Wipe", "Subtle", 1000, &["r", "l", "u", "d", "ru", "lu", "rd", "ld"]), + ("split", "Split", "Subtle", 1500, &["vertOut", "vertIn", "horzOut", "horzIn"]), + ("reveal", "Reveal", "Subtle", 2000, &["smoothlyLeft", "smoothlyRight", "blackLeft", "blackRight"]), + ("cut", "Cut", "Subtle", 0, &["", "throughBlack"]), + ("randomBar", "Random Bars", "Subtle", 1000, &["vert", "horz"]), + ("shape", "Shape", "Subtle", 2000, &["circle", "diamond", "plus", "in", "out"]), + ("uncover", "Uncover", "Subtle", 1000, &["l", "r", "u", "d", "lu", "ru", "ld", "rd"]), + ("cover", "Cover", "Subtle", 1000, &["l", "r", "u", "d", "lu", "ru", "ld", "rd"]), + ("flash", "Flash", "Subtle", 1000, &[]), + ("fallOver", "Fall Over", "Exciting", 2000, &["l", "r"]), + ("drape", "Drape", "Exciting", 2000, &["l", "r"]), + ("curtains", "Curtains", "Exciting", 2000, &[]), + ("wind", "Wind", "Exciting", 2000, &["l", "r"]), + ("prestige", "Prestige", "Exciting", 2000, &[]), + ("fracture", "Fracture", "Exciting", 2000, &[]), + ("crush", "Crush", "Exciting", 2000, &[]), + ("peelOff", "Peel Off", "Exciting", 1500, &["l", "r"]), + ("pageCurlDouble", "Page Curl", "Exciting", 1500, &["l", "r", "singleL", "singleR"]), + ("airplane", "Airplane", "Exciting", 2000, &["l", "r"]), + ("origami", "Origami", "Exciting", 2000, &["l", "r"]), + ("dissolve", "Dissolve", "Exciting", 1200, &[]), + ("checker", "Checkerboard", "Exciting", 1500, &["horz", "vert"]), + ("blinds", "Blinds", "Exciting", 1600, &["vert", "horz"]), + ("clock", "Clock", "Exciting", 2000, &["clockwise", "counterClockwise", "wedge"]), + ("ripple", "Ripple", "Exciting", 1400, &["center", "lu", "ru", "ld", "rd"]), + ("honeycomb", "Honeycomb", "Exciting", 3000, &[]), + ("glitter", "Glitter", "Exciting", 2500, &["l", "r", "u", "d", "hexL", "hexR", "hexU", "hexD"]), + ("vortex", "Vortex", "Exciting", 3000, &["l", "r", "u", "d"]), + ("shred", "Shred", "Exciting", 2000, &["stripsIn", "stripsOut", "particlesIn", "particlesOut"]), + ("switch", "Switch", "Exciting", 1250, &["l", "r"]), + ("flip", "Flip", "Exciting", 1250, &["l", "r"]), + ("gallery", "Gallery", "Exciting", 1500, &["l", "r"]), + ("cube", "Cube", "Exciting", 1250, &["l", "r", "u", "d"]), + ("doors", "Doors", "Exciting", 1400, &["vert", "horz"]), + ("box", "Box", "Exciting", 1250, &["l", "r", "u", "d"]), + ("comb", "Comb", "Exciting", 1000, &["horz", "vert"]), + ("zoom", "Zoom", "Exciting", 1000, &["in", "out", "rotate"]), + ("random", "Random", "Exciting", 2000, &[]), + ("pan", "Pan", "Dynamic Content", 1000, &["u", "d", "l", "r"]), + ("ferris", "Ferris Wheel", "Dynamic Content", 2000, &["l", "r"]), + ("conveyor", "Conveyor", "Dynamic Content", 2000, &["l", "r"]), + ("rotate", "Rotate", "Dynamic Content", 2000, &["l", "r", "u", "d"]), + ("window", "Window", "Dynamic Content", 1250, &["vert", "horz"]), + ("orbit", "Orbit", "Dynamic Content", 2000, &["l", "r", "u", "d"]), + ("flythrough", "Fly Through", "Dynamic Content", 1000, &["in", "out", "inBounce", "outBounce"]), +]; + +/// The Effect Options label PowerPoint shows for a transition option. Options are stored as the +/// file format's values: for moving transitions (Push, Wipe, Cover, Pan, Cube…) the direction +/// things move, so "l" is the effect coming "From Right"; Glitter and the preset effects (Fall +/// Over, Wind…) name the side itself; Ripple names where the wave heads. +pub fn transition_option_label(kind: &str, option: &str) -> String { + let side = |c: char| match c { + 'l' => "Left", + 'r' => "Right", + 'u' => "Top", + _ => "Bottom", + }; + let opposite = |c: char| match c { + 'l' => 'r', + 'r' => 'l', + 'u' => 'd', + _ => 'u', + }; + let corner = |o: &str, flip: bool| -> Option { + let mut cs = o.chars(); + let (h, v) = (cs.next()?, cs.next()?); + let (h, v) = if flip { (opposite(h), opposite(v)) } else { (h, v) }; + Some(format!("From {}-{}", side(v), side(h))) + }; + let moving = matches!( + kind, + "push" + | "wipe" + | "cover" + | "uncover" + | "pan" + | "vortex" + | "cube" + | "box" + | "rotate" + | "orbit" + | "gallery" + | "conveyor" + | "ferris" + | "switch" + | "flip" + ); + match (kind, option) { + (_, "horz") => "Horizontal".into(), + (_, "vert") => "Vertical".into(), + ("split", o) => format!("{} {}", if o.starts_with("horz") { "Horizontal" } else { "Vertical" }, if o.ends_with("In") { "In" } else { "Out" }), + ("fade" | "cut", "throughBlack") => "Through Black".into(), + ("fade", _) => "Smoothly".into(), + ("cut", _) => "Cut".into(), + ("clock", "counterClockwise") => "Counterclockwise".into(), + ("clock", "wedge") => "Wedge".into(), + ("clock", _) => "Clockwise".into(), + ("zoom", "rotate") => "Zoom and Rotate".into(), + ("ripple", "center") => "Center".into(), + ("ripple", o) => corner(o, true).unwrap_or_else(|| "Center".into()), + ("glitter", o) => { + let (pat, d) = match o.strip_prefix("hex") { + Some(d) => ("Hexagons", d.to_ascii_lowercase()), + None => ("Diamonds", o.to_string()), + }; + format!("{pat} From {}", side(d.chars().next().unwrap_or('l'))) + } + ("pageCurlDouble", o) => match o { + "singleL" => "Single Left".into(), + "singleR" => "Single Right".into(), + "r" => "Double Right".into(), + _ => "Double Left".into(), + }, + ("shred", o) => { + format!("{} {}", if o.starts_with("particles") { "Particles" } else { "Strips" }, if o.ends_with("Out") { "Out" } else { "In" }) + } + ("reveal", o) => format!( + "{} {}", + if o.starts_with("black") { "Through Black" } else { "Smoothly" }, + if o.ends_with("Right") { "From Right" } else { "From Left" } + ), + ("flythrough", o) => { + format!("{}{}", if o.starts_with("out") { "Out" } else { "In" }, if o.ends_with("Bounce") { " with Bounce" } else { "" }) + } + ("morph", o) => match o { + "words" => "Words".into(), + "characters" => "Characters".into(), + _ => "Objects".into(), + }, + (_, o) if o.len() == 2 && moving => corner(o, true).unwrap_or_default(), + (_, o) if o.len() == 1 && moving => format!("From {}", side(opposite(o.chars().next().unwrap_or('l')))), + (_, "l") => "Left".into(), + (_, "r") => "Right".into(), + (_, "u") => "Top".into(), + (_, "d") => "Bottom".into(), + (_, "in") => "In".into(), + (_, "out") => "Out".into(), + (_, o) => o.to_string(), + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AnimClass { + #[default] + Entrance, + Emphasis, + Exit, + Path, + Media, +} + +impl AnimClass { + pub fn xml(self) -> &'static str { + match self { + AnimClass::Entrance => "entr", + AnimClass::Emphasis => "emph", + AnimClass::Exit => "exit", + AnimClass::Path => "path", + AnimClass::Media => "mediacall", + } + } + pub fn from_xml(s: &str) -> Self { + match s { + "emph" => AnimClass::Emphasis, + "exit" => AnimClass::Exit, + "path" => AnimClass::Path, + "mediacall" => AnimClass::Media, + _ => AnimClass::Entrance, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AnimStart { + #[default] + OnClick, + WithPrevious, + AfterPrevious, +} + +/// Text build: as one object, all at once by paragraph, or by paragraph level. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TextBuild { + #[default] + AsOne, + ByParagraph, + ByWord, + ByLetter, +} + +/// Most paragraphs a by-paragraph build animates separately; later ones follow the last of them. +pub const MAX_BUILD_PARAGRAPHS: usize = 1_000; + +/// What a paragraph does in a by-paragraph build. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BuildRole { + /// Animated on its own: a filled paragraph at the shallowest filled level. + Top, + /// Follows the top paragraph with this index (deeper, empty or blank paragraphs after it). + Member(usize), + /// Before the first top paragraph: not animated. + Unowned, +} + +/// Does the paragraph show any text? A by-paragraph build doesn't animate blank ones on their own. +pub fn build_filled(p: &text::Paragraph) -> bool { + p.runs.iter().any(|r| !r.text.trim().is_empty()) +} + +/// The role of every paragraph of `text` in a by-paragraph build, by paragraph index: what the +/// timeline plays, and what the PPTX reader and writer map to one file effect per paragraph. +pub fn paragraph_build_roles(text: &text::TextBody) -> Vec { + let top = text.paragraphs.iter().filter(|p| build_filled(p)).map(|p| p.level).min().unwrap_or(0); + let mut tops = 0; + let mut owner: Option = None; + text.paragraphs + .iter() + .enumerate() + .map(|(i, p)| { + if p.level <= top && build_filled(p) && tops < MAX_BUILD_PARAGRAPHS { + tops += 1; + owner = Some(i); + BuildRole::Top + } else { + owner.map_or(BuildRole::Unowned, BuildRole::Member) + } + }) + .collect() +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Animation { + pub shape: ShapeId, + pub class: AnimClass, + /// Effect id from [`ANIMATIONS`] (e.g. `fade`, `fly`, `zoom`, `spin`, `pulse`, `lines`…). + pub effect: String, + /// Effect option (direction etc.). + pub option: String, + pub start: AnimStart, + pub duration_ms: u32, + pub delay_ms: u32, + /// Repeat count (0/1 = once); `u32::MAX` = until next click. + pub repeat: u32, + pub rewind: bool, + pub text_build: TextBuild, + /// Paragraph index when the effect animates one paragraph of a text body. + pub paragraph: Option, + /// Motion path in slide-relative units (SVG-like `M 0 0 L 0.25 0 E`). + pub path: Option, + /// Triggered by a click on another shape. + pub trigger: Option, + /// Emphasis amount: spin degrees, grow/shrink scale (1.5 = 150%), transparency… + pub amount: Option, + /// Emphasis colour (fill/line/font colour change). + pub color: Option, + /// PowerPoint preset id/subtype read from or written to files. + pub preset_id: Option, + pub preset_subtype: Option, + /// After animation: dim to colour, hide, hide on next click. + pub after: Option, + pub sound: Option, + pub smooth_start: f64, + pub smooth_end: f64, + pub bounce_end: f64, + pub auto_reverse: bool, +} + +impl Default for Animation { + fn default() -> Self { + Animation { + shape: ShapeId(0), + class: AnimClass::Entrance, + effect: "fade".into(), + option: String::new(), + start: AnimStart::OnClick, + duration_ms: 500, + delay_ms: 0, + repeat: 1, + rewind: false, + text_build: TextBuild::AsOne, + paragraph: None, + path: None, + trigger: None, + amount: None, + color: None, + preset_id: None, + preset_subtype: None, + after: None, + sound: None, + smooth_start: 0.0, + smooth_end: 0.0, + bounce_end: 0.0, + auto_reverse: false, + } + } +} + +/// One [`ANIMATIONS`] row: (id, label, class, PowerPoint preset id, default duration ms, options). +pub type AnimationInfo = (&'static str, &'static str, AnimClass, u32, u32, &'static [&'static str]); + +/// (id, label, class, PowerPoint preset id, default duration ms, options) +pub static ANIMATIONS: &[AnimationInfo] = &[ + ("appear", "Appear", AnimClass::Entrance, 1, 0, &[]), + ("fade", "Fade", AnimClass::Entrance, 10, 500, &[]), + ("fly", "Fly In", AnimClass::Entrance, 2, 500, &["b", "lb", "l", "lt", "t", "rt", "r", "rb"]), + ("float", "Float In", AnimClass::Entrance, 42, 1000, &["u", "d"]), + ("split", "Split", AnimClass::Entrance, 16, 500, &["horzIn", "horzOut", "vertIn", "vertOut"]), + ("wipe", "Wipe", AnimClass::Entrance, 22, 500, &["b", "l", "t", "r"]), + ("shape", "Shape", AnimClass::Entrance, 6, 2000, &["circleIn", "circleOut", "boxIn", "boxOut", "diamondIn", "diamondOut", "plusIn", "plusOut"]), + ("wheel", "Wheel", AnimClass::Entrance, 21, 2000, &["1", "2", "3", "4", "8"]), + ("randomBars", "Random Bars", AnimClass::Entrance, 14, 500, &["horz", "vert"]), + ("grow", "Grow & Turn", AnimClass::Entrance, 31, 1000, &[]), + ("zoom", "Zoom", AnimClass::Entrance, 53, 500, &["in", "out"]), + ("swivel", "Swivel", AnimClass::Entrance, 45, 2000, &["horz", "vert"]), + ("bounce", "Bounce", AnimClass::Entrance, 26, 2000, &[]), + ("blinds", "Blinds", AnimClass::Entrance, 3, 500, &["horz", "vert"]), + ("box", "Box", AnimClass::Entrance, 4, 2000, &["in", "out"]), + ("checkerboard", "Checkerboard", AnimClass::Entrance, 5, 500, &["across", "down"]), + ("circle", "Circle", AnimClass::Entrance, 6, 2000, &["in", "out"]), + ("diamond", "Diamond", AnimClass::Entrance, 8, 2000, &["in", "out"]), + ("dissolve", "Dissolve In", AnimClass::Entrance, 9, 500, &[]), + ("peek", "Peek In", AnimClass::Entrance, 12, 500, &["b", "l", "t", "r"]), + ("strips", "Strips", AnimClass::Entrance, 18, 500, &["lu", "ru", "ld", "rd"]), + ("expand", "Expand", AnimClass::Entrance, 55, 1000, &[]), + ("rise", "Rise Up", AnimClass::Entrance, 37, 1000, &[]), + ("spinner", "Spinner", AnimClass::Entrance, 49, 500, &[]), + ("pulse", "Pulse", AnimClass::Emphasis, 26, 500, &[]), + ("colorPulse", "Color Pulse", AnimClass::Emphasis, 27, 250, &[]), + ("teeter", "Teeter", AnimClass::Emphasis, 32, 1000, &[]), + ("spin", "Spin", AnimClass::Emphasis, 8, 2000, &["clockwise", "counterClockwise"]), + ("growShrink", "Grow/Shrink", AnimClass::Emphasis, 6, 2000, &["both", "horz", "vert"]), + ("desaturate", "Desaturate", AnimClass::Emphasis, 25, 500, &[]), + ("darken", "Darken", AnimClass::Emphasis, 24, 500, &[]), + ("lighten", "Lighten", AnimClass::Emphasis, 30, 500, &[]), + ("transparency", "Transparency", AnimClass::Emphasis, 9, 500, &[]), + ("objectColor", "Object Color", AnimClass::Emphasis, 19, 500, &[]), + ("complementaryColor", "Complementary Color", AnimClass::Emphasis, 21, 500, &[]), + ("lineColor", "Line Color", AnimClass::Emphasis, 7, 2000, &[]), + ("fillColor", "Fill Color", AnimClass::Emphasis, 1, 2000, &[]), + ("fontColor", "Font Color", AnimClass::Emphasis, 3, 2000, &[]), + ("underline", "Underline", AnimClass::Emphasis, 18, 500, &[]), + ("boldFlash", "Bold Flash", AnimClass::Emphasis, 10, 2000, &[]), + ("wave", "Wave", AnimClass::Emphasis, 34, 500, &[]), + ("disappear", "Disappear", AnimClass::Exit, 1, 0, &[]), + ("fadeOut", "Fade", AnimClass::Exit, 10, 500, &[]), + ("flyOut", "Fly Out", AnimClass::Exit, 2, 500, &["b", "lb", "l", "lt", "t", "rt", "r", "rb"]), + ("floatOut", "Float Out", AnimClass::Exit, 42, 1000, &["u", "d"]), + ("splitOut", "Split", AnimClass::Exit, 16, 500, &["horzIn", "horzOut", "vertIn", "vertOut"]), + ("wipeOut", "Wipe", AnimClass::Exit, 22, 500, &["b", "l", "t", "r"]), + ("shapeOut", "Shape", AnimClass::Exit, 6, 2000, &["circleIn", "circleOut"]), + ("wheelOut", "Wheel", AnimClass::Exit, 21, 2000, &["1", "2", "3", "4", "8"]), + ("randomBarsOut", "Random Bars", AnimClass::Exit, 14, 500, &["horz", "vert"]), + ("shrinkTurn", "Shrink & Turn", AnimClass::Exit, 31, 1000, &[]), + ("zoomOut", "Zoom", AnimClass::Exit, 53, 500, &["in", "out"]), + ("swivelOut", "Swivel", AnimClass::Exit, 45, 2000, &["horz", "vert"]), + ("bounceOut", "Bounce", AnimClass::Exit, 26, 2000, &[]), + ("dissolveOut", "Dissolve Out", AnimClass::Exit, 9, 500, &[]), + ("blindsOut", "Blinds", AnimClass::Exit, 3, 500, &["horz", "vert"]), + ("boxOut", "Box", AnimClass::Exit, 4, 2000, &["in", "out"]), + ("checkerboardOut", "Checkerboard", AnimClass::Exit, 5, 500, &["across", "down"]), + ("diamondOut", "Diamond", AnimClass::Exit, 8, 2000, &["in", "out"]), + ("peekOut", "Peek Out", AnimClass::Exit, 12, 500, &["b", "l", "t", "r"]), + ("stripsOut", "Strips", AnimClass::Exit, 18, 500, &["lu", "ru", "ld", "rd"]), + ("sinkDown", "Sink Down", AnimClass::Exit, 37, 1000, &[]), + ("spinnerOut", "Spinner", AnimClass::Exit, 49, 500, &[]), + ("contract", "Contract", AnimClass::Exit, 55, 1000, &[]), + ("lines", "Lines", AnimClass::Path, 64, 2000, &["down", "up", "right", "left"]), + ("arcs", "Arcs", AnimClass::Path, 37, 2000, &["down", "up", "right", "left"]), + ("turns", "Turns", AnimClass::Path, 43, 2000, &["down", "up", "right", "left"]), + ( + "shapes", + "Shapes", + AnimClass::Path, + 1, + 2000, + &["circle", "diamond", "hexagon", "triangle", "square", "trapezoid", "octagon", "parallelogram", "pentagon"], + ), + ("loops", "Loops", AnimClass::Path, 61, 2000, &[]), + ("diagonalDownRight", "Diagonal Down Right", AnimClass::Path, 49, 2000, &[]), + ("diagonalUpRight", "Diagonal Up Right", AnimClass::Path, 56, 2000, &[]), + ("zigzag", "Zigzag", AnimClass::Path, 38, 2000, &[]), + ("sineWave", "Sine Wave", AnimClass::Path, 40, 2000, &[]), + ("pathWave", "Wave", AnimClass::Path, 47, 2000, &[]), + ("decayingWave", "Decaying Wave", AnimClass::Path, 60, 2000, &[]), + ("heartbeat", "Heartbeat", AnimClass::Path, 45, 2000, &[]), + ("sCurve1", "S Curve 1", AnimClass::Path, 59, 2000, &[]), + ("sCurve2", "S Curve 2", AnimClass::Path, 39, 2000, &[]), + ("stairsDown", "Stairs Down", AnimClass::Path, 62, 2000, &[]), + ("bounceRight", "Bounce Right", AnimClass::Path, 54, 2000, &[]), + ("bounceLeft", "Bounce Left", AnimClass::Path, 41, 2000, &[]), + ("spiralRight", "Spiral Right", AnimClass::Path, 46, 2000, &[]), + ("spiralLeft", "Spiral Left", AnimClass::Path, 55, 2000, &[]), + ("spring", "Spring", AnimClass::Path, 53, 2000, &[]), + ("funnel", "Funnel", AnimClass::Path, 52, 2000, &[]), + ("swoosh", "Swoosh", AnimClass::Path, 30, 2000, &[]), + ("star4", "4 Point Star", AnimClass::Path, 16, 2000, &[]), + ("star5", "5 Point Star", AnimClass::Path, 5, 2000, &[]), + ("star6", "6 Point Star", AnimClass::Path, 11, 2000, &[]), + ("star8", "8 Point Star", AnimClass::Path, 17, 2000, &[]), + ("heart", "Heart", AnimClass::Path, 9, 2000, &[]), + ("crescentMoon", "Crescent Moon", AnimClass::Path, 6, 2000, &[]), + ("teardrop", "Teardrop", AnimClass::Path, 18, 2000, &[]), + ("football", "Football", AnimClass::Path, 12, 2000, &[]), + ("plusPath", "Plus", AnimClass::Path, 32, 2000, &[]), + ("rightTriangle", "Right Triangle", AnimClass::Path, 2, 2000, &[]), + ("curvedSquare", "Curved Square", AnimClass::Path, 20, 2000, &[]), + ("figure8Horizontal", "Horizontal Figure 8", AnimClass::Path, 26, 2000, &[]), + ("figure8Vertical", "Vertical Figure 8", AnimClass::Path, 22, 2000, &[]), + ("loopDeLoop", "Loop de Loop", AnimClass::Path, 24, 2000, &[]), + ("bean", "Bean", AnimClass::Path, 31, 2000, &[]), + ("peanut", "Peanut", AnimClass::Path, 27, 2000, &[]), + ("neutron", "Neutron", AnimClass::Path, 29, 2000, &[]), + ("customPath", "Custom Path", AnimClass::Path, 0, 2000, &[]), + ("play", "Play", AnimClass::Media, 1, 0, &[]), + ("pause", "Pause", AnimClass::Media, 2, 0, &[]), + ("stop", "Stop", AnimClass::Media, 3, 0, &[]), +]; + +pub fn animation_info(effect: &str, class: AnimClass) -> Option<&'static AnimationInfo> { + ANIMATIONS.iter().find(|a| a.0 == effect && a.2 == class).or_else(|| ANIMATIONS.iter().find(|a| a.0 == effect)) +} + +/// The path of a motion-path preset beyond Lines, Arcs, Turns, Shapes and Loops, in slide +/// fractions from the shape's position (our path syntax: `M`, `L`, `Z`, ending in `E`). +/// `None` for an effect that isn't one of these presets. +pub fn preset_path(effect: &str) -> Option { + use std::f64::consts::{PI, TAU}; + use std::fmt::Write; + // Sample `f` over 0..=1 into a polyline starting at (0, 0). + fn poly(n: usize, closed: bool, f: impl Fn(f64) -> (f64, f64)) -> String { + let (x0, y0) = f(0.0); + let mut s = String::from("M 0 0"); + for k in 1..=n { + let (x, y) = f(k as f64 / n as f64); + let _ = write!(s, " L {:.4} {:.4}", x - x0, y - y0); + } + if closed { + s.push_str(" Z"); + } + s.push_str(" E"); + s + } + fn pts(p: &[(f64, f64)], closed: bool) -> String { + let mut s = String::from("M 0 0"); + for (x, y) in p { + let _ = write!(s, " L {x:.4} {y:.4}"); + } + if closed { + s.push_str(" Z"); + } + s.push_str(" E"); + s + } + // A closed curve in polar form r(θ), starting at its top. + fn polar(scale: f64, r: impl Fn(f64) -> f64) -> String { + poly(96, true, |t| { + let a = -PI / 2.0 + TAU * t; + let rr = r(a + PI / 2.0) * scale; + (rr * a.cos(), rr * a.sin()) + }) + } + fn star(n: usize, outer: f64, inner: f64) -> String { + let mut p = Vec::new(); + let start = (0.0, -outer); + for k in 1..=2 * n { + let a = -PI / 2.0 + PI * k as f64 / n as f64; + let r = if k % 2 == 1 { inner } else { outer }; + p.push((r * a.cos() - start.0, r * a.sin() - start.1)); + } + pts(&p, true) + } + let spiral = |dir: f64| { + poly(120, false, move |t| { + let r = 0.12 * (1.0 - t); + let a = dir * 3.0 * TAU * t; + (dir * (0.12 - r * a.cos()), -r * a.sin()) + }) + }; + let bounce = |dir: f64| poly(90, false, move |t| (dir * 0.3 * t, 0.15 * t - 0.1 * (1.0 - t) * (3.0 * PI * t).sin().abs())); + Some(match effect { + "diagonalDownRight" => pts(&[(0.2, 0.2)], false), + "diagonalUpRight" => pts(&[(0.2, -0.2)], false), + "zigzag" => pts(&[(0.05, 0.04), (0.1, 0.0), (0.15, 0.04), (0.2, 0.0), (0.25, 0.04), (0.3, 0.0)], false), + "sineWave" => poly(64, false, |t| (0.3 * t, 0.04 * (2.0 * TAU * t).sin())), + "pathWave" => poly(48, false, |t| (0.3 * t, 0.06 * (TAU * t).sin())), + "decayingWave" => poly(96, false, |t| (0.3 * t, 0.07 * (-3.0 * t).exp() * (3.0 * TAU * t).sin())), + "heartbeat" => pts(&[(0.08, 0.0), (0.1, -0.08), (0.13, 0.06), (0.15, 0.0), (0.25, 0.0)], false), + "sCurve1" => poly(48, false, |t| (0.2 * t, 0.1 - 0.1 * (PI * t).cos())), + "sCurve2" => poly(48, false, |t| (0.1 - 0.1 * (PI * t).cos(), 0.2 * t)), + "stairsDown" => pts(&[(0.06, 0.0), (0.06, 0.06), (0.12, 0.06), (0.12, 0.12), (0.18, 0.12), (0.18, 0.18)], false), + "bounceRight" => bounce(1.0), + "bounceLeft" => bounce(-1.0), + "spiralRight" => spiral(1.0), + "spiralLeft" => spiral(-1.0), + "spring" => poly(160, false, |t| (0.3 * t + 0.03 * (5.0 * TAU * t).cos(), 0.03 * (5.0 * TAU * t).sin())), + "funnel" => poly(160, false, |t| { + let r = 0.12 * (1.0 - t); + (r * (3.0 * TAU * t).cos(), 0.25 * t + 0.03 * (3.0 * TAU * t).sin() * (1.0 - t)) + }), + "swoosh" => poly(48, false, |t| (0.3 * t, -0.12 * (PI * t).sin() + 0.1 * t * t)), + "star4" => star(4, 0.1, 0.04), + "star5" => star(5, 0.1, 0.04), + "star6" => star(6, 0.1, 0.05), + "star8" => star(8, 0.1, 0.06), + "heart" => poly(96, true, |t| { + let a = TAU * t; + (0.006 * 16.0 * a.sin().powi(3), -0.006 * (13.0 * a.cos() - 5.0 * (2.0 * a).cos() - 2.0 * (3.0 * a).cos() - (4.0 * a).cos())) + }), + "crescentMoon" => { + // The outer arc of a circle, back along a smaller, offset one. + let mut p: Vec<(f64, f64)> = (1..=32) + .map(|k| { + let a = -PI / 2.0 - PI * k as f64 / 32.0; + (0.1 * a.cos(), 0.1 + 0.1 * a.sin()) + }) + .collect(); + p.extend((1..=32).map(|k| { + let a = PI / 2.0 - PI * k as f64 / 32.0; + (0.06 * a.cos() - 0.03, 0.1 + 0.1 * a.sin()) + })); + pts(&p, true) + } + "teardrop" => polar(0.1, |a| 0.6 + 0.4 * (a / 2.0).cos().abs().powf(3.0)), + "football" => poly(64, true, |t| { + let a = TAU * t; + (0.12 * a.sin(), 0.06 * (1.0 - a.cos()) * (1.0 - 0.3 * (2.0 * a).cos().abs())) + }), + "plusPath" => pts( + &[ + (0.04, 0.0), + (0.04, 0.04), + (0.08, 0.04), + (0.08, 0.08), + (0.04, 0.08), + (0.04, 0.12), + (0.0, 0.12), + (0.0, 0.08), + (-0.04, 0.08), + (-0.04, 0.04), + (0.0, 0.04), + ], + true, + ), + "rightTriangle" => pts(&[(0.0, 0.15), (0.15, 0.15)], true), + "curvedSquare" => poly(96, true, |t| { + let a = -PI / 2.0 + TAU * t; + let sg = |v: f64| v.signum() * v.abs().sqrt(); + (0.1 * sg(a.cos()), 0.1 + 0.1 * sg(a.sin())) + }), + "figure8Horizontal" => poly(96, true, |t| { + let a = TAU * t; + (0.15 * a.sin(), 0.06 * (2.0 * a).sin()) + }), + "figure8Vertical" => poly(96, true, |t| { + let a = TAU * t; + (0.06 * (2.0 * a).sin(), 0.15 * (1.0 - a.cos())) + }), + "loopDeLoop" => poly(120, false, |t| (0.3 * t - 0.05 * (2.0 * TAU * t).sin(), -0.05 * (1.0 - (2.0 * TAU * t).cos()))), + "bean" => polar(0.1, |a| 0.75 + 0.25 * a.cos() + 0.15 * (2.0 * a).cos()), + "peanut" => polar(0.1, |a| 0.7 + 0.3 * (2.0 * a).cos().abs()), + "neutron" => polar(0.1, |a| 0.35 + 0.65 * (3.0 * a).cos().abs()), + _ => return None, + }) +} + +#[cfg(test)] +mod option_label_tests { + use super::*; + + #[test] + fn transition_option_labels() { + assert_eq!(transition_option_label("push", "l"), "From Right"); + assert_eq!(transition_option_label("push", "u"), "From Bottom"); + assert_eq!(transition_option_label("wipe", "r"), "From Left"); + assert_eq!(transition_option_label("cover", "ru"), "From Bottom-Left"); + assert_eq!(transition_option_label("ripple", "ru"), "From Bottom-Left"); + assert_eq!(transition_option_label("glitter", "hexU"), "Hexagons From Top"); + assert_eq!(transition_option_label("glitter", "l"), "Diamonds From Left"); + assert_eq!(transition_option_label("wind", "l"), "Left"); + assert_eq!(transition_option_label("pageCurlDouble", "singleR"), "Single Right"); + assert_eq!(transition_option_label("shred", "particlesOut"), "Particles Out"); + assert_eq!(transition_option_label("zoom", "rotate"), "Zoom and Rotate"); + assert_eq!(transition_option_label("split", "vertIn"), "Vertical In"); + for (id, _, _, _, opts) in TRANSITIONS { + for o in opts.iter() { + assert!(!transition_option_label(id, o).is_empty(), "{id}/{o}"); + } + } + } +} + +#[cfg(test)] +mod build_role_tests { + use super::*; + use text::TextBody; + + #[test] + fn blank_and_deeper_paragraphs_follow_the_top_one_before_them() { + let mut t = TextBody::from_text("Before\nLead\n \nSub\nNext"); + for (p, lvl) in t.paragraphs.iter_mut().zip([1, 0, 0, 1, 0]) { + p.level = lvl; + } + use BuildRole::*; + assert_eq!(paragraph_build_roles(&t), vec![Unowned, Top, Member(1), Member(1), Top]); + } +} diff --git a/crates/pptx/src/lib.rs b/crates/pptx/src/lib.rs new file mode 100644 index 000000000..ac935300a --- /dev/null +++ b/crates/pptx/src/lib.rs @@ -0,0 +1,1079 @@ +//! PresentationML (ECMA-376 part 1, chapter 19): the model of a `.pptx` presentation. +//! +//! A [`Presentation`] has slide masters (each with a theme and layouts) and slides. Each slide +//! uses a layout; placeholders on a slide inherit position, formatting and text styles from the +//! matching placeholder on its layout and master ([`placeholder::parents`]). Slides and masters +//! sit behind `Arc`s, so a snapshot of the whole deck costs one clone of the slide list. +//! +//! DrawingML (fills, lines, text bodies, tables, charts, themes) comes from [`craft_drawingml`]; +//! [`anim`] holds transitions and animations. Media bytes live in [`Presentation::media`] and +//! everything else names them by [`MediaId`]. The types derive serde with camelCase names, so a +//! presentation can be stored as JSON. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +pub mod anim; +pub mod placeholder; + +use std::sync::Arc; + +pub use craft_drawingml::color::{ColorScheme, ColorTransform, Rgba, SchemeSlot}; +pub use craft_drawingml::custgeom::CustomPath; +pub use craft_drawingml::diagram::{Diagram, DiagramNode}; +pub use craft_drawingml::style::{self, ColorBase, ColorRef, Effects, Fill, Line, ShapeStyle}; +pub use craft_drawingml::table::{self, Cell, Table}; +pub use craft_drawingml::text::{self, Paragraph, Run, RunProps, TextBody}; +pub use craft_drawingml::theme::{self, Theme}; +pub use craft_drawingml::{MediaId, SlideId, Xfrm, chart, chart::Chart}; +pub use kurbo::{Rect, Size}; +use serde::{Deserialize, Serialize}; + +pub use anim::{AnimClass, AnimStart, Animation, Transition}; + +macro_rules! id_type { + ($(#[$doc:meta])* $name:ident) => { + $(#[$doc])* + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] + #[serde(transparent)] + pub struct $name(pub u32); + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } + } + }; +} +id_type!( + /// A shape, unique across the presentation. + ShapeId +); +id_type!( + /// A slide layout. + LayoutId +); +id_type!( + /// A slide master. + MasterId +); + +/// Placeholder types. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PhType { + Title, + CtrTitle, + SubTitle, + #[default] + Body, + Obj, + Chart, + Table, + ClipArt, + Diagram, + Media, + Picture, + Date, + Footer, + SlideNum, + Header, + SlideImage, +} + +impl PhType { + pub fn xml(self) -> &'static str { + match self { + PhType::Title => "title", + PhType::CtrTitle => "ctrTitle", + PhType::SubTitle => "subTitle", + PhType::Body => "body", + PhType::Obj => "obj", + PhType::Chart => "chart", + PhType::Table => "tbl", + PhType::ClipArt => "clipArt", + PhType::Diagram => "dgm", + PhType::Media => "media", + PhType::Picture => "pic", + PhType::Date => "dt", + PhType::Footer => "ftr", + PhType::SlideNum => "sldNum", + PhType::Header => "hdr", + PhType::SlideImage => "sldImg", + } + } + pub fn from_xml(s: &str) -> Self { + match s { + "title" => PhType::Title, + "ctrTitle" => PhType::CtrTitle, + "subTitle" => PhType::SubTitle, + "obj" => PhType::Obj, + "chart" => PhType::Chart, + "tbl" => PhType::Table, + "clipArt" => PhType::ClipArt, + "dgm" => PhType::Diagram, + "media" => PhType::Media, + "pic" => PhType::Picture, + "dt" => PhType::Date, + "ftr" => PhType::Footer, + "sldNum" => PhType::SlideNum, + "hdr" => PhType::Header, + "sldImg" => PhType::SlideImage, + _ => PhType::Body, + } + } + pub fn is_title(self) -> bool { + matches!(self, PhType::Title | PhType::CtrTitle) + } + /// The prompt shown in an empty placeholder in Normal view. + pub fn prompt(self) -> &'static str { + match self { + PhType::Title | PhType::CtrTitle => "Click to add title", + PhType::SubTitle => "Click to add subtitle", + PhType::Body | PhType::Obj => "Click to add text", + PhType::Chart => "Click icon to add chart", + PhType::Table => "Click icon to add table", + PhType::Diagram => "Click icon to add SmartArt graphic", + PhType::Media => "Click icon to add media", + PhType::Picture | PhType::ClipArt => "Click icon to add picture", + PhType::Date => "Date", + PhType::Footer => "Footer", + PhType::SlideNum => "‹#›", + PhType::Header => "Header", + PhType::SlideImage => "", + } + } + /// Footer-row placeholders are not shown on slides unless the deck turns them on. + pub fn is_footer_kind(self) -> bool { + matches!(self, PhType::Date | PhType::Footer | PhType::SlideNum | PhType::Header) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Placeholder { + pub kind: PhType, + pub idx: u32, + /// `vert` orientation flag. + pub vertical: bool, + /// `quarter`, `half`, `full` size hint. + pub size: Option, + pub has_custom_prompt: bool, +} + +/// Shape geometry: a preset name with adjust values, or a custom path. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum Geom { + Preset { + name: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + adj: Vec, + }, + /// Custom geometry: sub-paths in their own coordinate space (w, h) scaled to the shape. + Custom { paths: Vec }, +} + +impl Default for Geom { + fn default() -> Self { + Geom::Preset { name: "rect".into(), adj: vec![] } + } +} + +impl Geom { + pub fn preset(name: &str) -> Self { + Geom::Preset { name: name.into(), adj: vec![] } + } + pub fn preset_name(&self) -> Option<&str> { + match self { + Geom::Preset { name, .. } => Some(name), + Geom::Custom { .. } => None, + } + } +} + +/// Embedded audio or video on a slide. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MediaClip { + pub media: MediaId, + pub video: bool, + /// Poster frame image. + pub poster: Option, + /// Crop of the picture (left, top, right, bottom as fractions), PowerPoint's `srcRect` on the video. + pub crop: [f64; 4], + pub trim_start_ms: u32, + pub trim_end_ms: u32, + pub fade_in_ms: u32, + pub fade_out_ms: u32, + /// 0–1. + pub volume: f64, + pub autoplay: bool, + pub loop_play: bool, + pub rewind: bool, + pub play_across_slides: bool, + pub hide_while_not_playing: bool, + pub full_screen: bool, + /// Bookmarks (name, ms). + pub bookmarks: Vec<(String, u32)>, + /// Probed length in ms (0 = unknown). + pub duration_ms: u32, + /// Probed picture size of a video in pixels (0 = unknown). + pub width: u32, + pub height: u32, +} + +/// One ink stroke (Draw tab): points in slide coordinates with pressure (`0..=1`, +/// where `1` is [`InkStroke::width`]). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct InkStroke { + pub points: Vec<(f64, f64, f32)>, + pub color: Rgba, + pub width: f64, + pub highlighter: bool, +} + +/// True when every sample is the placeholder `0.5` written before a device reported pressure. +/// Those strokes were drawn at [`InkStroke::width`] and still are. +pub fn ink_pressure_is_legacy(points: &[(f64, f64, f32)]) -> bool { + !points.is_empty() && points.iter().all(|p| (p.2 - 0.5).abs() <= 1.0e-3) +} + +/// Width of one ink sample. `pressure` of `1` is the pen width. A legacy flat stroke +/// (`legacy`) draws at the pen width. A missing reading draws at the pen width too. +/// A light touch stays visible (at least 8% of the pen). +pub fn ink_sample_width(pen: f64, pressure: f32, legacy: bool) -> f64 { + let pen = if pen.is_finite() { pen.abs() } else { 0.0 }; + if legacy { + return pen; + } + let p = if pressure.is_finite() { pressure.clamp(0.08, 1.0) } else { 1.0 }; + pen * f64::from(p) +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum ShapeKind { + /// An autoshape or text box. + Shape, + Picture { + fill: style::PictureFill, + }, + Group { + children: Vec, + /// Child coordinate space. + child: Xfrm, + }, + Connector { + start: Option<(ShapeId, u32)>, + end: Option<(ShapeId, u32)>, + }, + Table(Table), + Chart(Box), + Media(MediaClip), + Ink { + strokes: Vec, + }, + /// A 3-D model (`p:contentPart` with a `model3d` relationship, usually a `.glb`). + /// `poster` is the fallback picture an `mc:AlternateContent` choice carried for older readers. + Model3d { + media: MediaId, + #[serde(default)] + poster: Option, + }, + /// Something we read but don't model (SmartArt without fallback, OLE…): its XML is kept and its + /// fallback picture, if any, is shown. + Opaque { + xml: String, + preview: Option, + label: String, + /// The parts its XML points to (SmartArt data, an OLE embedding…), so a save writes them + /// back: (relationship id used in `xml`, relationship type, the part as a media item). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + parts: Vec<(String, String, MediaId)>, + }, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Shape { + pub id: ShapeId, + pub name: String, + /// `None`: placeholder inheriting its box from the layout/master. + pub xfrm: Option, + pub kind: ShapeKind, + pub geom: Geom, + pub ph: Option, + /// `None`: inherited (placeholder chain or style); `Some(Fill::None)`: no fill. + pub fill: Option, + pub line: Option, + pub effects: Option, + pub style: Option, + pub text: Option, + pub hidden: bool, + pub locked: bool, + /// Text box (`txBox="1"`): no fill, autosize, wrap off by default. + pub text_box: bool, + /// Alt text. + pub descr: String, + pub title: String, + pub decorative: bool, + pub click: Option, + pub hover: Option, + /// Add-in tags (`p:custDataLst` → `ppt/tags/tagN.xml`): name/value pairs kept for round trip. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tags: Vec<(String, String)>, + /// Unmodelled XML kept for round-trip (extension lists etc.). + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_ext: Option, + /// On a SmartArt group: the diagram's text outline, from which its shapes are regenerated. + #[serde(skip_serializing_if = "Option::is_none")] + pub diagram: Option, +} + +impl Default for Shape { + fn default() -> Self { + Shape { + id: ShapeId(0), + name: String::new(), + xfrm: None, + kind: ShapeKind::Shape, + geom: Geom::default(), + ph: None, + fill: None, + line: None, + effects: None, + style: None, + text: None, + hidden: false, + locked: false, + text_box: false, + descr: String::new(), + title: String::new(), + decorative: false, + click: None, + hover: None, + tags: vec![], + raw_ext: None, + diagram: None, + } + } +} + +impl Shape { + /// A stand-in shape for laying out one table cell's `body`: a table, so the cell's text takes + /// the master's other-text style as PowerPoint's does. + pub fn table_cell(body: text::TextBody) -> Shape { + Shape { text: Some(body), kind: ShapeKind::Table(Table::default()), ..Default::default() } + } + pub fn is_placeholder(&self) -> bool { + self.ph.is_some() + } + pub fn ph_type(&self) -> Option { + self.ph.as_ref().map(|p| p.kind) + } + pub fn is_group(&self) -> bool { + matches!(self.kind, ShapeKind::Group { .. }) + } + pub fn children(&self) -> &[Shape] { + match &self.kind { + ShapeKind::Group { children, .. } => children, + _ => &[], + } + } + pub fn children_mut(&mut self) -> Option<&mut Vec> { + match &mut self.kind { + ShapeKind::Group { children, .. } => Some(children), + _ => None, + } + } + /// Is this a line-like shape (no area)? + pub fn is_line(&self) -> bool { + matches!(self.kind, ShapeKind::Connector { .. }) || self.geom.preset_name().is_some_and(craft_drawingml::preset::is_line_like) + } + pub fn kind_name(&self) -> &'static str { + match &self.kind { + ShapeKind::Shape if self.ph.is_some() => "placeholder", + ShapeKind::Shape if self.text_box => "textBox", + ShapeKind::Shape => "shape", + ShapeKind::Picture { .. } => "picture", + ShapeKind::Group { .. } => "group", + ShapeKind::Connector { .. } => "connector", + ShapeKind::Table(_) => "table", + ShapeKind::Chart(_) => "chart", + ShapeKind::Media(m) if m.video => "video", + ShapeKind::Media(_) => "audio", + ShapeKind::Ink { .. } => "ink", + ShapeKind::Model3d { .. } => "model", + ShapeKind::Opaque { .. } => "object", + } + } +} + +/// Walk shapes depth-first (groups included). +pub fn walk<'a>(shapes: &'a [Shape], f: &mut dyn FnMut(&'a Shape, usize)) { + fn rec<'a>(shapes: &'a [Shape], depth: usize, f: &mut dyn FnMut(&'a Shape, usize)) { + if depth > 64 { + return; + } + for s in shapes { + f(s, depth); + rec(s.children(), depth + 1, f); + } + } + rec(shapes, 0, f); +} + +pub fn find_shape(shapes: &[Shape], id: ShapeId) -> Option<&Shape> { + fn rec(shapes: &[Shape], id: ShapeId, depth: usize) -> Option<&Shape> { + if depth > 64 { + return None; + } + for s in shapes { + if s.id == id { + return Some(s); + } + if let Some(f) = rec(s.children(), id, depth + 1) { + return Some(f); + } + } + None + } + rec(shapes, id, 0) +} + +pub fn find_shape_mut(shapes: &mut [Shape], id: ShapeId) -> Option<&mut Shape> { + fn rec(shapes: &mut [Shape], id: ShapeId, depth: usize) -> Option<&mut Shape> { + if depth > 64 { + return None; + } + for s in shapes.iter_mut() { + if s.id == id { + return Some(s); + } + if let ShapeKind::Group { children, .. } = &mut s.kind + && let Some(f) = rec(children, id, depth + 1) + { + return Some(f); + } + } + None + } + rec(shapes, id, 0) +} + +/// The list holding `id` among its siblings: the tree's top level, or a group's children. +pub fn sibling_list_mut(shapes: &mut Vec, id: ShapeId) -> Option<&mut Vec> { + fn rec(shapes: &mut Vec, id: ShapeId, depth: usize) -> Option<&mut Vec> { + if depth > 64 { + return None; + } + if shapes.iter().any(|s| s.id == id) { + return Some(shapes); + } + for s in shapes.iter_mut() { + if let ShapeKind::Group { children, .. } = &mut s.kind + && let Some(f) = rec(children, id, depth + 1) + { + return Some(f); + } + } + None + } + rec(shapes, id, 0) +} + +/// The group directly holding `id`: `Some(None)` for a top-level shape, `None` when absent. +pub fn parent_of(shapes: &[Shape], id: ShapeId) -> Option> { + fn rec(shapes: &[Shape], parent: Option, id: ShapeId, depth: usize) -> Option> { + if depth > 64 { + return None; + } + for s in shapes { + if s.id == id { + return Some(parent); + } + if let Some(f) = rec(s.children(), Some(s.id), id, depth + 1) { + return Some(f); + } + } + None + } + rec(shapes, None, id, 0) +} + +/// Background of a slide, layout or master. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +#[allow(clippy::large_enum_variant)] // the native JSON shape is fixed; boxing would not change it but would change the API +pub enum Background { + Fill { + fill: Fill, + }, + /// Theme background style reference (1001–1003) with a colour. + Ref { + idx: u32, + color: ColorRef, + }, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Comment { + pub author: String, + pub initials: String, + pub text: String, + /// ISO 8601. + pub date: String, + pub x: f64, + pub y: f64, + pub resolved: bool, + pub replies: Vec, + /// The shape the comment is attached to. + pub shape: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Slide { + pub id: SlideId, + pub layout: LayoutId, + pub name: String, + pub shapes: Vec, + pub background: Option, + pub hidden: bool, + pub show_master_shapes: bool, + pub transition: Option, + pub animations: Vec, + /// Speaker notes. + pub notes: TextBody, + pub comments: Vec, + /// `clrMapOvr`: a colour map replacing the layout's or master's (`bg1`→`dk1`…); None follows + /// the layout. + #[serde(skip_serializing_if = "Option::is_none")] + pub color_map: Option>, + /// Add-in tags (`p:custDataLst` → `ppt/tags/tagN.xml`): name/value pairs kept for round trip. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tags: Vec<(String, String)>, + /// Unmodelled XML kept for round-trip (custom data). + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_ext: Option, +} + +impl Default for Slide { + fn default() -> Self { + Slide { + id: SlideId(0), + layout: LayoutId(0), + name: String::new(), + shapes: vec![], + background: None, + hidden: false, + show_master_shapes: true, + transition: None, + animations: vec![], + notes: TextBody::default(), + comments: vec![], + color_map: None, + tags: vec![], + raw_ext: None, + } + } +} + +impl Slide { + pub fn shape(&self, id: ShapeId) -> Option<&Shape> { + find_shape(&self.shapes, id) + } + pub fn shape_mut(&mut self, id: ShapeId) -> Option<&mut Shape> { + find_shape_mut(&mut self.shapes, id) + } + /// The slide's title text (first title placeholder), for thumbnails, outline and navigation. + pub fn title(&self) -> String { + self.shapes + .iter() + .find(|s| s.ph_type().is_some_and(PhType::is_title)) + .and_then(|s| s.text.as_ref()) + .map(|t| t.text().replace('\u{b}', " ")) + .unwrap_or_default() + } + pub fn notes_text(&self) -> String { + self.notes.text() + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LayoutType { + Title, + #[default] + TitleAndContent, + SectionHeader, + TwoContent, + Comparison, + TitleOnly, + Blank, + ContentWithCaption, + PictureWithCaption, + TitleAndVerticalText, + VerticalTitleAndText, + Custom, +} + +impl LayoutType { + pub fn xml(self) -> &'static str { + match self { + LayoutType::Title => "title", + LayoutType::TitleAndContent => "obj", + LayoutType::SectionHeader => "secHead", + LayoutType::TwoContent => "twoObj", + LayoutType::Comparison => "twoTxTwoObj", + LayoutType::TitleOnly => "titleOnly", + LayoutType::Blank => "blank", + LayoutType::ContentWithCaption => "objTx", + LayoutType::PictureWithCaption => "picTx", + LayoutType::TitleAndVerticalText => "vertTx", + LayoutType::VerticalTitleAndText => "vertTitleAndTx", + LayoutType::Custom => "cust", + } + } + pub fn from_xml(s: &str) -> Self { + match s { + "title" => LayoutType::Title, + "obj" | "tx" => LayoutType::TitleAndContent, + "secHead" => LayoutType::SectionHeader, + "twoObj" | "twoColTx" => LayoutType::TwoContent, + "twoTxTwoObj" => LayoutType::Comparison, + "titleOnly" => LayoutType::TitleOnly, + "blank" => LayoutType::Blank, + "objTx" => LayoutType::ContentWithCaption, + "picTx" => LayoutType::PictureWithCaption, + "vertTx" => LayoutType::TitleAndVerticalText, + "vertTitleAndTx" => LayoutType::VerticalTitleAndText, + _ => LayoutType::Custom, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Layout { + pub id: LayoutId, + pub name: String, + pub kind: LayoutType, + pub shapes: Vec, + pub background: Option, + pub show_master_shapes: bool, + pub preserve: bool, + /// `clrMapOvr`: a colour map replacing the master's for this layout and the slides using it + /// (dark "section" layouts map `bg1` to `dk1`); None follows the master. + #[serde(skip_serializing_if = "Option::is_none")] + pub color_map: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_ext: Option, +} + +/// Which placeholders slides show in the footer row (Header & Footer dialog). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct HeaderFooter { + pub date: bool, + pub slide_number: bool, + pub footer: bool, + pub header: bool, + pub footer_text: String, + /// Fixed date text; empty = automatic date. + pub date_text: String, + /// `datetime1`…`datetime13` format for automatic dates. + pub date_format: String, + pub hide_on_title: bool, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Master { + pub id: MasterId, + pub name: String, + pub theme: Theme, + pub shapes: Vec, + pub background: Option, + pub title_style: text::ListStyle, + pub body_style: text::ListStyle, + pub other_style: text::ListStyle, + pub layouts: Vec, + /// `bg1`→`lt1` etc. Default mapping when empty. + pub color_map: Vec<(String, String)>, + pub preserve: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_ext: Option, +} + +impl Master { + pub fn layout(&self, id: LayoutId) -> Option<&Layout> { + self.layouts.iter().find(|l| l.id == id) + } + /// Resolve `bg1`/`tx1`/`bg2`/`tx2` through the colour map. + pub fn map_slot(&self, slot: SchemeSlot) -> SchemeSlot { + map_slot(&self.color_map, slot) + } + /// The theme's colour scheme with the colour map applied (`bg1`… resolve to mapped colours). + pub fn scheme(&self) -> ColorScheme { + self.scheme_with(&self.color_map) + } + /// The theme's colour scheme with `map` (a layout's or slide's override, or the master's own) + /// applied. + pub fn scheme_with(&self, map: &[(String, String)]) -> ColorScheme { + let mut s = self.theme.colors.clone(); + if map.is_empty() { + return s; + } + let base = self.theme.colors.clone(); + for (alias, slot) in [ + (SchemeSlot::Bg1, SchemeSlot::Lt1), + (SchemeSlot::Tx1, SchemeSlot::Dk1), + (SchemeSlot::Bg2, SchemeSlot::Lt2), + (SchemeSlot::Tx2, SchemeSlot::Dk2), + ] { + s.set(slot, base.get(map_slot(map, alias))); + } + s + } +} + +/// Resolve `bg1`/`tx1`/`bg2`/`tx2` through the colour map `map` (the default mapping when it has +/// no entry). +pub fn map_slot(map: &[(String, String)], slot: SchemeSlot) -> SchemeSlot { + let key = match slot { + SchemeSlot::Bg1 => "bg1", + SchemeSlot::Tx1 => "tx1", + SchemeSlot::Bg2 => "bg2", + SchemeSlot::Tx2 => "tx2", + other => return other, + }; + map.iter().find(|(k, _)| k == key).and_then(|(_, v)| SchemeSlot::from_xml(v)).unwrap_or(match slot { + SchemeSlot::Bg1 => SchemeSlot::Lt1, + SchemeSlot::Tx1 => SchemeSlot::Dk1, + SchemeSlot::Bg2 => SchemeSlot::Lt2, + _ => SchemeSlot::Dk2, + }) +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Section { + pub name: String, + pub slides: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CustomShow { + pub name: String, + pub slides: Vec, +} + +/// Slide Show ▸ Set Up Slide Show. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ShowSettings { + /// `speaker`, `browsed`, `kiosk`. + pub show_type: String, + pub loop_until_esc: bool, + pub without_narration: bool, + pub without_animation: bool, + pub use_timings: bool, + /// 1-based inclusive range; `None` = all. + pub range: Option<(u32, u32)>, + pub custom_show: Option, + pub pen_color: Rgba, + pub laser_color: Rgba, + pub show_media_controls: bool, +} + +impl Default for ShowSettings { + fn default() -> Self { + ShowSettings { + show_type: "speaker".into(), + loop_until_esc: false, + without_narration: false, + without_animation: false, + use_timings: true, + range: None, + custom_show: None, + pen_color: Rgba::rgb(0xFF, 0x00, 0x00), + laser_color: Rgba::rgb(0xFF, 0x00, 0x00), + show_media_controls: true, + } + } +} + +/// Document properties (File ▸ Properties). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Properties { + pub title: String, + pub subject: String, + pub author: String, + pub keywords: String, + pub comments: String, + pub category: String, + pub company: String, + pub created: String, + pub modified: String, + pub last_modified_by: String, + pub revision: u32, +} + +/// One typeface embedded in the presentation (`p:embeddedFont`). Each slot is a font file in +/// [`Presentation::media`]: regular, bold, italic, bold italic. A slot is absent when the file +/// didn't include that face. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct EmbeddedFont { + pub typeface: String, + /// `panose` on `p:font`, when the file gave the 20 hex digits. + #[serde(skip_serializing_if = "Option::is_none")] + pub panose: Option, + /// `pitchFamily` on `p:font`. + #[serde(skip_serializing_if = "Option::is_none")] + pub pitch_family: Option, + /// `charset` on `p:font`. + #[serde(skip_serializing_if = "Option::is_none")] + pub charset: Option, + pub regular: Option, + pub bold: Option, + pub italic: Option, + pub bold_italic: Option, +} + +impl EmbeddedFont { + /// The four face slots, in the order the package stores them. + pub fn faces(&self) -> [Option; 4] { + [self.regular, self.bold, self.italic, self.bold_italic] + } +} + +/// An embedded file (image, audio, video, font, workbook). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MediaItem { + pub id: MediaId, + /// Original file name, e.g. `image1.png`. + pub name: String, + pub content_type: String, + /// Bytes; kept out of the JSON manifest and stored as a separate file in native files. + #[serde(skip)] + pub data: Arc>, + /// External link instead of embedded bytes. + pub link: Option, +} + +/// A size whose sides may be missing, null or not numbers (NaN then); see `Presentation::slide_size`. +fn lenient_size<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result { + let v = serde_json::Value::deserialize(d)?; + let side = |k: &str| v.get(k).and_then(serde_json::Value::as_f64).unwrap_or(f64::NAN); + Ok(Size::new(side("width"), side("height"))) +} + +/// An XML package part the reader does not model, kept as written. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct KeptPart { + /// Part name from the package root (`customXml/item1.xml`). + pub name: String, + /// Content type; empty for a relationships part. + pub content_type: String, + pub xml: String, + /// Full relationship type linking it, empty when only its sibling parts point at it. + pub rel_type: String, + /// Linked from the package root rather than from the presentation part. + pub from_root: bool, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Presentation { + /// Slide size in points (default 16:9, 960 × 540). Read leniently: a missing, null or + /// non-numeric side becomes NaN for `the caller` to replace, instead of the + /// whole file failing to open. + #[serde(deserialize_with = "lenient_size")] + pub slide_size: Size, + #[serde(deserialize_with = "lenient_size")] + pub notes_size: Size, + pub slides: Vec>, + pub masters: Vec>, + pub notes_master: Option>, + pub handout_master: Option>, + pub sections: Vec
, + pub custom_shows: Vec, + pub show: ShowSettings, + pub header_footer: HeaderFooter, + pub props: Properties, + /// Presentation-wide default text style (shapes that aren't placeholders). + pub default_text_style: text::ListStyle, + pub media: Vec, + pub first_slide_number: u32, + /// Next free shape/slide/layout/media id. + pub next_id: u32, + /// Embedded fonts. The face bytes live in [`Self::media`]. + pub embedded_fonts: Vec, + /// Table styles defined in the file (`ppt/tableStyles.xml`). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub table_styles: Vec, + /// Add-in tags (`p:custDataLst` → `ppt/tags/tagN.xml`): name/value pairs kept for round trip. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tags: Vec<(String, String)>, + /// Package parts kept verbatim for round trip: custom XML data (SharePoint metadata), the + /// sensitivity label and custom document properties. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub kept_parts: Vec, + /// Drawing guides (View › Guides), shared by every slide (`p:guideLst` in viewProps). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub guides: Vec, + /// Unmodelled presentation-level XML kept for round-trip. + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_ext: Option, +} + +/// A drawing guide: a vertical line at `pos` points from the slide's left edge, or a horizontal +/// one from its top. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DrawGuide { + pub vertical: bool, + pub pos: f64, +} + +impl Default for Presentation { + /// An empty deck: 16:9 slides (960 × 540 points), no masters and no slides. An application + /// builds its own starting deck (masters, layouts, text styles) on top of this. + fn default() -> Self { + Presentation { + slide_size: Size::new(960.0, 540.0), + notes_size: Size::new(540.0, 720.0), + slides: vec![], + masters: vec![], + notes_master: None, + handout_master: None, + sections: vec![], + custom_shows: vec![], + show: ShowSettings::default(), + header_footer: HeaderFooter::default(), + props: Properties::default(), + default_text_style: text::ListStyle::default(), + media: vec![], + first_slide_number: 1, + next_id: 0, + embedded_fonts: vec![], + table_styles: vec![], + tags: vec![], + kept_parts: vec![], + guides: vec![], + raw_ext: None, + } + } +} + +impl Presentation { + /// A fresh id, unique across slides, shapes, layouts, masters and media in this deck. `None` + /// once every id is in use (a hostile file can arrive that way): the deck cannot grow + /// further, and callers must refuse the change rather than duplicate ids. + pub fn alloc_id(&mut self) -> Option { + let next = self.next_id.max(1).checked_add(1)?; + self.next_id = next; + Some(next) + } + /// Make sure `next_id` is above every id in use (after loading a file). + pub fn fix_next_id(&mut self) { + let mut m = self.next_id; + for s in &self.slides { + m = m.max(s.id.0); + walk(&s.shapes, &mut |sh, _| m = m.max(sh.id.0)); + } + for ma in &self.masters { + m = m.max(ma.id.0); + walk(&ma.shapes, &mut |sh, _| m = m.max(sh.id.0)); + for l in &ma.layouts { + m = m.max(l.id.0); + walk(&l.shapes, &mut |sh, _| m = m.max(sh.id.0)); + } + } + for md in &self.media { + m = m.max(md.id.0); + } + for extra in [self.notes_master.as_deref(), self.handout_master.as_deref()].into_iter().flatten() { + m = m.max(extra.id.0); + walk(&extra.shapes, &mut |sh, _| m = m.max(sh.id.0)); + } + self.next_id = m; + } + pub fn slide_index(&self, id: SlideId) -> Option { + self.slides.iter().position(|s| s.id == id) + } + pub fn slide(&self, id: SlideId) -> Option<&Slide> { + self.slides.iter().find(|s| s.id == id).map(AsRef::as_ref) + } + pub fn slide_mut(&mut self, id: SlideId) -> Option<&mut Slide> { + self.slides.iter_mut().find(|s| s.id == id).map(Arc::make_mut) + } + pub fn slide_at(&self, i: usize) -> Option<&Slide> { + self.slides.get(i).map(AsRef::as_ref) + } + /// Master and layout for a layout id. + pub fn layout(&self, id: LayoutId) -> Option<(&Master, &Layout)> { + self.masters.iter().find_map(|m| m.layout(id).map(|l| (m.as_ref(), l))) + } + /// The master (and layout) a slide uses, falling back to the first. + pub fn master_for(&self, slide: &Slide) -> Option<(&Master, Option<&Layout>)> { + if let Some((m, l)) = self.layout(slide.layout) { + return Some((m, Some(l))); + } + self.masters.first().map(|m| (m.as_ref(), m.layouts.first())) + } + pub fn first_master(&self) -> Option<&Master> { + self.masters.first().map(AsRef::as_ref) + } + pub fn media(&self, id: MediaId) -> Option<&MediaItem> { + self.media.iter().find(|m| m.id == id) + } + /// Add media bytes, reusing an identical existing item. + pub fn add_media(&mut self, name: &str, content_type: &str, data: Vec) -> Option { + if let Some(m) = self.media.iter().find(|m| m.data.len() == data.len() && *m.data == data) { + return Some(m.id); + } + let id = MediaId(self.alloc_id()?); + self.media.push(MediaItem { id, name: name.into(), content_type: content_type.into(), data: Arc::new(data), link: None }); + Some(id) + } + /// Section index containing a slide (by position), when sections exist. + pub fn section_of(&self, slide: SlideId) -> Option { + self.sections.iter().position(|s| s.slides.contains(&slide)) + } + /// 1-based slide number as displayed (respects `first_slide_number`). + pub fn slide_number(&self, index: usize) -> u32 { + self.first_slide_number.saturating_add(u32::try_from(index).unwrap_or(u32::MAX)) + } + /// Basic consistency check: unique slide/shape ids, layouts exist. Returns problems found. + pub fn validate(&self) -> Vec { + let mut out = vec![]; + let mut seen = std::collections::HashSet::new(); + // Indexed once: a per-slide scan of every layout is slides × layouts work. + let layouts: std::collections::HashSet<_> = self.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.id)).collect(); + for s in &self.slides { + if !seen.insert(s.id) { + out.push(format!("duplicate slide id {}", s.id)); + } + if !layouts.contains(&s.layout) { + out.push(format!("slide {} uses missing layout {}", s.id, s.layout)); + } + let mut ids = std::collections::HashSet::new(); + walk(&s.shapes, &mut |sh, _| { + if !ids.insert(sh.id) { + out.push(format!("slide {}: duplicate shape id {}", s.id, sh.id)); + } + }); + } + out + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/pptx/src/placeholder.rs b/crates/pptx/src/placeholder.rs new file mode 100644 index 000000000..3ee82846e --- /dev/null +++ b/crates/pptx/src/placeholder.rs @@ -0,0 +1,106 @@ +//! Placeholder inheritance: which layout and master placeholders a placeholder takes its +//! position, formatting and text styles from (`p:ph` `type` and `idx`, ECMA-376 part 1, 19.3.1.36). +//! +//! Only the matching lives here. What a caller does with the parents (effective position, fill, +//! text style) is its own business. + +use crate::{Layout, Master, PhType, Shape}; + +/// Where a shape lives. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Owner { + Slide, + Layout, + Master, +} + +/// Can a slide placeholder of type `slide` inherit from a layout placeholder of type `other`? +fn compatible(slide: PhType, other: PhType) -> bool { + use PhType::*; + match slide { + Title | CtrTitle => matches!(other, Title | CtrTitle), + SubTitle | Body | Obj | Chart | Table | ClipArt | Diagram | Media | Picture => { + !matches!(other, Title | CtrTitle | Date | Footer | SlideNum | Header) + } + k => k == other, + } +} + +/// The placeholder in `shapes` that `shape` matches: by `idx` (when `by_idx`) and a compatible +/// type, else the same type, else a compatible one. +pub fn find_ph<'s>(shapes: &'s [Shape], shape: &Shape, by_idx: bool) -> Option<&'s Shape> { + let ph = shape.ph.as_ref()?; + if by_idx + && ph.idx != 0 + && let Some(s) = shapes.iter().find(|s| s.ph.as_ref().is_some_and(|p| p.idx == ph.idx && compatible(ph.kind, p.kind))) + { + return Some(s); + } + // Same type first, then a compatible one. + shapes + .iter() + .find(|s| s.ph.as_ref().is_some_and(|p| p.kind == ph.kind && (!by_idx || ph.idx == 0 || p.idx == ph.idx || ph.kind.is_title()))) + .or_else(|| shapes.iter().find(|s| s.ph.as_ref().is_some_and(|p| p.kind == ph.kind))) + .or_else(|| shapes.iter().find(|s| s.ph.as_ref().is_some_and(|p| compatible(ph.kind, p.kind)))) +} + +/// The master placeholder a placeholder of type `kind` inherits from: the title, the matching +/// footer-row placeholder, or the body. +pub fn master_ph(master: &Master, kind: PhType) -> Option<&Shape> { + let want = match kind { + PhType::Title | PhType::CtrTitle => PhType::Title, + PhType::Date | PhType::Footer | PhType::SlideNum | PhType::Header => kind, + _ => PhType::Body, + }; + master.shapes.iter().find(|s| s.ph_type() == Some(want)) +} + +/// The layout and master placeholders `shape` inherits from. A slide's placeholder matches one on +/// `layout`, and the master placeholder follows the layout one's type; a layout's placeholder +/// inherits from the master only; a master's from nothing. +pub fn parents<'a>(master: &'a Master, layout: Option<&'a Layout>, owner: Owner, shape: &Shape) -> (Option<&'a Shape>, Option<&'a Shape>) { + let Some(ph) = shape.ph.as_ref() else { return (None, None) }; + match owner { + Owner::Slide => { + let lp = layout.and_then(|l| find_ph(&l.shapes, shape, true)); + let kind = lp.and_then(Shape::ph_type).unwrap_or(ph.kind); + (lp, master_ph(master, kind)) + } + Owner::Layout => (None, master_ph(master, ph.kind)), + Owner::Master => (None, None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Placeholder, ShapeId}; + + fn ph(id: u32, kind: PhType, idx: u32) -> Shape { + Shape { id: ShapeId(id), ph: Some(Placeholder { kind, idx, ..Default::default() }), ..Default::default() } + } + + #[test] + fn slide_placeholders_match_by_index_then_type() { + let master = Master { shapes: vec![ph(1, PhType::Title, 0), ph(2, PhType::Body, 1), ph(3, PhType::SlideNum, 12)], ..Default::default() }; + let layout = Layout { shapes: vec![ph(10, PhType::Title, 0), ph(11, PhType::Body, 1), ph(12, PhType::Obj, 2)], ..Default::default() }; + let id = |s: Option<&Shape>| s.map(|s| s.id.0); + // By idx: idx 2 is the layout's obj, whose master parent is the body. + let (l, m) = parents(&master, Some(&layout), Owner::Slide, &ph(20, PhType::Body, 2)); + assert_eq!((id(l), id(m)), (Some(12), Some(2))); + // A centred title takes the layout's title and the master's title. + let (l, m) = parents(&master, Some(&layout), Owner::Slide, &ph(21, PhType::CtrTitle, 0)); + assert_eq!((id(l), id(m)), (Some(10), Some(1))); + // An unknown idx falls back to the same type. + let (l, _) = parents(&master, Some(&layout), Owner::Slide, &ph(22, PhType::Body, 9)); + assert_eq!(id(l), Some(11)); + // A title never inherits from a body, nor a slide number from a title. + assert_eq!(find_ph(&layout.shapes[1..], &ph(23, PhType::Title, 0), true).map(|s| s.id.0), None); + let (l, m) = parents(&master, Some(&layout), Owner::Slide, &ph(24, PhType::SlideNum, 12)); + assert_eq!((id(l), id(m)), (None, Some(3))); + // Layouts inherit from the master only, masters from nothing, other shapes from nothing. + assert_eq!(parents(&master, None, Owner::Layout, &ph(25, PhType::SubTitle, 1)), (None, master.shapes.get(1))); + assert_eq!(parents(&master, None, Owner::Master, &ph(26, PhType::Title, 0)), (None, None)); + assert_eq!(parents(&master, Some(&layout), Owner::Slide, &Shape::default()), (None, None)); + } +} diff --git a/crates/pptx/src/tests.rs b/crates/pptx/src/tests.rs new file mode 100644 index 000000000..dececf12f --- /dev/null +++ b/crates/pptx/src/tests.rs @@ -0,0 +1,131 @@ +use super::*; + +#[test] +fn serde_roundtrip() { + let mut p = Presentation::default(); + p.masters.push(Arc::new(Master { layouts: vec![Layout { id: LayoutId(2), ..Default::default() }], ..Default::default() })); + p.slides.push(Arc::new(Slide { id: SlideId(3), layout: LayoutId(2), ..Default::default() })); + let j = serde_json::to_string(&p).unwrap(); + let q: Presentation = serde_json::from_str(&j).unwrap(); + assert_eq!(p, q); + // Unknown and missing fields are tolerated. + let q: Presentation = serde_json::from_str(r#"{"slides":[],"zzz":1}"#).unwrap(); + assert!(q.slides.is_empty()); + assert_eq!(q.slide_size, Size::new(960.0, 540.0)); + // A side that is missing, null or not a number reads as NaN instead of failing the file. + let q: Presentation = serde_json::from_str(r#"{"slideSize":{"width":720,"height":null}}"#).unwrap(); + assert_eq!(q.slide_size.width, 720.0); + assert!(q.slide_size.height.is_nan()); +} + +/// A deck whose ids are used up (a hostile file can arrive that way) yields no further ids: +/// allocating never repeats one. +#[test] +fn exhausted_id_space_allocates_nothing() { + let mut p = Presentation { next_id: u32::MAX, ..Default::default() }; + assert_eq!(p.alloc_id(), None); + assert_eq!(p.alloc_id(), None); + assert!(p.add_media("x.png", "image/png", vec![9]).is_none(), "fresh media bytes are refused too"); +} + +#[test] +fn ids_are_unique_and_fix_next_id() { + let mut p = Presentation::default(); + let shape = Shape { id: ShapeId(40), ..Default::default() }; + p.slides.push(Arc::new(Slide { id: SlideId(30), layout: LayoutId(50), shapes: vec![shape], ..Default::default() })); + p.masters.push(Arc::new(Master { id: MasterId(7), layouts: vec![Layout { id: LayoutId(50), ..Default::default() }], ..Default::default() })); + p.fix_next_id(); + assert_eq!(p.next_id, 50); + assert_eq!(p.alloc_id(), Some(51)); + let m = p.add_media("a.png", "image/png", vec![1, 2]).unwrap(); + assert_eq!(p.add_media("b.png", "image/png", vec![1, 2]), Some(m), "identical bytes are shared"); + assert!(p.validate().is_empty(), "{:?}", p.validate()); + Arc::make_mut(&mut p.slides[0]).layout = LayoutId(99); + assert_eq!(p.validate(), ["slide 30 uses missing layout 99"]); +} + +#[test] +fn group_find_and_walk() { + let child = Shape { id: ShapeId(900), ..Default::default() }; + let g = Shape { id: ShapeId(901), kind: ShapeKind::Group { children: vec![child], child: Xfrm::new(0.0, 0.0, 1.0, 1.0) }, ..Default::default() }; + let mut slide = Slide { shapes: vec![g], ..Default::default() }; + assert!(slide.shape(ShapeId(900)).is_some()); + assert!(slide.shape_mut(ShapeId(900)).is_some()); + let mut n = 0; + walk(&slide.shapes, &mut |_, _| n += 1); + assert_eq!(n, 2); +} + +#[test] +fn walks_stop_at_depth_64() { + let mut s = Shape { id: ShapeId(1), ..Default::default() }; + for i in 2..100 { + s = Shape { id: ShapeId(i), kind: ShapeKind::Group { children: vec![s], child: Xfrm::default() }, ..Default::default() }; + } + let tree = vec![s]; + let mut n = 0; + walk(&tree, &mut |_, _| n += 1); + assert_eq!(n, 65); + assert!(find_shape(&tree, ShapeId(1)).is_none()); + assert!(find_shape(&tree, ShapeId(40)).is_some()); +} + +#[test] +fn sibling_lists_and_parents_reach_into_groups() { + let leaf = |id: u32| Shape { id: ShapeId(id), ..Default::default() }; + let group = |id: u32, children: Vec| Shape { + id: ShapeId(id), + kind: ShapeKind::Group { children, child: Default::default() }, + ..Default::default() + }; + let mut tree = vec![leaf(1), group(2, vec![leaf(3), group(4, vec![leaf(5)])]), leaf(6)]; + assert_eq!(parent_of(&tree, ShapeId(1)), Some(None)); + assert_eq!(parent_of(&tree, ShapeId(3)), Some(Some(ShapeId(2)))); + assert_eq!(parent_of(&tree, ShapeId(5)), Some(Some(ShapeId(4)))); + assert_eq!(parent_of(&tree, ShapeId(9)), None); + let ids = |v: &Vec| v.iter().map(|s| s.id.0).collect::>(); + assert_eq!(sibling_list_mut(&mut tree, ShapeId(6)).map(|v| ids(v)), Some(vec![1, 2, 6])); + assert_eq!(sibling_list_mut(&mut tree, ShapeId(4)).map(|v| ids(v)), Some(vec![3, 4])); + assert_eq!(sibling_list_mut(&mut tree, ShapeId(5)).map(|v| ids(v)), Some(vec![5])); + assert!(sibling_list_mut(&mut tree, ShapeId(9)).is_none()); +} + +#[test] +fn ink_width_follows_pressure_and_keeps_the_old_placeholder() { + assert!(ink_pressure_is_legacy(&[(0.0, 0.0, 0.5), (1.0, 1.0, 0.5)])); + assert!(!ink_pressure_is_legacy(&[(0.0, 0.0, 0.5), (1.0, 1.0, 1.0)])); + assert!(!ink_pressure_is_legacy(&[])); + assert!((ink_sample_width(8.0, 0.5, true) - 8.0).abs() < 1e-9); + assert!((ink_sample_width(8.0, 1.0, false) - 8.0).abs() < 1e-9); + assert!((ink_sample_width(8.0, 0.25, false) - 2.0).abs() < 1e-9); + let floor = ink_sample_width(8.0, 0.0, false); + assert!((floor - 0.64).abs() < 1e-4, "a light touch stays visible, got {floor}"); + assert!((ink_sample_width(8.0, f32::NAN, false) - 8.0).abs() < 1e-9); +} + +#[test] +fn colour_maps_alias_the_background_and_text_slots() { + let mut m = Master::default(); + let base = m.theme.colors.clone(); + assert_eq!(m.map_slot(SchemeSlot::Bg1), SchemeSlot::Lt1, "the default mapping when empty"); + assert_eq!(m.map_slot(SchemeSlot::Accent2), SchemeSlot::Accent2); + let dark: Vec<(String, String)> = [("bg1", "dk1"), ("tx1", "lt1")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect(); + let s = m.scheme_with(&dark); + assert_eq!(s.get(SchemeSlot::Lt1), base.get(SchemeSlot::Dk1)); + assert_eq!(s.get(SchemeSlot::Dk1), base.get(SchemeSlot::Lt1)); + assert_eq!(s.get(SchemeSlot::Lt2), base.get(SchemeSlot::Lt2), "bg2 keeps its default mapping"); + m.color_map = dark; + assert_eq!(m.scheme(), s); +} + +#[test] +fn enum_names_round_trip_through_their_xml() { + for k in [PhType::Title, PhType::CtrTitle, PhType::Table, PhType::Diagram, PhType::SlideImage, PhType::SlideNum] { + assert_eq!(PhType::from_xml(k.xml()), k); + } + assert_eq!(PhType::from_xml("zzz"), PhType::Body); + for k in [LayoutType::Title, LayoutType::Comparison, LayoutType::VerticalTitleAndText, LayoutType::Custom] { + assert_eq!(LayoutType::from_xml(k.xml()), k); + } + assert_eq!(LayoutType::from_xml("twoColTx"), LayoutType::TwoContent); +} diff --git a/crates/psd/Cargo.toml b/crates/psd/Cargo.toml index d9d02abae..c9aa6dbe0 100644 --- a/crates/psd/Cargo.toml +++ b/crates/psd/Cargo.toml @@ -21,6 +21,9 @@ flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", encoding_rs = { workspace = true } serde = { workspace = true, optional = true } +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +rayon = { workspace = true } + [dev-dependencies] proptest = { workspace = true } craft-psd = { path = ".", features = ["testgen"] } diff --git a/crates/psd/src/atn.rs b/crates/psd/src/atn.rs index 09a187686..32f3bce31 100644 --- a/crates/psd/src/atn.rs +++ b/crates/psd/src/atn.rs @@ -1,4 +1,4 @@ -//! Photoshop Actions files (`.atn`): one action set, its actions and their steps. +//! Photoshop Actions files (`.atn`): action sets, their actions and their steps. //! //! Implemented clean-room from Adobe's "Photoshop File Formats" specification (the *Actions* //! and *Descriptor structure* sections) and observation of files Photoshop writes. No code was @@ -6,7 +6,8 @@ //! code-unit count + UTF-16BE, usually NUL-terminated) unless noted. //! //! ```text -//! file := version u32 (12 or 16), set name UnicodeString, expanded u8, count u32, count × action +//! file := version u32 (12 or 16), set (version 16 may hold further sets until EOF) +//! set := name UnicodeString, expanded u8, count u32, count × action //! action := function key u16 (0 = none, n = Fn), shift u8, command u8, colour u16, //! name UnicodeString, expanded u8, count u32, count × item //! item := expanded u8, enabled u8, with dialog u8, dialog options u8, @@ -31,8 +32,9 @@ //! //! [`parse`] never panics: every count is bounds-checked against the bytes left, descriptor //! nesting is bounded, and allocations are capped. [`write`] always produces version 16, so -//! `parse(write(f)) == f` for files [`parse`] read, and `write(parse(b)) == b` for version 16 files -//! Photoshop wrote; a version 12 file's legacy descriptors keep their meaning, not their layout. +//! `parse(write(f)) == f` for sets [`parse`] read. The existing single-set API retains trailing-byte +//! warnings. [`parse_sets`] and [`write_sets`] preserve complete version-16 multi-set files with +//! one version header; a version-12 file's legacy descriptors keep their meaning, not their layout. use crate::codepage; use crate::descriptor::{Descriptor, UnicodeString}; @@ -40,6 +42,9 @@ use crate::error::{PsdError, Result}; use crate::io::{Reader, WriteExt, decode_legacy_name}; mod legacy; +mod sets; + +pub use sets::{MAX_SETS, MAX_TOTAL_ACTIONS, MAX_TOTAL_ITEMS, parse_sets, parse_sets_with_encoding, parse_sets_with_spans, write_sets}; /// The version written by this module (Photoshop 6 and every later release). pub const VERSION: u32 = 16; @@ -48,6 +53,70 @@ pub const MAX_ACTIONS: usize = 10_000; /// Most steps read from one action. pub const MAX_ITEMS: usize = 100_000; +/// A complete version16 archive and byte provenance into its original input. +/// Ranges address the caller's input, including original names/flags/descriptor encodings. +/// No source bytes are copied or rewritten by this parser. +#[derive(Debug, Clone, PartialEq)] +pub struct SpannedAtnArchive { + /// Parsed sets in physical file order. + pub sets: Vec, + /// Byte spans for the sets, in the same order. + pub spans: Vec, +} + +/// One physical set, including its original header fields and action occurrences. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AtnSetSpan { + /// Half-open input range of the complete set, including its actions. + pub range: std::ops::Range, + /// Set header through the action count, before the first action. + pub header: std::ops::Range, + /// Length-prefixed Unicode set name bytes. + pub name: std::ops::Range, + /// Original expanded-flag byte. + pub expanded: std::ops::Range, + /// Original four-byte action count. + pub count: std::ops::Range, + /// Action spans in physical file order. + pub actions: Vec, +} + +/// One physical action. Header ranges let a library edit one field without normalizing others. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AtnActionSpan { + /// Half-open input range of the complete action, including its steps. + pub range: std::ops::Range, + /// Action header through the step count, before the first step. + pub header: std::ops::Range, + /// Original function-key code bytes. + pub function_key: std::ops::Range, + /// Original Shift-modifier flag byte. + pub shift: std::ops::Range, + /// Original command-modifier flag byte. + pub command: std::ops::Range, + /// Original color-label code bytes. + pub color: std::ops::Range, + /// Length-prefixed Unicode action name bytes. + pub name: std::ops::Range, + /// Original expanded-flag byte. + pub expanded: std::ops::Range, + /// Original four-byte step count. + pub count: std::ops::Range, + /// Step spans in physical file order. + pub items: Vec, +} + +/// One original event, even when it maps to several native command steps. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AtnItemSpan { + /// Half-open input range of the complete original step. + pub range: std::ops::Range, + /// Expanded, enabled, dialog, dialog-options bytes, in that order. + pub flags: std::ops::Range, + /// Descriptor body after its -1 flag; absent for a descriptor-free event. + pub descriptor: Option>, +} + /// A parsed action set. #[derive(Debug, Clone, PartialEq, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -217,7 +286,8 @@ fn write_ascii(out: &mut Vec, s: &[u8]) -> Result<()> { } /// Read one step with the descriptor dialect selected by the file version. -fn read_item(r: &mut Reader<'_>, legacy: bool) -> Result { +fn read_item(r: &mut Reader<'_>, legacy: bool, mut span: Option<&mut AtnItemSpan>) -> Result { + let start = r.pos(); let expanded = r.u8()? != 0; let enabled = r.u8()? != 0; let with_dialog = r.u8()? != 0; @@ -230,29 +300,67 @@ fn read_item(r: &mut Reader<'_>, legacy: bool) -> Result { let dictionary = read_ascii(r)?; let descriptor = match r.i32()? { 0 => None, - -1 => Some(if legacy { legacy::read(r)? } else { Descriptor::read(r)? }), + -1 => { + let start = r.pos(); + let descriptor = if legacy { legacy::read(r)? } else { Descriptor::read(r)? }; + if let Some(span) = span.as_deref_mut() { + span.descriptor = Some(start..r.pos()); + } + Some(descriptor) + } n => return Err(PsdError::invalid(format!("bad descriptor flag {n}"))), }; + if let Some(span) = span { + span.range = start..r.pos(); + span.flags = start..start.saturating_add(4); + } Ok(AtnItem { expanded, enabled, with_dialog, dialog_options, event, dictionary, descriptor }) } /// Read an action and its steps using the file's descriptor dialect. -fn read_action(r: &mut Reader<'_>, legacy: bool) -> Result { +fn read_action(r: &mut Reader<'_>, legacy: bool, budget: Option<&mut sets::Budget>, mut span: Option<&mut AtnActionSpan>) -> Result { + let start = r.pos(); let function_key = r.u16()?; + let shift_start = r.pos(); let shift = r.u8()? != 0; + let command_start = r.pos(); let command = r.u8()? != 0; + let color_start = r.pos(); let color = r.u16()?; + let name_start = r.pos(); let name = UnicodeString::read(r)?; + let expanded_start = r.pos(); let expanded = r.u8()? != 0; + let count_start = r.pos(); let n = r.u32()?; + if let Some(span) = span.as_deref_mut() { + span.header = start..r.pos(); + span.function_key = start..shift_start; + span.shift = shift_start..command_start; + span.command = command_start..color_start; + span.color = color_start..name_start; + span.name = name_start..expanded_start; + span.expanded = expanded_start..count_start; + span.count = count_start..r.pos(); + } // Each item needs at least its 4 flags, the event kind, two empty names and the descriptor flag. r.check_count(u64::from(n), 20)?; if n as usize > MAX_ITEMS { return Err(PsdError::LimitExceeded("too many steps in one action")); } + if let Some(budget) = budget { + budget.items(n as usize)?; + } let mut items = Vec::with_capacity((n as usize).min(1024)); for _ in 0..n { - items.push(read_item(r, legacy)?); + let mut item_span = span.as_ref().map(|_| AtnItemSpan::default()); + items.push(read_item(r, legacy, item_span.as_mut())?); + if let (Some(span), Some(item)) = (span.as_deref_mut(), item_span) { + span.items.push(item); + } + } + if let Some(span) = span { + span.range = start..r.pos(); } Ok(AtnAction { function_key, shift, command, color, name, expanded, items }) } @@ -278,33 +386,74 @@ pub fn parse_with_encoding(data: &[u8], encoding: Option<&str>) -> Result Result { let mut r = Reader::new(data); + let version = read_version(&mut r)?; + let mut file = read_set(&mut r, version == 12, None)?; + if !r.is_empty() { + file.warnings.push(format!("{} trailing bytes ignored", r.remaining())); + } + Ok(file) +} + +fn read_version(r: &mut Reader<'_>) -> Result { let version = r.u32()?; if !matches!(version, 12 | VERSION) { return Err(PsdError::Unsupported(format!("actions file version {version} (supported versions: 12 and 16)"))); } - let name = UnicodeString::read(&mut r)?; + Ok(version) +} + +fn read_set(r: &mut Reader<'_>, legacy: bool, budget: Option<&mut sets::Budget>) -> Result { + read_set_with_spans(r, legacy, budget, None) +} + +fn read_set_with_spans( + r: &mut Reader<'_>, + legacy: bool, + mut budget: Option<&mut sets::Budget>, + mut span: Option<&mut AtnSetSpan>, +) -> Result { + let start = r.pos(); + let name = UnicodeString::read(r)?; + let expanded_start = r.pos(); let expanded = r.u8()? != 0; + let count_start = r.pos(); let n = r.u32()?; + if let Some(span) = span.as_deref_mut() { + span.header = start..r.pos(); + span.name = start..expanded_start; + span.expanded = expanded_start..count_start; + span.count = count_start..r.pos(); + } // Each action needs at least its key, modifiers, colour, an empty name, the flag and a count. r.check_count(u64::from(n), 15)?; if n as usize > MAX_ACTIONS { return Err(PsdError::LimitExceeded("too many actions in one set")); } + if let Some(budget) = budget.as_deref_mut() { + budget.actions(n as usize)?; + } let mut actions = Vec::with_capacity((n as usize).min(256)); for _ in 0..n { - actions.push(read_action(&mut r, version == 12)?); + let mut action_span = span.as_ref().map(|_| AtnActionSpan::default()); + actions.push(read_action(r, legacy, budget.as_deref_mut(), action_span.as_mut())?); + if let (Some(span), Some(action)) = (span.as_deref_mut(), action_span) { + span.actions.push(action); + } } - let mut warnings = Vec::new(); - if !r.is_empty() { - warnings.push(format!("{} trailing bytes ignored", r.remaining())); + if let Some(span) = span { + span.range = start..r.pos(); } - Ok(AtnFile { name, expanded, actions, warnings, encoding: None }) + Ok(AtnFile { name, expanded, actions, warnings: Vec::new(), encoding: None }) } /// The encoding of `f`'s 8-bit names that are not ASCII or UTF-8, or `None` if no name shown /// from them needs one; adds a warning when the guess is uncertain. fn name_encoding(f: &AtnFile, warnings: &mut Vec) -> Option<&'static encoding_rs::Encoding> { - let items = || f.actions.iter().flat_map(|a| &a.items); + name_encoding_sets(std::slice::from_ref(f), warnings) +} + +fn name_encoding_sets(files: &[AtnFile], warnings: &mut Vec) -> Option<&'static encoding_rs::Encoding> { + let items = || files.iter().flat_map(|file| &file.actions).flat_map(|action| &action.items); let legacy = |b: &[u8]| !b.is_ascii(); // Steps that store their name as Unicode too: the encoding that turns one into the other. let pairs: Vec<(&[u8], String)> = items() @@ -333,13 +482,26 @@ fn name_encoding(f: &AtnFile, warnings: &mut Vec) -> Option<&'static enc return None; } // The set, action and class names are UTF-16: their script points at the writer's locale. - let mut hint = f.name.to_string_lossy(); - for name in f.actions.iter().map(|a| a.name.to_string_lossy()).chain(items().filter_map(class_name)) { - if hint.len() > codepage::GUESS_SAMPLE { - break; + let mut hint = String::new(); + for (index, file) in files.iter().enumerate() { + if index == 0 { + hint = file.name.to_string_lossy(); + } else if hint.len() <= codepage::GUESS_SAMPLE { + hint.push('\n'); + hint.push_str(&file.name.to_string_lossy()); + } + for name in file + .actions + .iter() + .map(|action| action.name.to_string_lossy()) + .chain(file.actions.iter().flat_map(|action| &action.items).filter_map(class_name)) + { + if hint.len() > codepage::GUESS_SAMPLE { + break; + } + hint.push('\n'); + hint.push_str(&name); } - hint.push('\n'); - hint.push_str(&name); } let guess = codepage::guess(&orphans, &hint); if guess.uncertain { @@ -385,7 +547,12 @@ fn count(n: usize, what: &'static str) -> Result { pub fn write(f: &AtnFile) -> Result> { let mut out = Vec::new(); out.put_u32(VERSION); - f.name.write(&mut out); + write_set(&mut out, f)?; + Ok(out) +} + +fn write_set(out: &mut Vec, f: &AtnFile) -> Result<()> { + f.name.write(out); out.put_u8(u8::from(f.expanded)); out.put_u32(count(f.actions.len(), "too many actions")?); for a in &f.actions { @@ -393,14 +560,14 @@ pub fn write(f: &AtnFile) -> Result> { out.put_u8(u8::from(a.shift)); out.put_u8(u8::from(a.command)); out.put_u16(a.color); - a.name.write(&mut out); + a.name.write(out); out.put_u8(u8::from(a.expanded)); out.put_u32(count(a.items.len(), "too many steps")?); for item in &a.items { - write_item(&mut out, item)?; + write_item(out, item)?; } } - Ok(out) + Ok(()) } #[cfg(test)] @@ -863,4 +1030,86 @@ mod tests { let _ = parse(&bytes); } } + + /// Independent wire input: duplicate raw UTF16 names, noncanonical true bytes, and a + /// descriptor boolean whose semantic value cannot preserve its original byte by itself. + fn spanned_wire_archive() -> Vec { + fn ascii(out: &mut Vec, bytes: &[u8]) { + out.extend_from_slice(&(bytes.len() as u32).to_be_bytes()); + out.extend_from_slice(bytes); + } + let mut out = 16u32.to_be_bytes().to_vec(); + let raw_name = [0, 0, 0, 2, 0xd8, 0, 0, 0]; // unpaired high surrogate, then NUL + for _ in 0..2 { + out.extend_from_slice(&raw_name); + out.push(2); // expanded=true, retaining its original byte matters + out.extend_from_slice(&1u32.to_be_bytes()); + out.extend_from_slice(&[0, 3, 2, 0, 0, 12]); // F3, Shift byte2, no Command, colour12 + out.extend_from_slice(&raw_name); + out.push(2); + out.extend_from_slice(&2u32.to_be_bytes()); + out.extend_from_slice(&[2, 2, 0, 3]); + out.extend_from_slice(b"longFltn"); + ascii(&mut out, b"Flatten"); + out.extend_from_slice(&0i32.to_be_bytes()); + out.extend_from_slice(&[0, 1, 2, 2]); + out.extend_from_slice(b"TEXT"); + ascii(&mut out, b"futureAction"); + ascii(&mut out, b"Future"); + out.extend_from_slice(&(-1i32).to_be_bytes()); + out.extend_from_slice(&0u32.to_be_bytes()); // empty descriptor Unicode name + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(b"null"); + out.extend_from_slice(&1u32.to_be_bytes()); + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(b"flagbool"); + out.push(2); + } + out + } + + #[test] + fn archive_spans_retain_physical_occurrences_and_original_wire_fields() { + let bytes = spanned_wire_archive(); + let archive = parse_sets_with_spans(&bytes).unwrap(); + assert_eq!(archive.sets.len(), 2); + assert_eq!(archive.spans.len(), 2); + assert_eq!(archive.spans[0].range.start, 4); + assert_eq!(archive.spans[0].range.end, archive.spans[1].range.start); + assert_eq!(archive.spans[1].range.end, bytes.len()); + let mut untouched = bytes[..4].to_vec(); + for (file, set) in archive.sets.iter().zip(&archive.spans) { + assert_eq!(file.name.0, [0xd800, 0]); + assert_eq!(bytes[set.expanded.clone()], [2]); + assert_eq!(bytes[set.count.clone()], 1u32.to_be_bytes()); + assert_eq!(set.header.end, set.actions[0].range.start); + let action = &set.actions[0]; + assert_eq!(bytes[action.function_key.clone()], [0, 3]); + assert_eq!(bytes[action.shift.clone()], [2]); + assert_eq!(bytes[action.command.clone()], [0]); + assert_eq!(bytes[action.color.clone()], [0, 12]); + assert_eq!(bytes[action.expanded.clone()], [2]); + assert_eq!(bytes[action.count.clone()], 2u32.to_be_bytes()); + assert_eq!(action.header.end, action.items[0].range.start); + assert_eq!(bytes[action.items[0].flags.clone()], [2, 2, 0, 3]); + assert_eq!(bytes[action.items[1].flags.clone()], [0, 1, 2, 2]); + assert_eq!(action.items[0].range.end, action.items[1].range.start); + assert_eq!(action.items[1].range.end, action.range.end); + assert_eq!(action.range.end, set.range.end); + assert_eq!(bytes[action.items[1].range.clone()].last(), Some(&2)); + untouched.extend_from_slice(&bytes[set.range.clone()]); + } + assert_eq!(untouched, bytes, "no semantic writer generates the expected raw bytes"); + assert_eq!(parse_sets(&bytes).unwrap(), archive.sets, "ordinary parsing keeps its semantic API"); + assert_ne!(write_sets(&archive.sets).unwrap(), bytes, "typed boolean rewriting is not a raw library round trip"); + } + + #[test] + fn archive_spans_reject_partial_later_records_and_legacy_layout() { + let mut bytes = spanned_wire_archive(); + bytes.pop(); // the second set's descriptor value is incomplete + assert!(parse_sets_with_spans(&bytes).is_err(), "no partial archive is admitted"); + assert!(parse_sets_with_spans(&16u32.to_be_bytes()).is_err(), "a version prefix is not an invented empty ATN"); + assert!(matches!(parse_sets_with_spans(&12u32.to_be_bytes()), Err(PsdError::Unsupported(_)))); + } } diff --git a/crates/psd/src/atn/sets.rs b/crates/psd/src/atn/sets.rs new file mode 100644 index 000000000..974b6b245 --- /dev/null +++ b/crates/psd/src/atn/sets.rs @@ -0,0 +1,135 @@ +//! Whole-file version-16 set streams. The original single-set/legacy API remains compatible. +use super::{ + AtnFile, AtnSetSpan, MAX_ACTIONS, MAX_ITEMS, SpannedAtnArchive, VERSION, codepage, name_encoding_sets, read_set_with_spans, read_version, + write_set, +}; +use crate::{ + error::{PsdError, Result}, + io::{Reader, WriteExt}, +}; + +/// Whole-archive resource ceilings, not Photoshop format maxima. Per-set/action ceilings still apply. +pub const MAX_SETS: usize = MAX_ACTIONS; +/// Maximum action headers across the complete archive, including any metadata sidecar. +pub const MAX_TOTAL_ACTIONS: usize = MAX_ACTIONS; +/// Maximum steps across the complete archive, including any metadata sidecar. +pub const MAX_TOTAL_ITEMS: usize = MAX_ITEMS; + +#[derive(Default)] +pub(super) struct Budget { + actions: usize, + items: usize, +} + +impl Budget { + pub(super) fn actions(&mut self, count: usize) -> Result<()> { + if count > MAX_ACTIONS { + return Err(PsdError::LimitExceeded("too many actions in one set")); + } + self.actions = self + .actions + .checked_add(count) + .filter(|sum| *sum <= MAX_TOTAL_ACTIONS) + .ok_or(PsdError::LimitExceeded("too many actions in actions archive"))?; + Ok(()) + } + + pub(super) fn items(&mut self, count: usize) -> Result<()> { + if count > MAX_ITEMS { + return Err(PsdError::LimitExceeded("too many steps in one action")); + } + self.items = self + .items + .checked_add(count) + .filter(|sum| *sum <= MAX_TOTAL_ITEMS) + .ok_or(PsdError::LimitExceeded("too many steps in actions archive"))?; + Ok(()) + } +} + +/// Read every set in a version-16 file, in order, with one version prefix and strict EOF. +/// A malformed later set fails the whole result. Version12 retains the existing one-set/tail contract. +pub fn parse_sets(data: &[u8]) -> Result> { + parse_sets_with_encoding(data, None) +} + +/// [`parse_sets`] with one legacy name encoding for the complete file. Automatic detection also +/// uses the complete stream; its diagnostic is attached once to the first set. +pub fn parse_sets_with_encoding(data: &[u8], encoding: Option<&str>) -> Result> { + parse_archive(data, encoding, false).map(|archive| archive.sets) +} + +/// Read a strict complete version16 archive, retaining physical record/field ranges. +/// Legacy version12 conversion is deliberately excluded: its descriptor dialect cannot be +/// spliced unchanged into a version16 library. Existing import APIs still accept it. +/// A header without any real set remains invalid; an empty logical library needs a valid carrier. +pub fn parse_sets_with_spans(data: &[u8]) -> Result { + parse_archive(data, None, true) +} + +fn parse_archive(data: &[u8], encoding: Option<&str>, capture: bool) -> Result { + let forced = encoding.map(codepage::resolve_label).transpose()?; + let mut reader = Reader::new(data); + let version = read_version(&mut reader)?; + let legacy = version == 12; + if capture && legacy { + return Err(PsdError::Unsupported("raw Actions library provenance requires version16".into())); + } + let mut files = Vec::new(); + let mut spans = Vec::new(); + let mut budget = Budget::default(); + loop { + if files.len() >= MAX_SETS { + return Err(PsdError::LimitExceeded("too many sets in actions archive")); + } + let before = reader.remaining(); + let mut span = capture.then(AtnSetSpan::default); + let mut file = read_set_with_spans(&mut reader, legacy, Some(&mut budget), span.as_mut())?; + if let Some(span) = span { + spans.push(span); + } + if legacy && !reader.is_empty() { + file.warnings.push(format!("{} trailing bytes ignored", reader.remaining())); + } + if reader.remaining() >= before { + return Err(PsdError::invalid("actions set made no progress")); + } + files.push(file); + if legacy || reader.is_empty() { + break; + } + } + let mut warnings = Vec::new(); + let encoding = forced.or_else(|| name_encoding_sets(&files, &mut warnings)).map(|encoding| encoding.name().to_string()); + for file in &mut files { + file.encoding = encoding.clone(); + } + if let Some(first) = files.first_mut() { + first.warnings.extend(warnings); + } + Ok(SpannedAtnArchive { sets: files, spans }) +} + +/// Write all sets under one version16 prefix. Validate aggregate counts before allocating output. +/// At least one set is required; an empty set is valid. Stored names/descriptors stay verbatim. +pub fn write_sets(files: &[AtnFile]) -> Result> { + if files.is_empty() { + return Err(PsdError::invalid("actions archive must contain at least one set")); + } + if files.len() > MAX_SETS { + return Err(PsdError::LimitExceeded("too many sets in actions archive")); + } + let mut budget = Budget::default(); + for file in files { + budget.actions(file.actions.len())?; + for action in &file.actions { + budget.items(action.items.len())?; + } + } + let mut out = Vec::new(); + out.put_u32(VERSION); + for file in files { + write_set(&mut out, file)?; + } + Ok(out) +} diff --git a/crates/psd/src/compression.rs b/crates/psd/src/compression.rs index 7d05f5471..d684525a8 100644 --- a/crates/psd/src/compression.rs +++ b/crates/psd/src/compression.rs @@ -68,34 +68,90 @@ pub mod packbits { /// Encodes one row, appending to `out`. Never emits the `-128` no-op. pub fn encode(src: &[u8], out: &mut Vec) { + // Constant 16-bit samples with different bytes contain only literal packets. + if src.len() >= 128 + && let Some(&[a, b]) = src.first_chunk::<2>() + && a != b + { + let (blocks, tail) = src.as_chunks::<8>(); + if blocks.iter().all(|v| *v == [a, b, a, b, a, b, a, b]) && tail.iter().zip([a, b].into_iter().cycle()).all(|(&v, p)| v == p) { + for chunk in src.chunks(128) { + out.push((chunk.len() - 1) as u8); + out.extend_from_slice(chunk); + } + return; + } + } + packets(src, |header, bytes| { + out.push(header); + out.extend_from_slice(bytes); + }); + } + + /// The encoded row length, without allocating or writing its payload. + pub fn encoded_len(src: &[u8]) -> usize { + let mut len = 0; + packets(src, |_, bytes| len += 1 + bytes.len()); + len + } + + /// Visit PackBits run and literal packets in their deterministic encoding order. + fn packets(src: &[u8], mut emit: impl FnMut(u8, &[u8])) { let n = src.len(); let mut i = 0; while i < n { // Length of the run starting at i. let mut run = 1; - while i + run < n && run < 128 && src[i + run] == src[i] { + while i + run < n && run < 8 && src[i + run] == src[i] { run += 1; } + if run == 8 { + while run + 8 <= 128 && src.get(i + run..).and_then(|s| s.first_chunk::<8>()) == Some(&[src[i]; 8]) { + run += 8; + } + while i + run < n && run < 128 && src[i + run] == src[i] { + run += 1; + } + } // A literal never stops before a 2-byte run, so any run of >= 2 // seen here starts a packet boundary: a run packet is never larger. if run >= 2 { - out.push((1i16 - run as i16) as i8 as u8); - out.push(src[i]); + emit((1i16 - run as i16) as i8 as u8, &src[i..i + 1]); i += run; continue; } // Literal: extend until a run of >= 3 begins or 128 bytes. let start = i; let mut len = 0; - while i < n && len < 128 { + // Short packets keep the scalar scan; lookahead pays only on longer literals. + while i < n && len < 16 { if i + 2 < n && src[i] == src[i + 1] && src[i] == src[i + 2] { break; } i += 1; len += 1; } - out.push((len - 1) as u8); - out.extend_from_slice(&src[start..start + len]); + if len == 16 { + while len + 8 <= 128 { + let Some(&[a, b, c, d, e, f, g, h, j, k]) = src.get(i..).and_then(|s| s.first_chunk::<10>()) else { break }; + let word = u64::from_le_bytes([a, b, c, d, e, f, g, h]); + let different = (word ^ u64::from_le_bytes([b, c, d, e, f, g, h, j])) | (word ^ u64::from_le_bytes([c, d, e, f, g, h, j, k])); + // A zero byte marks a triple; scalar scanning keeps the first boundary. + if different.wrapping_sub(0x0101010101010101) & !different & 0x8080808080808080 != 0 { + break; + } + i += 8; + len += 8; + } + while i < n && len < 128 { + if i + 2 < n && src[i] == src[i + 1] && src[i] == src[i + 2] { + break; + } + i += 1; + len += 1; + } + } + emit((len - 1) as u8, &src[start..start + len]); } } @@ -369,82 +425,178 @@ fn decode_rle(data: &[u8], layout: &PlaneLayout, total: usize) -> Result Ok(out) } +/// Encode independent row chunks and join their count tables and payloads in order. fn encode_rle(decoded: &[u8], layout: &PlaneLayout) -> Result> { let rows = layout.rows()?; - let rb = layout.row_bytes(); - let cs = layout.count_size(); if rows == 0 { return Ok(Vec::new()); } - // Rows are independent, so they pack in parallel chunks of rows. Two passes: the first only - // measures each packed row, so the output is allocated once at its exact size and the second - // packs every row straight into its place. Collecting packed chunks and then copying them into - // the output held the encoded image twice, with growth slack, at the save's peak (photocraft#6607). + let rb = layout.row_bytes(); + let cs = layout.count_size(); let threads = if cfg!(target_arch = "wasm32") { 1 } else { std::thread::available_parallelism().map_or(1, |v| v.get()).clamp(1, 32) }; - let chunk_rows = rows.div_ceil(threads).max(1); - let row = |r: usize| &decoded[r * rb..(r + 1) * rb]; - let mut lens = vec![0usize; rows]; - for_each_chunk(threads, lens.chunks_mut(chunk_rows).enumerate().collect(), |(c, lens): (usize, &mut [usize])| { - let mut scratch = Vec::with_capacity(rb + rb.div_ceil(128)); - for (i, len) in lens.iter_mut().enumerate() { - scratch.clear(); - packbits::encode(row(c * chunk_rows + i), &mut scratch); - *len = scratch.len(); - } - })?; - let mut out = vec![0u8; rows * cs + lens.iter().sum::()]; - let (counts, mut packed) = out.split_at_mut(rows * cs); - for (count, &n) in counts.chunks_exact_mut(cs).zip(&lens) { - if cs == 2 { - let n = u16::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row exceeds 65535 bytes (use PSB)"))?; - count.copy_from_slice(&n.to_be_bytes()); - } else { - let n = u32::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row too large"))?; - count.copy_from_slice(&n.to_be_bytes()); - } + let step = rows.div_ceil(threads).max(1); + let ranges: Vec<_> = (0..rows).step_by(step).map(|start| start..start.saturating_add(step).min(rows)).collect(); + // Scan packets once. Each worker owns its count table and payload; joining copies each + // payload once, trading temporary encoded bytes for the former second packet scan. + let mut chunks: Vec<_> = map_chunks(threads, ranges.clone(), |range| -> Result> { + let table = range.len().checked_mul(cs).ok_or(PsdError::LimitExceeded("RLE table overflow"))?; + let hint = + if cfg!(target_arch = "wasm32") { table } else { table.saturating_add(range.len().saturating_mul(rb.saturating_add(rb.div_ceil(128)))) }; + encode_rle_chunk(decoded, layout, range, hint) + })? + .into_iter() + .collect::>()?; + if chunks.len() == 1 { + return Ok(chunks.remove(0).into_boxed_slice().into_vec()); } - let mut parts = Vec::with_capacity(threads); - for (c, lens) in lens.chunks(chunk_rows).enumerate() { - let (part, rest) = packed.split_at_mut(lens.iter().sum()); - parts.push((c, lens, part)); - packed = rest; + let total = chunks.iter().try_fold(0usize, |n, c| n.checked_add(c.len()).ok_or(PsdError::LimitExceeded("RLE size overflow")))?; + let table = rows.checked_mul(cs).ok_or(PsdError::LimitExceeded("RLE table overflow"))?; + let mut out = Vec::new(); + out.try_reserve_exact(total).map_err(|_| PsdError::OutOfMemory("not enough memory for encoded RLE"))?; + out.resize(table, 0); + for (range, chunk) in ranges.into_iter().zip(chunks) { + let counts = range.len() * cs; + out[range.start * cs..range.end * cs].copy_from_slice(&chunk[..counts]); + out.extend_from_slice(&chunk[counts..]); } - for_each_chunk(threads, parts, |(c, lens, mut part): (usize, &[usize], &mut [u8])| { - let mut scratch = Vec::with_capacity(rb + rb.div_ceil(128)); - for (i, &n) in lens.iter().enumerate() { - scratch.clear(); - packbits::encode(row(c * chunk_rows + i), &mut scratch); - let (dst, rest) = std::mem::take(&mut part).split_at_mut(n); - dst.copy_from_slice(&scratch); - part = rest; - } - })?; Ok(out) } -/// Runs `f` on every item, each on its own thread unless `threads` is 1 (wasm32). -fn for_each_chunk(threads: usize, items: Vec, f: impl Fn(T) + Sync) -> Result<()> { - if threads == 1 { - items.into_iter().for_each(f); - return Ok(()); - } - std::thread::scope(|scope| { - let handles: Vec<_> = items.into_iter().map(|item| scope.spawn(|| f(item))).collect(); - // Join every worker instead of aborting the scope with a thread panic. - let mut ok = true; - for h in handles { - ok &= h.join().is_ok(); +/// Encode one worker's rows, measuring packets only when the spare capacity cannot bound them. +fn encode_rle_chunk(decoded: &[u8], layout: &PlaneLayout, range: std::ops::Range, hint: usize) -> Result> { + const OOM: PsdError = PsdError::OutOfMemory("not enough memory for encoded RLE"); + let rb = layout.row_bytes(); + let cs = layout.count_size(); + let table = range.len().checked_mul(cs).ok_or(PsdError::LimitExceeded("RLE table overflow"))?; + let mut out = Vec::new(); + out.try_reserve_exact(hint.max(table)).or_else(|_| out.try_reserve_exact(table)).map_err(|_| OOM)?; + out.resize(table, 0); + let row_bound = rb.saturating_add(rb.div_ceil(128)); + for (i, row) in range.enumerate() { + let src = &decoded[row * rb..(row + 1) * rb]; + if out.capacity() - out.len() < row_bound { + let needed = packbits::encoded_len(src); + out.try_reserve(needed).or_else(|_| out.try_reserve_exact(needed)).map_err(|_| OOM)?; } - if ok { Ok(()) } else { Err(PsdError::LimitExceeded("an RLE worker panicked")) } - }) + let start = out.len(); + packbits::encode(src, &mut out); + let n = out.len() - start; + let count = &mut out[i * cs..(i + 1) * cs]; + if cs == 2 { + count.copy_from_slice(&u16::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row exceeds 65535 bytes (use PSB)"))?.to_be_bytes()); + } else { + count.copy_from_slice(&u32::try_from(n).map_err(|_| PsdError::LimitExceeded("RLE row too large"))?.to_be_bytes()); + } + } + Ok(out) +} + +/// Ordered work on the caller's pool, including nested channel and row encoding. +fn map_chunks(threads: usize, items: Vec, f: impl Fn(T) -> R + Sync) -> Result> { + if threads == 1 { + return Ok(items.into_iter().map(f).collect()); + } + #[cfg(not(target_arch = "wasm32"))] + { + use rayon::prelude::*; + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| items.into_par_iter().map(&f).collect())) + .map_err(|_| PsdError::LimitExceeded("an RLE worker panicked")) + } + #[cfg(target_arch = "wasm32")] + Ok(items.into_iter().map(f).collect()) } /// zlib-compresses `data` at the default level. pub fn zip_compress(data: &[u8]) -> Vec { + #[cfg(not(target_arch = "wasm32"))] + let mut e = zip_encoder(); + #[cfg(target_arch = "wasm32")] let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); - // Writing into a Vec cannot fail. + // The in-memory output always accepts the complete write. let _ = e.write_all(data); - e.finish().unwrap_or_default() + #[cfg(not(target_arch = "wasm32"))] + { + if e.try_finish().is_err() { + return Vec::new(); + } + let output = std::mem::take(e.get_mut()); + // Return only the finished codec; the slot stays empty during compression. + let _ = ZIP_ENCODER.try_with(|slot| slot.set(Some(e))); + output.into_vec() + } + #[cfg(target_arch = "wasm32")] + { + e.finish().unwrap_or_default() + } +} + +#[cfg(not(target_arch = "wasm32"))] +thread_local! { + /// One finished codec per thread; encoded output moves out before it is retained. + static ZIP_ENCODER: std::cell::Cell>> = const { std::cell::Cell::new(None) }; +} + +/// Reset a thread's finished encoder without reallocating its fixed-size working buffers. +/// Nested calls and thread teardown can use a fresh encoder without accessing a busy slot. +#[cfg(not(target_arch = "wasm32"))] +fn zip_encoder() -> flate2::write::ZlibEncoder { + if let Some(mut encoder) = ZIP_ENCODER.try_with(std::cell::Cell::take).ok().flatten() + && encoder.reset(ZipOutput::default()).is_ok() + { + return encoder; + } + flate2::write::ZlibEncoder::new(ZipOutput::default(), flate2::Compression::default()) +} + +/// Bounded native output blocks avoid reallocating large ZIP buffers as they grow. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Default)] +struct ZipOutput { + blocks: Vec>, + current: Vec, +} + +#[cfg(not(target_arch = "wasm32"))] +impl std::io::Write for ZipOutput { + /// Accept all input bytes into bounded output blocks without reserving raw image size. + fn write(&mut self, mut bytes: &[u8]) -> std::io::Result { + let len = bytes.len(); + while !bytes.is_empty() { + if self.current.len() == self.current.capacity() { + if !self.current.is_empty() { + self.blocks.push(std::mem::take(&mut self.current)); + } + self.current = Vec::with_capacity(if self.blocks.is_empty() { 8192 } else { 256 << 10 }); + } + let part = &mut self.current; + let (head, rest) = bytes.split_at((part.capacity() - part.len()).min(bytes.len())); + part.extend_from_slice(head); + bytes = rest; + } + Ok(len) + } + + /// Complete a no-op flush because every write is already retained in memory. + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl ZipOutput { + /// Join encoded ZIP blocks into one vector sized to the compressed payload. + fn into_vec(mut self) -> Vec { + self.blocks.push(self.current); + if self.blocks.len() == 1 { + return self.blocks.pop().unwrap_or_default().into_boxed_slice().into_vec(); + } + let len = self.blocks.iter().fold(0usize, |n, c| n.saturating_add(c.len())); + let mut out = Vec::with_capacity(len); + for part in self.blocks { + out.extend_from_slice(&part); + } + out + } } /// Decompresses a zlib stream, requiring at least `expected` output bytes and @@ -489,6 +641,20 @@ pub fn predict(buf: &mut [u8], layout: &PlaneLayout) -> Result<()> { if rb == 0 { return Ok(()); } + #[cfg(not(target_arch = "wasm32"))] + if buf.len() >= 1 << 20 && matches!(layout.depth, 1 | 8 | 16 | 32) { + use rayon::prelude::*; + let threads = rayon::current_num_threads().min(32); + let rows = buf.len() / rb; + let step = rows.div_ceil(threads).max(1) * rb; + return buf[..rows * rb].par_chunks_mut(step).try_for_each(|part| predict_rows(part, layout)); + } + predict_rows(buf, layout) +} + +/// Apply per-row prediction, reusing scratch for float byte-plane shuffling. +fn predict_rows(buf: &mut [u8], layout: &PlaneLayout) -> Result<()> { + let rb = layout.row_bytes(); let w = layout.width; let mut scratch = Vec::new(); for row in buf.chunks_exact_mut(rb) { @@ -509,7 +675,6 @@ pub fn predict(buf: &mut [u8], layout: &PlaneLayout) -> Result<()> { } } 32 => { - scratch.clear(); scratch.resize(rb, 0); // Split into four byte planes (MSB first) and delta the whole // plane-major row. Plane k's first delta uses the last byte of diff --git a/crates/psd/src/compression/tests.rs b/crates/psd/src/compression/tests.rs index 9529af65b..9f8c61664 100644 --- a/crates/psd/src/compression/tests.rs +++ b/crates/psd/src/compression/tests.rs @@ -137,13 +137,84 @@ fn layout(planes: usize, width: usize, height: usize, depth: u16, version: Versi PlaneLayout { planes, width, height, depth, version } } +/// Verify PackBits round trips empty, literal and repeated-byte inputs. fn pb_roundtrip(src: &[u8]) -> Vec { let enc = packbits::encode_vec(src); + assert_eq!(packbits::encoded_len(src), enc.len()); let dec = packbits::decode(&enc, src.len()).unwrap(); assert_eq!(dec, src, "encoded: {enc:?}"); enc } +/// Compare block scanning with scalar packet boundaries over varied runs and literals. +#[test] +fn packbits_block_scan_preserves_scalar_packets() { + /// Encode PackBits with the previous scalar packet scan as a byte oracle. + fn scalar(src: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut i = 0; + while i < src.len() { + let mut run = 1; + while i + run < src.len() && run < 128 && src[i + run] == src[i] { + run += 1; + } + if run >= 2 { + out.extend_from_slice(&[(1i16 - run as i16) as u8, src[i]]); + i += run; + } else { + let start = i; + while i < src.len() && i - start < 128 { + if i + 2 < src.len() && src[i] == src[i + 1] && src[i] == src[i + 2] { + break; + } + i += 1; + } + out.push((i - start - 1) as u8); + out.extend_from_slice(&src[start..i]); + } + } + out + } + let check = |src: &[u8]| { + let expected = scalar(src); + let mut actual = vec![42]; + packbits::encode(src, &mut actual); + assert_eq!(&actual[1..], expected, "source {src:?}"); + assert_eq!(packbits::encoded_len(src), expected.len()); + assert!(expected.len() <= src.len() + src.len().div_ceil(128)); + }; + for n in 0..=10 { + for mut bits in 0..3usize.pow(n) { + let src: Vec<_> = (0..n) + .map(|_| { + let b = [0, 1, 255][bits % 3]; + bits /= 3; + b + }) + .collect(); + check(&src); + } + } + for start in 0..260 { + for run in [2, 3, 7, 8, 9, 127, 128, 129, 256] { + let mut src: Vec<_> = (0..520).map(|i| (i % 251) as u8).collect(); + src[start..start + run].fill(255); + check(&src); + } + } + for pair in [[0, 1], [0, 255], [128, 127], [255, 255]] { + for n in [127, 128, 129, 255, 256, 257, 12000, 12001] { + let mut src: Vec<_> = pair.into_iter().cycle().take(n).collect(); + check(&src); + for i in [0, 7, 8, 127.min(n - 1), 128.min(n - 1), n - 1] { + src[i] ^= 1; + check(&src); + src[i] ^= 1; + } + } + } +} + #[test] fn zip_absurd_declared_size_is_an_error() { let z = zip_compress(&[7u8; 64]); @@ -452,6 +523,24 @@ fn prediction_32bit_floats_roundtrip() { assert_eq!(decode_planes(Compression::ZipPrediction, &enc, &l).unwrap(), v); } +/// Compare fused float prediction with explicit byte-plane shuffling and delta coding. +#[test] +fn fused_prediction_matches_shuffled_byte_deltas() { + for w in [1, 2, 3, 31, 256, 257] { + let l = layout(2, w, 3, 32, Version::Psb); + let mut actual: Vec<_> = (0..l.decoded_len().unwrap()).map(|i| (i * 31 + i / 7) as u8).collect(); + let expected: Vec<_> = actual + .chunks_exact(w * 4) + .flat_map(|row| { + let shuffled: Vec<_> = (0..4).flat_map(|k| row.iter().skip(k).step_by(4).copied()).collect(); + (0..shuffled.len()).map(move |i| shuffled[i].wrapping_sub(if i == 0 { 0 } else { shuffled[i - 1] })) + }) + .collect(); + predict(&mut actual, &l).unwrap(); + assert_eq!(actual, expected, "width {w}"); + } +} + #[test] fn zip_bomb_is_bounded() { let big = zip_compress(&vec![0u8; 1 << 20]); @@ -463,3 +552,206 @@ fn zip_bomb_is_bounded() { fn zip_garbage_errors() { assert!(zip_decompress(&[1, 2, 3, 4], 4).is_err()); } + +/// Compare bounded ZIP output with the original growing writer over varied inputs. +#[test] +fn zip_output_matches_the_growing_writer() { + let mut seed = 0x12345678u32; + let noise = (0..1_048_577) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + seed as u8 + }) + .collect(); + for bytes in [Vec::new(), vec![0; 131072], (0..131072).map(|i| (i * 31 + i / 173) as u8).collect(), noise].iter().cycle().take(12) { + let mut old = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + old.write_all(bytes).unwrap(); + let packed = zip_compress(bytes); + assert_eq!(packed, old.finish().unwrap()); + #[cfg(not(target_arch = "wasm32"))] + assert_eq!(packed.capacity(), packed.len()); + } +} + +/// Workers retain only private codec state; a checked-out slot supports nested compression. +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn zip_encoder_reuse_is_private_and_retains_no_output() { + std::thread::scope(|scope| { + let workers: Vec<_> = (0..2) + .map(|byte| { + scope.spawn(move || { + let mut seed = 0x12345678u32; + for len in [1_048_577usize, 32769, 0, 259, 258, 257, 1, 32767, 32768, 2, 262145] { + let bytes: Vec<_> = (0..len) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + if byte == 0 { 0 } else { seed as u8 } + }) + .collect(); + let packed = zip_compress(&bytes); + if byte == 1 && len == 1_048_577 { + assert!(packed.len() > 256 << 10, "the retention oracle must exercise multiple output blocks"); + } + let encoder = ZIP_ENCODER.with(std::cell::Cell::take).unwrap(); + assert_eq!(encoder.total_in(), len as u64); + assert_eq!(encoder.total_out(), packed.len() as u64); + assert_eq!(encoder.get_ref().blocks.capacity(), 0); + assert_eq!(encoder.get_ref().current.capacity(), 0); + assert_eq!(zip_decompress(&zip_compress(b"nested"), 6).unwrap(), b"nested"); + ZIP_ENCODER.with(|slot| slot.set(Some(encoder))); + let mut fresh = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + fresh.write_all(&bytes).unwrap(); + assert_eq!(zip_compress(&bytes), fresh.finish().unwrap()); + } + }) + }) + .collect(); + for worker in workers { + worker.join().unwrap(); + } + }); +} + +/// Compression stays byte-identical during teardown, including an unavailable native cache. +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn zip_compression_survives_thread_local_teardown() { + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; + struct OnDrop(Arc<(AtomicBool, AtomicBool)>); + impl Drop for OnDrop { + /// Record whether teardown reached the destroyed cache and preserved fresh-stream bytes. + fn drop(&mut self) { + let unavailable = ZIP_ENCODER.try_with(|_| ()).is_err(); + let mut fresh = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + fresh.write_all(b"teardown").unwrap(); + let same = zip_compress(b"teardown") == fresh.finish().unwrap(); + self.0.0.store(same, Ordering::SeqCst); + self.0.1.store(unavailable, Ordering::SeqCst); + } + } + thread_local! { static AFTER_CODEC: std::cell::Cell> = const { std::cell::Cell::new(None) }; } + let checked = Arc::new((AtomicBool::new(false), AtomicBool::new(false))); + let flag = checked.clone(); + std::thread::spawn(move || { + AFTER_CODEC.with(|slot| slot.set(Some(OnDrop(flag)))); + zip_compress(b"first"); + }) + .join() + .unwrap(); + assert!(checked.0.load(Ordering::SeqCst), "teardown must preserve the fresh encoder's bytes"); + // OS-key TLS may destroy keys in another order; native TLS runs these destructors in reverse. + #[cfg(any(target_vendor = "apple", target_os = "linux", target_env = "msvc"))] + assert!(checked.1.load(Ordering::SeqCst), "teardown must exercise the uncached fallback"); +} + +/// Verify multiple writes retain their bytes and respect ZIP output block bounds. +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn zip_output_blocks_stay_bounded_across_writes() { + let bytes = vec![42; (1 << 20) + 1]; + let mut output = ZipOutput::default(); + output.write_all(&[]).unwrap(); + for part in bytes.chunks(32767) { + output.write_all(part).unwrap(); + } + assert!(output.blocks.iter().chain([&output.current]).all(|b| b.capacity() <= 256 << 10)); + let packed = output.into_vec(); + assert_eq!(packed, bytes); + assert_eq!(packed.capacity(), packed.len()); +} + +/// Verify compressible ZIP output retains capacity proportional to encoded size. +#[test] +fn compressible_zip_does_not_retain_a_raw_sized_allocation() { + let raw = vec![0; 1 << 20]; + let packed = zip_compress(&raw); + assert!(packed.len() < 1 << 14); + assert!(packed.capacity() < 1 << 16); + assert_eq!(zip_decompress(&packed, raw.len()).unwrap(), raw); +} + +/// Verify failed RLE allocation hints fall back to incremental growth with identical bytes. +#[test] +fn rle_failed_hint_and_incremental_growth_preserve_bytes() { + for version in [Version::Psd, Version::Psb] { + for width in [0, 1, 127, 128, 129, 1000] { + let l = layout(1, width, 9, 8, version); + for fill in [false, true] { + let bytes: Vec<_> = (0..l.decoded_len().unwrap()).map(|i| if fill { 0 } else { (i * 13 + i / 17) as u8 }).collect(); + let expected = encode_planes(Compression::Rle, &bytes, &l).unwrap(); + for hint in [usize::MAX, 0, l.rows().unwrap() * l.count_size()] { + assert_eq!(encode_rle_chunk(&bytes, &l, 0..l.rows().unwrap(), hint).unwrap(), expected); + } + } + } + } +} + +/// Verify measured packet lengths match encoding over mixed runs and literals. +#[test] +fn counted_packbits_matches_mixed_runs_and_literals() { + for n in 0..520 { + let bytes: Vec<_> = (0..n).map(|i| if i % 23 < 9 { (i / 23) as u8 } else { (i * 31) as u8 }).collect(); + assert_eq!(packbits::encoded_len(&bytes), packbits::encode_vec(&bytes).len()); + } +} + +/// Compare parallel prediction with independent serial rows at each supported depth. +#[test] +fn prediction_matches_independent_serial_rows() { + for depth in [1, 8, 16, 32] { + let layout = layout(4, if depth == 1 { 8193 } else { 257 }, 1031, depth, Version::Psb); + let rb = layout.row_bytes(); + let bytes: Vec<_> = (0..layout.decoded_len().unwrap() + 3).map(|i| (i * 13 + i / 17) as u8).collect(); + let mut expected = bytes.clone(); + for row in expected.chunks_exact_mut(rb) { + let src = row.to_vec(); + if depth == 16 { + for (i, p) in row.as_chunks_mut::<2>().0.iter_mut().enumerate().skip(1) { + let a = u16::from_be_bytes([src[i * 2], src[i * 2 + 1]]); + let b = u16::from_be_bytes([src[i * 2 - 2], src[i * 2 - 1]]); + *p = a.wrapping_sub(b).to_be_bytes(); + } + } else { + let shuffled = if depth == 32 { (0..4).flat_map(|c| src.iter().skip(c).step_by(4).copied()).collect::>() } else { src }; + for (i, p) in row.iter_mut().enumerate() { + *p = shuffled[i].wrapping_sub(if i == 0 { 0 } else { shuffled[i - 1] }); + } + } + } + #[cfg(not(target_arch = "wasm32"))] + for threads in [1, 2, 4] { + use rayon::prelude::*; + let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap(); + pool.install(|| { + (0..3).into_par_iter().for_each(|_| { + let mut got = bytes.clone(); + predict(&mut got, &layout).unwrap(); + assert_eq!(got, expected, "depth {depth}, pool {threads}"); + }) + }); + } + let mut got = bytes; + predict(&mut got, &layout).unwrap(); + assert_eq!(got, expected); + } +} + +/// Verify RLE chunks use the caller's pool and propagate worker panics as errors. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn rle_chunks_use_the_callers_pool_and_report_panics() { + let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap(); + pool.install(|| { + map_chunks(8, (0..64).collect(), |_| assert!(matches!(rayon::current_thread_index(), Some(0 | 1)))).unwrap(); + assert!(map_chunks(2, vec![0, 1], |_| panic!("worker")).is_err()); + }); +} diff --git a/crates/psd/src/filter_effects.rs b/crates/psd/src/filter_effects.rs index b3d545d72..abda0c503 100644 --- a/crates/psd/src/filter_effects.rs +++ b/crates/psd/src/filter_effects.rs @@ -189,6 +189,52 @@ impl FilterEffects { let mut out = Vec::new(); out.put_u32(self.version); for it in &self.items { + let section = out.begin_len(true); + write_pascal(&mut out, it.id.as_bytes(), 1); + out.put_u32(it.version); + let at = out.begin_len(true); + write_rect(&mut out, &it.rect); + out.put_u32(it.depth); + out.put_u32(it.max_channels); + for i in 0..it.max_channels as usize + 2 { + match it.slots.get(i).and_then(Option::as_ref) { + Some(p) => { + out.put_u32(1); + write_plane(&mut out, p); + } + None => out.put_u32(0), + } + } + // A 64-bit length never overflows. + let _ = out.end_len(at, true); + match &it.mask { + Some((r, p)) => { + out.put_u8(1); + write_rect(&mut out, r); + write_plane(&mut out, p); + } + None => out.put_u8(0), + } + let _ = out.end_len(section, true); + out.resize(out.len().next_multiple_of(4), 0); + } + if self.items.is_empty() { + // One empty section, so the block still parses. + out.put_u64(0); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Serialize filter-effect sections through staging buffers as a byte oracle. + fn staged_bytes(fx: &FilterEffects) -> Vec { + let mut out = Vec::new(); + out.put_u32(fx.version); + for it in &fx.items { let mut body = Vec::new(); write_pascal(&mut body, it.id.as_bytes(), 1); body.put_u32(it.version); @@ -219,17 +265,12 @@ impl FilterEffects { out.put(&body); out.resize(out.len().next_multiple_of(4), 0); } - if self.items.is_empty() { + if fx.items.is_empty() { // One empty section, so the block still parses. out.put_u64(0); } out } -} - -#[cfg(test)] -mod tests { - use super::*; fn item(id: &str, mask: bool) -> FilterEffectsItem { let rect = Rect { top: 1, left: 2, bottom: 5, right: 6 }; @@ -240,6 +281,20 @@ mod tests { FilterEffectsItem { id: id.into(), version: 1, rect, depth: 8, max_channels: 24, slots, mask: mask.then(|| (rect, plane(128))) } } + /// Compare direct filter-effect serialization with staged bytes and section lengths. + #[test] + fn direct_sections_match_staged_bytes() { + for version in [1, 2, 3] { + let mut large = item(&"é".repeat(140), true); + large.rect.top = -7; + large.slots[0].as_mut().unwrap().data = vec![73; 8193]; + for items in [Vec::new(), vec![large, item("odd", false), item("four", true)]] { + let fx = FilterEffects { version, items }; + assert_eq!(fx.to_bytes(), staged_bytes(&fx)); + } + } + } + /// The unpadded body of `it`'s section. fn section(it: &FilterEffectsItem) -> Vec { let b = FilterEffects { version: 3, items: vec![it.clone()] }.to_bytes(); diff --git a/crates/psd/src/patterns.rs b/crates/psd/src/patterns.rs index f7fd80a51..23ca8cf3d 100644 --- a/crates/psd/src/patterns.rs +++ b/crates/psd/src/patterns.rs @@ -25,6 +25,8 @@ use crate::compression::{Compression, MAX_DECODED_BYTES, PlaneLayout, decode_planes, encode_planes}; use crate::error::{PsdError, Result}; use crate::header::Version; +use crate::io::WriteExt; +use std::borrow::Cow; /// Largest pattern edge accepted (Photoshop's own limit is far lower). const MAX_EDGE: u32 = 30_000; @@ -417,7 +419,11 @@ fn zeroed_pattern_plane(len: usize) -> Result> { Ok(out) } +/// Append one validated pattern, backpatching lengths in its final output buffer. fn write_pattern(p: &PsdPattern, out: &mut Vec) -> Result<()> { + if !matches!(p.depth, 1 | 8 | 16 | 32) { + return Err(PsdError::Unsupported(format!("pattern depth {}", p.depth))); + } if p.width > MAX_EDGE || p.height > MAX_EDGE || p.width > i16::MAX as u32 || p.height > i16::MAX as u32 { return Err(PsdError::LimitExceeded("pattern size")); } @@ -444,46 +450,50 @@ fn write_pattern(p: &PsdPattern, out: &mut Vec) -> Result<()> { out.extend_from_slice(&pal); } let rect = [0i32, 0, p.height as i32, p.width as i32]; - let mut body = Vec::new(); + out.extend_from_slice(&3u32.to_be_bytes()); + let at = out.begin_len(false); for v in rect { - body.extend_from_slice(&v.to_be_bytes()); + out.extend_from_slice(&v.to_be_bytes()); } const SLOTS: u32 = 24; - body.extend_from_slice(&SLOTS.to_be_bytes()); + out.extend_from_slice(&SLOTS.to_be_bytes()); let layout = PlaneLayout { planes: 1, width: p.width as usize, height: p.height as usize, depth: p.depth, version: Version::Psd }; - let array = |plane: &[u8], body: &mut Vec| -> Result<()> { + let array = |plane: &[u8], out: &mut Vec| -> Result<()> { let (comp, data) = if p.depth == 8 { - (1u8, encode_planes(Compression::Rle, plane, &layout)?) + (1u8, Cow::Owned(encode_planes(Compression::Rle, plane, &layout)?)) } else { - (0u8, encode_planes(Compression::Raw, plane, &layout)?) + let total = layout.decoded_len()?; + if plane.len() != total { + return Err(PsdError::invalid(format!("decoded buffer has {} bytes, layout requires {}", plane.len(), total))); + } + (0u8, Cow::Borrowed(plane)) }; - body.extend_from_slice(&1u32.to_be_bytes()); - body.extend_from_slice(&((23 + data.len()) as u32).to_be_bytes()); - body.extend_from_slice(&u32::from(p.depth).to_be_bytes()); + out.extend_from_slice(&1u32.to_be_bytes()); + let len = u32::try_from(data.len()).ok().and_then(|n| n.checked_add(23)).ok_or(PsdError::LimitExceeded("pattern array length"))?; + out.extend_from_slice(&len.to_be_bytes()); + out.extend_from_slice(&u32::from(p.depth).to_be_bytes()); for v in rect { - body.extend_from_slice(&v.to_be_bytes()); + out.extend_from_slice(&v.to_be_bytes()); } - body.extend_from_slice(&p.depth.to_be_bytes()); - body.push(comp); - body.extend_from_slice(&data); + out.extend_from_slice(&p.depth.to_be_bytes()); + out.push(comp); + out.extend_from_slice(&data); Ok(()) }; let nc = p.channels.len().min(SLOTS as usize); for c in &p.channels[..nc] { - array(c, &mut body)?; + array(c, out)?; } for _ in nc..SLOTS as usize { - body.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(&0u32.to_be_bytes()); } // User mask slot: unused; sheet mask slot: transparency, where Photoshop keeps it (photocraft#8054). - body.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(&0u32.to_be_bytes()); match &p.alpha { - Some(a) => array(a, &mut body)?, - None => body.extend_from_slice(&0u32.to_be_bytes()), + Some(a) => array(a, out)?, + None => out.extend_from_slice(&0u32.to_be_bytes()), } - out.extend_from_slice(&3u32.to_be_bytes()); - out.extend_from_slice(&(body.len() as u32).to_be_bytes()); - out.extend_from_slice(&body); + out.end_len(at, false)?; Ok(()) } @@ -578,11 +588,10 @@ fn pattern_has_user_mask(r: &mut Rd) -> Result { pub fn write_pattern_block(patterns: &[PsdPattern]) -> Result> { let mut out = Vec::new(); for p in patterns { - let mut one = Vec::new(); - write_pattern(p, &mut one)?; - out.extend_from_slice(&(one.len() as u32).to_be_bytes()); - let pad = (4 - one.len() % 4) % 4; - out.extend_from_slice(&one); + let at = out.begin_len(false); + write_pattern(p, &mut out)?; + out.end_len(at, false)?; + let pad = (4 - (out.len() - at - 4) % 4) % 4; out.extend(std::iter::repeat_n(0u8, pad)); } Ok(out) diff --git a/crates/psd/src/patterns/tests.rs b/crates/psd/src/patterns/tests.rs index d1e27692f..ae591bc49 100644 --- a/crates/psd/src/patterns/tests.rs +++ b/crates/psd/src/patterns/tests.rs @@ -17,6 +17,56 @@ fn pat(depth: u16, alpha: bool) -> PsdPattern { } } +/// Compare direct pattern serialization with staged bytes at supported depths. +#[test] +fn direct_writes_match_staged_bytes() { + for depth in [1, 8, 16, 32] { + for alpha in [false, true] { + let mut p = pat(depth, alpha); + if depth == 1 { + p.channels = vec![vec![0xA8; 3]; 3]; + p.alpha = alpha.then(|| vec![0xF8; 3]); + } + p.id = "é".repeat(140); + let mut indexed = p.clone(); + indexed.mode = 2; + indexed.channels.truncate(1); + indexed.palette = Some(vec![73; 17]); + let patterns = [p, indexed]; + let mut block = Vec::new(); + let mut file = b"8BPT\0\x01\0\0\0\x02".to_vec(); + for p in &patterns { + let mut one = Vec::new(); + staged_pattern(p, &mut one).unwrap(); + block.extend_from_slice(&(one.len() as u32).to_be_bytes()); + block.extend_from_slice(&one); + block.resize(block.len().next_multiple_of(4), 0); + file.extend_from_slice(&one); + } + assert_eq!(write_pattern_block(&patterns).unwrap(), block); + assert_eq!(write_pat_file(&patterns).unwrap(), file); + } + } +} + +/// Verify borrowed raw pattern planes retain layout and length validation. +#[test] +fn raw_pattern_writes_still_validate_layout_and_plane_length() { + for depth in [1, 16, 32, 7] { + let p = pat(depth, false); + assert!(write_pattern_block(&[PsdPattern { channels: vec![vec![0; 2]], ..p.clone() }]).is_err()); + assert!(write_pat_file(&[PsdPattern { channels: Vec::new(), alpha: Some(vec![0; 2]), ..p }]).is_err()); + } +} + +/// Verify matching plane lengths cannot bypass unsupported pattern-depth checks. +#[test] +fn writers_reject_unsupported_depth_with_matching_plane_lengths() { + let p = PsdPattern { depth: 7, channels: vec![vec![0; 15]; 3], alpha: Some(vec![0; 15]), ..pat(8, false) }; + assert!(write_pattern_block(std::slice::from_ref(&p)).is_err()); + assert!(write_pat_file(&[p]).is_err()); +} + /// photocraft#9318 review: a name the reader refuses (more than 65,536 UTF-16 units with its NUL) is /// refused on write, so a saved library can't become a file that loads nothing. #[test] @@ -350,3 +400,74 @@ fn zero_size_pattern_rectangles_are_rejected() { set_i32(&mut block, vma + 8, 0); assert!(parse_pattern_block(&block).is_err()); } + +/// Serialize a pattern with the previous staged writer as a byte oracle. +fn staged_pattern(p: &PsdPattern, out: &mut Vec) -> Result<()> { + if p.width > MAX_EDGE || p.height > MAX_EDGE || p.width > i16::MAX as u32 || p.height > i16::MAX as u32 { + return Err(PsdError::LimitExceeded("pattern size")); + } + out.extend_from_slice(&1u32.to_be_bytes()); + out.extend_from_slice(&p.mode.to_be_bytes()); + out.extend_from_slice(&(p.height as u16).to_be_bytes()); + out.extend_from_slice(&(p.width as u16).to_be_bytes()); + let units: Vec = p.name.encode_utf16().chain(std::iter::once(0)).collect(); + // The reader refuses longer names (see `read_pattern`). + if units.len() > 65_536 { + return Err(PsdError::LimitExceeded("pattern name length")); + } + out.extend_from_slice(&(units.len() as u32).to_be_bytes()); + for u in units { + out.extend_from_slice(&u.to_be_bytes()); + } + let id = p.id.as_bytes(); + let idl = id.len().min(255); + out.push(idl as u8); + out.extend_from_slice(&id[..idl]); + if p.mode == 2 { + let mut pal = p.palette.clone().unwrap_or_default(); + pal.resize(768, 0); + out.extend_from_slice(&pal); + } + let rect = [0i32, 0, p.height as i32, p.width as i32]; + let mut body = Vec::new(); + for v in rect { + body.extend_from_slice(&v.to_be_bytes()); + } + const SLOTS: u32 = 24; + body.extend_from_slice(&SLOTS.to_be_bytes()); + let layout = PlaneLayout { planes: 1, width: p.width as usize, height: p.height as usize, depth: p.depth, version: Version::Psd }; + let array = |plane: &[u8], body: &mut Vec| -> Result<()> { + let (comp, data) = if p.depth == 8 { + (1u8, encode_planes(Compression::Rle, plane, &layout)?) + } else { + (0u8, encode_planes(Compression::Raw, plane, &layout)?) + }; + body.extend_from_slice(&1u32.to_be_bytes()); + body.extend_from_slice(&((23 + data.len()) as u32).to_be_bytes()); + body.extend_from_slice(&u32::from(p.depth).to_be_bytes()); + for v in rect { + body.extend_from_slice(&v.to_be_bytes()); + } + body.extend_from_slice(&p.depth.to_be_bytes()); + body.push(comp); + body.extend_from_slice(&data); + Ok(()) + }; + let nc = p.channels.len().min(SLOTS as usize); + for c in &p.channels[..nc] { + array(c, &mut body)?; + } + for _ in nc..SLOTS as usize { + body.extend_from_slice(&0u32.to_be_bytes()); + } + // User mask slot: unused; sheet mask slot: transparency, where Photoshop keeps it (photocraft#8054). + body.extend_from_slice(&0u32.to_be_bytes()); + match &p.alpha { + Some(a) => array(a, &mut body)?, + None => body.extend_from_slice(&0u32.to_be_bytes()), + } + out.extend_from_slice(&3u32.to_be_bytes()); + out.extend_from_slice(&(body.len() as u32).to_be_bytes()); + out.extend_from_slice(&body); + Ok(()) +} diff --git a/crates/psd/tests/atn_sidecar.rs b/crates/psd/tests/atn_sidecar.rs new file mode 100644 index 000000000..101f352d6 --- /dev/null +++ b/crates/psd/tests/atn_sidecar.rs @@ -0,0 +1,233 @@ +//! Independently specified version16 wire streams: one header, consecutive set bodies. +use craft_psd::{atn, descriptor::Value, error::PsdError}; +use proptest::prelude::*; + +fn u32be(out: &mut Vec, value: u32) { + out.extend_from_slice(&value.to_be_bytes()); +} +fn unicode(out: &mut Vec, units: &[u16]) { + u32be(out, units.len() as u32); + for unit in units { + out.extend_from_slice(&unit.to_be_bytes()); + } +} +fn text(out: &mut Vec, value: &str) { + unicode(out, &value.encode_utf16().collect::>()); +} +fn id(out: &mut Vec, key: &str) { + if key.len() == 4 { + u32be(out, 0); + } else { + u32be(out, key.len() as u32); + } + out.extend_from_slice(key.as_bytes()); +} +fn descriptor(items: &[(&str, &[u8; 4], Vec)]) -> Vec { + let mut out = Vec::new(); + unicode(&mut out, &[]); + id(&mut out, "null"); + u32be(&mut out, items.len() as u32); + for (key, kind, value) in items { + id(&mut out, key); + out.extend_from_slice(*kind); + out.extend(value); + } + out +} +fn list(descriptors: &[Vec]) -> Vec { + let mut out = Vec::new(); + u32be(&mut out, descriptors.len() as u32); + for descriptor in descriptors { + out.extend_from_slice(b"Objc"); + out.extend(descriptor); + } + out +} +fn string(value: &str) -> Vec { + let mut out = Vec::new(); + text(&mut out, value); + out +} +fn set_header(out: &mut Vec, name: &[u16], actions: u32) { + unicode(out, name); + out.push(0); + u32be(out, actions); +} +fn action_header(out: &mut Vec, name: &str, items: u32) { + out.extend_from_slice(&[0, 0, 0, 0, 0, 0]); // Fn, Shift, Control and colour. + text(out, name); + out.push(0); + u32be(out, items); +} +fn bare_item(out: &mut Vec) { + out.extend_from_slice(&[0, 1, 0, 0]); + out.extend_from_slice(b"longInvr"); + u32be(out, 0); + u32be(out, 0); +} + +fn fixture() -> (Vec, usize, Vec, Vec) { + let metadata = descriptor(&[ + ("actionDescriptionKey", b"TEXT", string("Description\0")), + ("actionThumbnailTypeKey", b"long", 3i32.to_be_bytes().to_vec()), + ("actionIconKey", b"TEXT", string("future-icon\0")), + ("actionPreviewKey", b"bool", vec![0]), + ("actionGenerativeKey", b"bool", vec![0]), + ("actionPreconditionVal", b"long", (-1i32).to_be_bytes().to_vec()), + ("futureDouble", b"doub", 0x7ff8_0000_0000_0042u64.to_be_bytes().to_vec()), + ]); + let first = descriptor(&[("setColorIndex", b"long", 7i32.to_be_bytes().to_vec()), ("actions", b"VlLs", list(&[metadata]))]); + let empty = descriptor(&[("setColorIndex", b"long", 19i32.to_be_bytes().to_vec()), ("actions", b"VlLs", list(&[]))]); + let root = descriptor(&[("sets", b"VlLs", list(&[first, empty])), ("futureRaw", b"tdta", vec![0, 0, 0, 3, 0, 255, 7])]); + let mut payload = 16u32.to_be_bytes().to_vec(); + payload.extend(root); + let mut raw = Vec::new(); + u32be(&mut raw, payload.len() as u32); + raw.extend(&payload); + let outer = descriptor(&[("Msge", b"TEXT", string("Original note\0")), ("Cntn", b"bool", vec![1]), ("modernizedActionData", b"tdta", raw)]); + let mut bytes = 16u32.to_be_bytes().to_vec(); + set_header(&mut bytes, &"Real\0".encode_utf16().collect::>(), 1); + action_header(&mut bytes, "Invert\0", 1); + bare_item(&mut bytes); + let first_end = bytes.len(); + set_header(&mut bytes, &[0xd800, 0], 0); // Empty real set and unpaired UTF16 remain exact. + set_header(&mut bytes, &"Action sidecar\0".encode_utf16().collect::>(), 1); + action_header(&mut bytes, "Play for details\0", 1); + bytes.extend_from_slice(&[0, 1, 1, 2]); + bytes.extend_from_slice(b"TEXT"); + u32be(&mut bytes, 4); + bytes.extend_from_slice(b"stop"); + u32be(&mut bytes, 4); + bytes.extend_from_slice(b"Stop"); + bytes.extend_from_slice(&(-1i32).to_be_bytes()); + bytes.extend(&outer); + (bytes, first_end, outer, payload) +} + +#[test] +fn one_version_and_three_set_bodies_preserve_raw_sidecar_descriptor_separately() { + let (bytes, first_end, outer, payload) = fixture(); + assert_eq!(bytes.get(first_end..first_end + 4).unwrap(), &2u32.to_be_bytes()); + let sets = atn::parse_sets(&bytes).unwrap(); + assert_eq!(sets.len(), 3); + assert_eq!(sets[0].name.to_string_lossy(), "Real"); + assert_eq!(sets[1].name.0, vec![0xd800, 0]); + assert!(sets[1].actions.is_empty()); + let sidecar = sets[2].actions[0].items[0].descriptor.as_ref().unwrap(); + assert_eq!(sidecar.to_bytes(), outer, "descriptor bytes have their own independent oracle"); + assert_eq!(sidecar.get("modernizedActionData"), Some(&Value::RawData(payload))); + assert_eq!(atn::write_sets(&sets).unwrap(), bytes); + let single = atn::parse(&bytes).unwrap(); + assert_eq!(single.name, sets[0].name); + assert_eq!(single.warnings, [format!("{} trailing bytes ignored", bytes.len() - first_end)]); +} + +#[test] +fn every_truncated_later_set_fails_without_returning_partial_sets() { + let (bytes, first_end, _, _) = fixture(); + // The first complete set alone is valid. The second complete empty set is another valid prefix. + let second_end = first_end + 13; + for cut in first_end + 1..bytes.len() { + if cut != second_end { + assert!(atn::parse_sets(&bytes[..cut]).is_err(), "cut {cut}"); + } + } + assert_eq!(atn::parse_sets(&bytes[..first_end]).unwrap().len(), 1); + assert_eq!(atn::parse_sets(&bytes[..second_end]).unwrap().len(), 2); + let mut repeated = bytes[..first_end].to_vec(); + repeated.extend_from_slice(&16u32.to_be_bytes()); + set_header(&mut repeated, &[], 0); + assert!(atn::parse_sets(&repeated).is_err(), "a repeated version is not the measured set boundary"); + assert!(atn::parse_sets(&16u32.to_be_bytes()).is_err()); + assert!(atn::write_sets(&[]).is_err()); +} + +#[test] +fn forced_encoding_applies_to_all_sets_and_legacy_tails_remain_compatible() { + let (bytes, _, _, _) = fixture(); + let sets = atn::parse_sets_with_encoding(&bytes, Some("shift_jis")).unwrap(); + assert!(sets.iter().all(|set| set.encoding.as_deref() == Some("Shift_JIS"))); + assert_eq!(atn::write_sets(&sets).unwrap(), bytes); + assert!(atn::parse_sets_with_encoding(&bytes, Some("utf-16")).is_err()); + let mut legacy = 12u32.to_be_bytes().to_vec(); + set_header(&mut legacy, &[], 0); + legacy.extend_from_slice(&[99, 1, 4]); + let old = atn::parse(&legacy).unwrap(); + assert_eq!(atn::parse_sets(&legacy).unwrap(), vec![old.clone()]); + assert_eq!(old.warnings, ["3 trailing bytes ignored"]); +} + +#[test] +fn whole_archive_set_and_action_budgets_are_checked_across_boundaries() { + let mut bytes = 16u32.to_be_bytes().to_vec(); + for _ in 0..atn::MAX_SETS { + set_header(&mut bytes, &[], 0); + } + let sets = atn::parse_sets(&bytes).unwrap(); + assert_eq!(sets.len(), atn::MAX_SETS); + assert_eq!(atn::write_sets(&sets).unwrap(), bytes); + set_header(&mut bytes, &[], 0); + assert!(matches!(atn::parse_sets(&bytes), Err(PsdError::LimitExceeded(_)))); + let mut excess = sets; + excess.push(Default::default()); + assert!(matches!(atn::write_sets(&excess), Err(PsdError::LimitExceeded(_)))); + + let mut bytes = 16u32.to_be_bytes().to_vec(); + for _ in 0..2 { + set_header(&mut bytes, &[], (atn::MAX_TOTAL_ACTIONS / 2) as u32); + for _ in 0..atn::MAX_TOTAL_ACTIONS / 2 { + action_header(&mut bytes, "", 0); + } + } + let sets = atn::parse_sets(&bytes).unwrap(); + assert_eq!(sets.iter().map(|set| set.actions.len()).sum::(), atn::MAX_TOTAL_ACTIONS); + assert_eq!(atn::write_sets(&sets).unwrap(), bytes); + set_header(&mut bytes, &[], 1); + action_header(&mut bytes, "", 0); + assert!(matches!(atn::parse_sets(&bytes), Err(PsdError::LimitExceeded(_)))); +} + +#[test] +fn aggregate_steps_are_bounded_before_a_later_action_allocates() { + let mut bytes = 16u32.to_be_bytes().to_vec(); + set_header(&mut bytes, &[], 2); + for _ in 0..2 { + action_header(&mut bytes, "", (atn::MAX_TOTAL_ITEMS / 2) as u32); + for _ in 0..atn::MAX_TOTAL_ITEMS / 2 { + bare_item(&mut bytes); + } + } + let sets = atn::parse_sets(&bytes).unwrap(); + assert_eq!(atn::write_sets(&sets).unwrap(), bytes); + set_header(&mut bytes, &[], 1); + action_header(&mut bytes, "", 1); + bare_item(&mut bytes); + assert!(matches!(atn::parse_sets(&bytes), Err(PsdError::LimitExceeded(_)))); + let mut excess = sets; + excess[0].actions[0].items.push(Default::default()); + assert!(matches!(atn::write_sets(&excess), Err(PsdError::LimitExceeded(_)))); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + #[test] + fn arbitrary_streams_reject_or_reach_a_stable_writer(data in prop::collection::vec(any::(), 0..2048)) { + if let Ok(files) = atn::parse_sets(&data) { + let encoded = atn::write_sets(&files).unwrap(); + let again = atn::parse_sets(&encoded).unwrap(); + prop_assert_eq!(atn::write_sets(&again).unwrap(), encoded); + } + } + + #[test] + fn mutated_independent_multiset_stream_rejects_or_reaches_a_stable_writer(index in any::(), value in any::()) { + let (mut bytes, _, _, _) = fixture(); + let at = index % bytes.len(); bytes[at] = value; + if let Ok(files) = atn::parse_sets(&bytes) { + let encoded = atn::write_sets(&files).unwrap(); + let again = atn::parse_sets(&encoded).unwrap(); + prop_assert_eq!(atn::write_sets(&again).unwrap(), encoded); + } + } +} diff --git a/crates/rtf/Cargo.toml b/crates/rtf/Cargo.toml index 59f139917..ecff5a2fe 100644 --- a/crates/rtf/Cargo.toml +++ b/crates/rtf/Cargo.toml @@ -10,7 +10,17 @@ repository.workspace = true [lints] workspace = true +[features] +# `outlook`: compressed RTF ([MS-OXRTFCP]) and the text or encapsulated HTML ([MS-OXRTFEX]) of a +# mail item's body. +outlook = ["dep:craft-mime"] + [dependencies] craft-codepage = { workspace = true } +# Character sets by Windows code page number, for the `outlook` module. +craft-mime = { workspace = true, optional = true } # The East Asian multibyte code pages (932, 936, 949, 950). encoding_rs = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } diff --git a/crates/sheet/src/a1.rs b/crates/sheet/src/a1.rs index be5943661..95586da9e 100644 --- a/crates/sheet/src/a1.rs +++ b/crates/sheet/src/a1.rs @@ -66,8 +66,9 @@ impl std::fmt::Display for CellRef { } } -/// A rectangle of cells, both corners included; `first` is the top left one. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +/// A rectangle of cells, both corners included; `first` is the top left one. The default is +/// `A1`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub struct Range { pub first: CellRef, pub last: CellRef, diff --git a/crates/timeline/Cargo.toml b/crates/timeline/Cargo.toml index 59a79ed4b..3eca9bc5c 100644 --- a/crates/timeline/Cargo.toml +++ b/crates/timeline/Cargo.toml @@ -15,14 +15,18 @@ workspace = true serde = ["dep:serde"] # Final Cut Pro 7 XML (`fcp7`), read with craft-xml's bounded parser. xml = ["dep:craft-xml"] +# OpenTimelineIO JSON (`otio`), with serde_json. +otio = ["dep:serde_json"] [dependencies] serde = { workspace = true, optional = true } craft-xml = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } [dev-dependencies] proptest = { workspace = true } +serde_json = { workspace = true } [[test]] name = "it" -required-features = ["xml"] +required-features = ["xml", "otio"] diff --git a/crates/timeline/README.md b/crates/timeline/README.md index f53be17d6..6397d9d2e 100644 --- a/crates/timeline/README.md +++ b/crates/timeline/README.md @@ -16,6 +16,7 @@ text formats that are documents of their own. | `edl` | CMX 3600 edit decision lists: `parse` to the list as written (events, their lines and timecode fields, `M2`, `AUD`, clip-name, source-file, effect-name and locator comments), `write` for events the caller built, channel codes, reel names, the frame-rate guess. | | `fcp7` (feature `xml`) | Final Cut Pro 7 XML (`xmeml` 4 and 5, the dialect Premiere Pro reads and writes) to and from `model`. Read with craft-xml, strictly and bounded. | | `fcpxml` (feature `xml`) | FCPXML 1.9–1.11 to and from `model`: the primary storyline as V1, connected clips by lane, `ref-clip` compounds as nested sequences, rational times exact, transforms, opacity, volume and their keyframes, retiming through `timeMap`. | +| `otio` (feature `otio`) | OpenTimelineIO JSON to and from `model`: timelines, collections, stacks as nested sequences, gaps, transitions, markers, linear time warps and freeze frames. What OTIO has no schema for goes in `metadata.`, and the model's `ext` is merged into it, so an application's own data round-trips. | | crate root | `Format` (EDL, xmeml, FCPXML, OTIO, AAF, OMF), `detect_text`, `decode_text`. | No file I/O. EDL and ALE give the document as the format writes it. The formats that describe a @@ -23,7 +24,7 @@ whole edit read into and write from `model`; mapping `model` onto an application stays in the application. AAF and OMF are in `craft-aaf`. -Feature `xml`: the XML formats, on craft-xml. Feature `serde`: `Serialize` / `Deserialize` for the time, report and `Format` types. +Feature `xml`: the XML formats, on craft-xml. Feature `otio`: OpenTimelineIO, on serde_json. Feature `serde`: `Serialize` / `Deserialize` for the time, report and `Format` types. ## Specifications diff --git a/crates/timeline/src/fcpxml.rs b/crates/timeline/src/fcpxml.rs index 81342f70c..198574718 100644 --- a/crates/timeline/src/fcpxml.rs +++ b/crates/timeline/src/fcpxml.rs @@ -1325,7 +1325,7 @@ mod tests { "# ); let mut report = Report::default(); - let iopts = ImportOptions { base_dir: None, catalog: &crate::fx::Standard, namespace: "test" }; + let iopts = ImportOptions { base_dir: None, name: None, catalog: &crate::fx::Standard, namespace: "test" }; let imported = import(&xml, &iopts, &mut report).expect("import succeeds"); let xopts = ExportOptions { relative_to: None, diff --git a/crates/timeline/src/lib.rs b/crates/timeline/src/lib.rs index 4de152282..b638fd3ec 100644 --- a/crates/timeline/src/lib.rs +++ b/crates/timeline/src/lib.rs @@ -15,6 +15,7 @@ //! - [`edl`]: CMX 3600 edit decision lists. //! - [`fcp7`] (feature `xml`): Final Cut Pro 7 XML (`xmeml`). //! - [`fcpxml`] (feature `xml`): FCPXML 1.9–1.11. +//! - [`otio`] (feature `otio`): OpenTimelineIO JSON. //! - [`Format`], [`detect_text`], [`decode_text`]: which interchange format a document is. //! //! No file I/O: documents are text or bytes, and media references are strings. Each format module @@ -30,6 +31,8 @@ pub mod fcp7; pub mod fcpxml; pub mod fx; pub mod model; +#[cfg(feature = "otio")] +pub mod otio; pub mod path; pub mod rate; pub mod report; @@ -116,7 +119,7 @@ pub enum Error { } impl Error { - #[cfg_attr(not(feature = "xml"), allow(dead_code))] + #[cfg_attr(not(any(feature = "xml", feature = "otio")), allow(dead_code))] pub(crate) fn parse(format: Format, message: impl Into) -> Error { Error::Parse { format: format.name(), message: message.into() } } @@ -142,6 +145,8 @@ pub type Result = std::result::Result; pub struct ImportOptions<'a> { /// Directory relative media paths are resolved against (normally the document's directory). pub base_dir: Option<&'a str>, + /// Name for a timeline the document leaves unnamed. + pub name: Option<&'a str>, /// The application's effects and generators (see [`model::Catalog`]; [`fx::Standard`] knows /// only what the formats name themselves). pub catalog: &'a dyn model::Catalog, diff --git a/crates/timeline/src/model.rs b/crates/timeline/src/model.rs index 6adff43dd..3f8db8c1c 100644 --- a/crates/timeline/src/model.rs +++ b/crates/timeline/src/model.rs @@ -14,7 +14,7 @@ //! supplies. //! - **Generators** other than the ones every editor has: [`Generator::Other`] carries a name and //! an opaque payload. -//! - **Extensions.** `ext` on items, sequences, tracks, clips, transitions and markers is JSON +//! - **Extensions.** `ext` on items (a sequence's is its item's), tracks, clips, transitions and markers is JSON //! text the application owns. A format that can carry application metadata (OpenTimelineIO) //! writes it under the application's namespace and gives it back on reading; the others ignore it. @@ -540,12 +540,11 @@ pub struct Sequence { pub markers: Vec, /// Timecode of the first frame, in frames. pub start_timecode: i64, - pub ext: Ext, } impl Sequence { pub fn new(settings: SequenceSettings) -> Self { - Self { settings, video_tracks: Vec::new(), audio_tracks: Vec::new(), markers: Vec::new(), start_timecode: 0, ext: None } + Self { settings, video_tracks: Vec::new(), audio_tracks: Vec::new(), markers: Vec::new(), start_timecode: 0 } } /// The end of the last clip. pub fn duration(&self) -> Tick { diff --git a/crates/timeline/tests/it/fcp7.rs b/crates/timeline/tests/it/fcp7.rs index dfa3cc186..00d0704ec 100644 --- a/crates/timeline/tests/it/fcp7.rs +++ b/crates/timeline/tests/it/fcp7.rs @@ -723,7 +723,7 @@ fn absurd_timebase_is_bounded() { ); let (imported, _) = fcp7_import(&doc, None); for seq in &imported.sequences { - for format in [Format::Fcp7Xml, Format::Fcpxml] { + for format in [Format::Fcp7Xml, Format::Fcpxml, Format::Otio] { export(&imported.project, *seq, format, &xopts()).unwrap_or_else(|e| panic!("{format:?}: {e}")); } } @@ -742,7 +742,7 @@ fn absurd_start_timecode_is_bounded() { "#; let (imported, report) = fcp7_import(doc, None); for seq in &imported.sequences { - for format in [Format::Fcp7Xml, Format::Fcpxml] { + for format in [Format::Fcp7Xml, Format::Fcpxml, Format::Otio] { export(&imported.project, *seq, format, &xopts()).unwrap_or_else(|e| panic!("{format:?}: {e}")); } assert_eq!(imported.project.sequence(*seq).map(|s| s.start_timecode), Some(0)); diff --git a/crates/timeline/tests/it/main.rs b/crates/timeline/tests/it/main.rs index 12e73ea72..f8d222b28 100644 --- a/crates/timeline/tests/it/main.rs +++ b/crates/timeline/tests/it/main.rs @@ -2,4 +2,5 @@ mod fcp7; mod fcpxml; +mod otio; mod support; diff --git a/crates/timeline/tests/it/support.rs b/crates/timeline/tests/it/support.rs index 4a3247d78..1afb703a0 100644 --- a/crates/timeline/tests/it/support.rs +++ b/crates/timeline/tests/it/support.rs @@ -11,7 +11,7 @@ use craft_timeline::model::{ TransitionId, VideoStreamInfo, }; use craft_timeline::time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange}; -use craft_timeline::{Error, ExportOptions, Format, ImportOptions, Imported, Report, fcp7, fcpxml}; +use craft_timeline::{Error, ExportOptions, Format, ImportOptions, Imported, Report, fcp7, fcpxml, otio}; /// [`Standard`] and a few effects an application adds: a blur, a grade, two transitions. pub struct TestCatalog; @@ -31,6 +31,7 @@ fn extra() -> Vec { def("lumetri", "Lumetri Color", EffectKind::Video, "Color Correction", vec![float("exposure", "Exposure", 0.0)]), def("dip_to_white", "Dip to White", EffectKind::VideoTransition, "Dissolve", vec![]), def("push", "Push", EffectKind::VideoTransition, "Slide", vec![]), + def("iris_round", "Iris Round", EffectKind::VideoTransition, "Iris", vec![]), ] } @@ -49,7 +50,7 @@ impl Catalog for TestCatalog { pub const NS: &str = "testapp"; pub fn iopts(base_dir: Option<&str>) -> ImportOptions<'_> { - ImportOptions { base_dir, catalog: &TestCatalog, namespace: NS } + ImportOptions { base_dir, name: None, catalog: &TestCatalog, namespace: NS } } pub fn xopts() -> ExportOptions<'static> { @@ -307,6 +308,7 @@ pub fn import(bytes: &[u8], format: Format, base_dir: Option<&str>) -> Result<(I let imported = match format { Format::Fcp7Xml => fcp7::import(&text, &opts, &mut report), Format::Fcpxml => fcpxml::import(&text, &opts, &mut report), + Format::Otio => otio::import(&text, &opts, &mut report), _ => Err(Error::Other(format!("{} is not read here", format.name()))), }?; Ok((imported, report)) @@ -318,6 +320,7 @@ pub fn export(p: &Project, seq: ItemId, format: Format, opts: &ExportOptions) -> let text = match format { Format::Fcp7Xml => fcp7::export(p, seq, opts, &mut report), Format::Fcpxml => fcpxml::export(p, seq, opts, &mut report), + Format::Otio => otio::export(p, seq, opts, &mut report), _ => Err(Error::Other(format!("{} is not written here", format.name()))), }?; Ok((text.into_bytes(), report)) diff --git a/crates/webp/Cargo.toml b/crates/webp/Cargo.toml index 5a0901a3a..c5f7364b6 100644 --- a/crates/webp/Cargo.toml +++ b/crates/webp/Cargo.toml @@ -13,10 +13,13 @@ workspace = true [features] # Decoding every frame through the `image` crate's WebP decoder, with bounded memory. frames = ["dep:image"] +# Lossy animations from RGBA frames through craft-video's VP8 key-frame encoder. +vp8 = ["dep:craft-video"] [dependencies] thiserror = { workspace = true } image = { version = "0.25", default-features = false, features = ["webp"], optional = true } +craft-video = { workspace = true, optional = true, features = ["vp8-encode"] } [dev-dependencies] # Decodes the files the writer makes and writes still test files; not a product dependency. diff --git a/crates/wordmodel/Cargo.toml b/crates/wordmodel/Cargo.toml index 7426a738d..200cd1653 100644 --- a/crates/wordmodel/Cargo.toml +++ b/crates/wordmodel/Cargo.toml @@ -15,6 +15,9 @@ workspace = true test-support = [] [dependencies] +# The equation tree and its forms (math, math_latex, math_linear, math_symbols, math_tree), with +# symbol_fonts and Rgb, re-exported here under the paths they had. +craft-math = { workspace = true } base64 = "0.23" log = "0.4" serde = { version = "1", features = ["derive", "rc"] } diff --git a/crates/wordmodel/src/lib.rs b/crates/wordmodel/src/lib.rs index 9034bd8af..5fb2d14d8 100644 --- a/crates/wordmodel/src/lib.rs +++ b/crates/wordmodel/src/lib.rs @@ -37,11 +37,7 @@ pub mod legacy_form; pub mod line_dash; pub mod link; pub mod locale_number; -pub mod math; -pub mod math_latex; -pub mod math_linear; -pub mod math_symbols; -pub mod math_tree; +pub use craft_math::{math, math_latex, math_linear, math_symbols, math_tree, symbol_fonts}; pub mod numbering; pub mod para; pub mod picfx; @@ -50,7 +46,6 @@ pub mod resolve; pub mod section; pub mod smart_art; pub mod styles; -pub mod symbol_fonts; pub mod table; pub mod walk; pub mod wordart; diff --git a/crates/wordmodel/src/math.rs b/crates/wordmodel/src/math.rs deleted file mode 100644 index db9875fdb..000000000 --- a/crates/wordmodel/src/math.rs +++ /dev/null @@ -1,1138 +0,0 @@ -//! Equations: the Office Math object model (OMML, ECMA-376 Part 1 §22.1) as a tree, its linear -//! format (UnicodeMath, the plain text Word shows for an equation) and a parser for that format. -//! -//! An equation is a list of [`MNode`]s (an *argument*); structures hold arguments of their own -//! (a fraction's numerator and denominator, a radical's degree and base…). Layout reads the tree; -//! DOCX import builds it from OMML and keeps the source XML so a saved file carries the equation -//! exactly as Word wrote it. - -use serde::{Deserialize, Serialize}; - -use crate::props::Rgb; - -/// A sequence of math nodes (OMML's `m:e`, `m:num`, `m:sub`…). -pub type Arg = Vec; - -/// Deepest structure nesting kept (parsers stop here; layout draws nothing deeper). -pub const MAX_DEPTH: usize = 64; - -/// Most matrix columns kept (writers pad every row to the widest, so this bounds a row's cost). -pub const MAX_MATRIX_COLS: usize = 64; - -/// Most matrix rows kept. -pub const MAX_MATRIX_ROWS: usize = 1000; - -/// An equation's structure, plus what's needed to write it back. -#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] -pub struct Math { - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub nodes: Arg, - /// The source `m:oMath` element (DOCX import), written back unchanged on save. Empty for - /// equations made here. - #[serde(default, skip_serializing_if = "String::is_empty")] - pub omml: String, - /// Justification of a display equation. - #[serde(default, skip_serializing_if = "is_default")] - pub jc: MathJc, - /// Shown as its linear-format text (Equation › Convert › Linear) instead of built up. - #[serde(default, skip_serializing_if = "is_default")] - pub linear: bool, -} - -fn is_default(v: &T) -> bool { - *v == T::default() -} - -impl Math { - pub fn is_empty(&self) -> bool { - self.nodes.is_empty() - } - - /// Rough heap bytes a clone copies: every node, its argument lists and its run text. - /// Iterative, so it costs at most the size of the structure. - pub fn heap_bytes(&self) -> usize { - let mut n = self.omml.len(); - let mut todo: Vec<&MNode> = self.nodes.iter().collect(); - while let Some(node) = todo.pop() { - n = n.saturating_add(std::mem::size_of::()); - if let MNode::Run(r) = node { - n = n.saturating_add(r.text.len()).saturating_add(r.font.as_ref().map_or(0, String::len)); - } - each_arg(node, &mut |a| { - n = n.saturating_add(std::mem::size_of::()); - todo.extend(a); - }); - } - n - } -} - -/// Display equation justification (`m:oMathParaPr/m:jc`). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum MathJc { - #[default] - CenterGroup, - Center, - Left, - Right, -} - -/// Where a display equation too wide for its line breaks at a binary operator (`m:brkBin`). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum BrkBin { - /// The operator starts the next line. - #[default] - Before, - /// The operator ends the line. - After, - /// The operator ends the line and starts the next one again. - Repeat, -} - -/// How a minus repeats across a break when [`BrkBin::Repeat`] (`m:brkBinSub`): the operator -/// ending the line, then the one starting the next. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum BrkBinSub { - /// `--` - #[default] - MinusMinus, - /// `-+` - MinusPlus, - /// `+-` - PlusMinus, -} - -/// Document-wide equation options (`m:mathPr` in the settings part) that layout uses: how long -/// display equations wrap. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct MathProps { - pub brk_bin: BrkBin, - pub brk_bin_sub: BrkBinSub, - /// Indent of a wrapped equation's continuation lines, points (`m:wrapIndent`). - pub wrap_indent: f32, - /// Continuation lines align right instead (`m:wrapRight`). - pub wrap_right: bool, -} - -/// `m:wrapIndent`'s default: 1440 twips, one inch. -pub const DEFAULT_WRAP_INDENT: f32 = 72.0; - -impl Default for MathProps { - fn default() -> Self { - MathProps { brk_bin: BrkBin::Before, brk_bin_sub: BrkBinSub::MinusMinus, wrap_indent: DEFAULT_WRAP_INDENT, wrap_right: false } - } -} - -/// Math run style (`m:sty`): plain, bold, italic, bold italic. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum MSty { - Plain, - Bold, - Italic, - BoldItalic, -} - -/// Math alphabet (`m:scr`). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum MScr { - #[default] - Roman, - Script, - Fraktur, - DoubleStruck, - SansSerif, - Monospace, -} - -/// A run of math text (`m:r`). -#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] -pub struct MRun { - pub text: String, - /// `None`: math default (letters italic, the rest upright). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sty: Option, - #[serde(default, skip_serializing_if = "is_default")] - pub scr: MScr, - /// Normal (non-math) text. - #[serde(default, skip_serializing_if = "is_default")] - pub nor: bool, - /// Literal: no automatic operator handling. - #[serde(default, skip_serializing_if = "is_default")] - pub lit: bool, - /// Font size, points. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub size: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub color: Option, - /// Font for normal text. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub font: Option, - /// A manual line break before this run in a display equation (`m:brk`): `Some(0)` wraps as - /// usual, `Some(n)` aligns the new line at the `n`th operator of the first line (`m:alnAt`). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub brk: Option, -} - -impl MRun { - pub fn new(text: impl Into) -> MRun { - MRun { text: text.into(), ..Default::default() } - } - pub fn plain(text: impl Into) -> MRun { - MRun { text: text.into(), sty: Some(MSty::Plain), ..Default::default() } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum FracKind { - /// Stacked with a rule. - #[default] - Bar, - /// Skewed (num ⁄ den). - Skewed, - /// Linear (num/den). - Linear, - /// Stacked without a rule. - NoBar, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ScriptKind { - Sup, - Sub, - SubSup, - /// Pre-scripts (`m:sPre`). - Pre, -} - -/// Where an n-ary operator's limits go. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum LimLoc { - /// Under and over the operator. - UndOvr, - /// As sub- and superscripts. - SubSup, -} - -/// Column justification in a matrix. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ColJc { - #[default] - Center, - Left, - Right, -} - -/// A math node. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(tag = "t", rename_all = "camelCase")] -pub enum MNode { - Run(MRun), - /// `m:f`. - Frac { - #[serde(default)] - kind: FracKind, - num: Arg, - den: Arg, - }, - /// `m:sSup`, `m:sSub`, `m:sSubSup`, `m:sPre`. - Script { - kind: ScriptKind, - base: Arg, - #[serde(default)] - sub: Arg, - #[serde(default)] - sup: Arg, - }, - /// `m:rad`. - Rad { - #[serde(default)] - deg: Arg, - #[serde(default)] - deg_hide: bool, - e: Arg, - }, - /// `m:nary`: ∑ ∫ ∏ … with limits and an operand. - Nary { - chr: char, - /// `None`: Word's default (integrals take sub/sup limits, the rest under/over). - #[serde(default)] - lim_loc: Option, - #[serde(default)] - grow: bool, - #[serde(default)] - sub_hide: bool, - #[serde(default)] - sup_hide: bool, - #[serde(default)] - sub: Arg, - #[serde(default)] - sup: Arg, - e: Arg, - }, - /// `m:d`: delimiters around elements separated by `sep`. - Delim { - /// `None` = no delimiter. - beg: Option, - end: Option, - #[serde(default)] - sep: Option, - /// Delimiters grow to the content (Word's default). - #[serde(default = "yes")] - grow: bool, - /// Grow to match the content exactly rather than centred on the math axis. - #[serde(default)] - shp_match: bool, - elems: Vec, - }, - /// `m:func`: function name applied to an argument (sin x). - Func { - name: Arg, - e: Arg, - }, - /// `m:limLow` / `m:limUpp`. - Lim { - upper: bool, - e: Arg, - lim: Arg, - }, - /// `m:acc`: accent over the base. - Acc { - chr: char, - e: Arg, - }, - /// `m:bar`: overbar or underbar. - Bar { - top: bool, - e: Arg, - }, - /// `m:borderBox`. `hide`: top, bottom, left, right. `strike`: horizontal, vertical, - /// bottom-left→top-right, top-left→bottom-right. - BorderBox { - #[serde(default)] - hide: [bool; 4], - #[serde(default)] - strike: [bool; 4], - e: Arg, - }, - /// `m:box`. - Boxed { - e: Arg, - }, - /// `m:groupChr`: a stretched character (brace, arrow) under or over the base. - GroupChr { - chr: char, - top: bool, - e: Arg, - }, - /// `m:eqArr`: rows aligned at `&`. - EqArr { - rows: Vec, - }, - /// `m:m`. - Matrix { - rows: Vec>, - #[serde(default)] - col_jc: Vec, - }, - /// `m:phant`. - Phant { - #[serde(default = "yes")] - show: bool, - #[serde(default)] - zero_wid: bool, - #[serde(default)] - zero_asc: bool, - #[serde(default)] - zero_desc: bool, - e: Arg, - }, -} - -fn yes() -> bool { - true -} - -/// A row holds an equation-number mark (`#`) in a top-level run. -pub fn has_number_mark(row: &[MNode]) -> bool { - row.iter().any(|n| matches!(n, MNode::Run(r) if !r.lit && !r.nor && r.text.contains('#'))) -} - -/// Integral signs (their limits default to sub/sup positions). -pub fn is_integral(c: char) -> bool { - matches!(c, '∫' | '∬' | '∭' | '∮' | '∯' | '∰' | '∱' | '∲' | '∳' | '⨌') -} - -/// Characters that take n-ary operator form in the linear format. -pub fn is_nary_char(c: char) -> bool { - is_integral(c) || matches!(c, '∑' | '∏' | '∐' | '⋃' | '⋂' | '⋁' | '⋀' | '⨁' | '⨂' | '⨀' | '⨄' | '⨆') -} - -/// Mathematical alphanumeric for `c` in a style and alphabet (Unicode block U+1D400). -pub fn math_alnum(c: char, bold: bool, italic: bool, scr: MScr) -> char { - let off = |base: u32, k: u32| char::from_u32(base + k).unwrap_or(c); - let upper = c.is_ascii_uppercase(); - let lower = c.is_ascii_lowercase(); - if upper || lower { - let k = if upper { c as u32 - 'A' as u32 } else { c as u32 - 'a' as u32 }; - // Letters that live in the Letterlike Symbols block. - let hole = |table: &[(char, char)]| table.iter().find(|(f, _)| *f == c).map(|(_, t)| *t); - let (base_u, base_l) = match (scr, bold, italic) { - (MScr::Roman, false, false) => return c, - (MScr::Roman, true, false) => (0x1D400, 0x1D41A), - (MScr::Roman, false, true) => { - if c == 'h' { - return '\u{210E}'; - } - (0x1D434, 0x1D44E) - } - (MScr::Roman, true, true) => (0x1D468, 0x1D482), - (MScr::Script, false, _) => { - if let Some(t) = hole(&[ - ('B', 'ℬ'), - ('E', 'ℰ'), - ('F', 'ℱ'), - ('H', 'ℋ'), - ('I', 'ℐ'), - ('L', 'ℒ'), - ('M', 'ℳ'), - ('R', 'ℛ'), - ('e', 'ℯ'), - ('g', 'ℊ'), - ('o', 'ℴ'), - ]) { - return t; - } - (0x1D49C, 0x1D4B6) - } - (MScr::Script, true, _) => (0x1D4D0, 0x1D4EA), - (MScr::Fraktur, false, _) => { - if let Some(t) = hole(&[('C', 'ℭ'), ('H', 'ℌ'), ('I', 'ℑ'), ('R', 'ℜ'), ('Z', 'ℨ')]) { - return t; - } - (0x1D504, 0x1D51E) - } - (MScr::Fraktur, true, _) => (0x1D56C, 0x1D586), - (MScr::DoubleStruck, _, _) => { - if let Some(t) = hole(&[('C', 'ℂ'), ('H', 'ℍ'), ('N', 'ℕ'), ('P', 'ℙ'), ('Q', 'ℚ'), ('R', 'ℝ'), ('Z', 'ℤ')]) { - return t; - } - (0x1D538, 0x1D552) - } - (MScr::SansSerif, false, false) => (0x1D5A0, 0x1D5BA), - (MScr::SansSerif, true, false) => (0x1D5D4, 0x1D5EE), - (MScr::SansSerif, false, true) => (0x1D608, 0x1D622), - (MScr::SansSerif, true, true) => (0x1D63C, 0x1D656), - (MScr::Monospace, _, _) => (0x1D670, 0x1D68A), - }; - return off(if upper { base_u } else { base_l }, k); - } - if c.is_ascii_digit() { - let k = c as u32 - '0' as u32; - return match (scr, bold) { - (MScr::Roman, true) => off(0x1D7CE, k), - (MScr::DoubleStruck, _) => off(0x1D7D8, k), - (MScr::SansSerif, false) => off(0x1D7E2, k), - (MScr::SansSerif, true) => off(0x1D7EC, k), - (MScr::Monospace, _) => off(0x1D7F6, k), - _ => c, - }; - } - // Greek (roman alphabet only). - if scr == MScr::Roman && (bold || italic) { - let (cap, small) = match (bold, italic) { - (true, false) => (0x1D6A8, 0x1D6C2), - (false, true) => (0x1D6E2, 0x1D6FC), - _ => (0x1D71C, 0x1D736), - }; - let u = c as u32; - if (0x391..=0x3A9).contains(&u) && u != 0x3A2 { - return off(cap, u - 0x391); - } - if (0x3B1..=0x3C9).contains(&u) { - return off(small, u - 0x3B1); - } - let extra = match c { - '∂' => Some(25), - 'ϵ' => Some(26), - 'ϑ' => Some(27), - 'ϰ' => Some(28), - 'ϕ' => Some(29), - 'ϱ' => Some(30), - 'ϖ' => Some(31), - _ => None, - }; - if let Some(e) = extra - && italic - { - return off(small, e); - } - } - c -} - -// --------------------------------------------------------------------------------------------- -// Linear format out. - -/// The equation as linear-format text (`x=(-b±√(b^2-4ac))/2a`). -pub fn to_linear(nodes: &[MNode]) -> String { - let mut s = String::new(); - lin_arg(nodes, &mut s, 0); - s -} - -fn lin_arg(a: &[MNode], s: &mut String, depth: usize) { - if depth > MAX_DEPTH { - return; - } - for (i, n) in a.iter().enumerate() { - lin_node(n, s, depth + 1); - // A bare script or limit ends at a space: a letter, digit or period after it is its own item. - if matches!(n, MNode::Script { kind, .. } if *kind != ScriptKind::Pre) || matches!(n, MNode::Lim { .. }) { - let ends_bare = s.chars().next_back().is_some_and(|c| c.is_alphanumeric() || c == '.'); - if ends_bare && a.get(i + 1).and_then(first_lin_char).is_some_and(|c| c.is_alphanumeric() || c == '.') { - s.push(' '); - } - } - } -} - -/// The first character `n` is written with, when that is cheap to tell (letters and digits). -fn first_lin_char(n: &MNode) -> Option { - match n { - MNode::Run(r) => r.text.chars().next(), - MNode::Func { name, .. } => name.first().and_then(first_lin_char), - MNode::Script { kind, base, .. } if *kind != ScriptKind::Pre => base.first().and_then(first_lin_char), - MNode::Lim { e, .. } => e.first().and_then(first_lin_char), - MNode::Frac { num, .. } => num.first().and_then(first_lin_char), - _ => None, - } -} - -/// An argument as an operand: bare when it's a single simple atom, parenthesised otherwise. -fn lin_operand(a: &[MNode], s: &mut String, depth: usize) { - let mut inner = String::new(); - lin_arg(a, &mut inner, depth); - let simple = match a { - [MNode::Run(r)] => { - let mut cs = r.text.chars(); - match (cs.next(), cs.next()) { - (Some(c), None) => !"+-−=/^_ ".contains(c), - _ => r.text.chars().all(|c| c.is_alphanumeric() || c == '.'), - } - } - [MNode::Delim { .. }] => true, - _ => false, - }; - if simple && !inner.is_empty() { - s.push_str(&inner); - } else { - s.push('('); - s.push_str(&inner); - s.push(')'); - } -} - -/// A script or limit operand. The parser reads a run of letters and digits up to the next space or -/// operator, so `V_dd` is written bare as Word's Linear format shows it (`V_(dd)` reads back the same). -fn lin_script(a: &[MNode], s: &mut String, depth: usize) { - // Bare only when the parser reads the whole run back, in the same style. - if let [MNode::Run(r)] = a - && r.text.chars().nth(1).is_some() - { - let upright_name = r.sty == Some(MSty::Plain) && crate::math_linear::FUNCTION_NAMES.contains(&r.text.as_str()); - let number = r.text.starts_with(|c: char| c.is_ascii_digit()) && r.text.chars().all(|c| c.is_alphanumeric() || c == '.'); - let word = r.text.starts_with(char::is_alphabetic) && r.text.chars().all(char::is_alphanumeric); - let upright_other = r.sty == Some(MSty::Plain) && !upright_name; - if upright_other || (!number && !word) { - s.push('('); - s.push_str(&r.text); - s.push(')'); - return; - } - } - lin_operand(a, s, depth); -} - -fn lin_node(n: &MNode, s: &mut String, depth: usize) { - match n { - MNode::Run(r) => s.push_str(&r.text), - MNode::Frac { kind, num, den } => { - lin_operand(num, s, depth); - s.push(match kind { - FracKind::Skewed => '⁄', - FracKind::Linear => '∕', - FracKind::NoBar => '¦', - FracKind::Bar => '/', - }); - lin_operand(den, s, depth); - } - MNode::Script { kind, base, sub, sup } => { - if *kind == ScriptKind::Pre { - s.push('_'); - lin_script(sub, s, depth); - s.push('^'); - lin_script(sup, s, depth); - s.push('▒'); - lin_operand(base, s, depth); - return; - } - lin_operand(base, s, depth); - if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) { - s.push('_'); - lin_script(sub, s, depth); - } - if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) { - s.push('^'); - lin_script(sup, s, depth); - } - } - MNode::Rad { deg, deg_hide, e } => { - if *deg_hide || deg.is_empty() { - s.push('√'); - lin_operand(e, s, depth); - } else { - s.push_str("√("); - lin_arg(deg, s, depth); - s.push('&'); - lin_arg(e, s, depth); - s.push(')'); - } - } - MNode::Nary { chr, sub_hide, sup_hide, sub, sup, e, .. } => { - s.push(*chr); - if !sub_hide && !sub.is_empty() { - s.push('_'); - lin_script(sub, s, depth); - } - if !sup_hide && !sup.is_empty() { - s.push('^'); - lin_script(sup, s, depth); - } - s.push('▒'); - lin_operand(e, s, depth); - } - MNode::Delim { beg, end, elems, .. } => { - // A standard pair writes as itself; anything else uses explicit `├X … Y┤`. - let pair = matches!((beg, end), (Some(b), Some(e)) if crate::math_linear::closer(*b) == Some(*e)); - let invisible = beg.is_none() && end.is_none(); - if pair { - s.extend(*beg); - } else if invisible { - s.push('〖'); - } else { - s.push('├'); - s.extend(*beg); - } - for (i, e) in elems.iter().enumerate() { - if i > 0 { - s.push('│'); - } - lin_arg(e, s, depth); - } - if pair { - s.extend(*end); - } else if invisible { - s.push('〗'); - } else { - s.extend(*end); - s.push('┤'); - } - } - MNode::Func { name, e } => { - lin_arg(name, s, depth); - s.push('\u{2061}'); - lin_operand(e, s, depth); - } - MNode::Lim { upper, e, lim } => { - lin_operand(e, s, depth); - s.push(if *upper { '┴' } else { '┬' }); - lin_script(lim, s, depth); - } - MNode::Acc { chr, e } => { - lin_operand(e, s, depth); - s.push(*chr); - } - MNode::Bar { top, e } => { - s.push(if *top { '¯' } else { '▁' }); - lin_operand(e, s, depth); - } - MNode::BorderBox { e, .. } => { - s.push('▭'); - lin_operand(e, s, depth); - } - MNode::Boxed { e } => { - s.push('□'); - lin_operand(e, s, depth); - } - MNode::GroupChr { chr, e, .. } => { - s.push(*chr); - lin_operand(e, s, depth); - } - // A numbered equation (`E=mc^2#(1)`) is written as its one row. - MNode::EqArr { rows } if rows.len() == 1 && rows.first().is_some_and(|r| has_number_mark(r)) => { - if let Some(r) = rows.first() { - lin_arg(r, s, depth); - } - } - MNode::EqArr { rows } => { - s.push_str("█("); - for (i, r) in rows.iter().enumerate() { - if i > 0 { - s.push('@'); - } - lin_arg(r, s, depth); - } - s.push(')'); - } - MNode::Matrix { rows, .. } => { - s.push_str("■("); - for (i, r) in rows.iter().enumerate() { - if i > 0 { - s.push('@'); - } - for (j, c) in r.iter().enumerate() { - if j > 0 { - s.push('&'); - } - lin_arg(c, s, depth); - } - } - s.push(')'); - } - MNode::Phant { e, .. } => { - s.push('⟡'); - lin_operand(e, s, depth); - } - } -} - -// --------------------------------------------------------------------------------------------- -// Linear format in. - -/// Parse linear-format text into nodes ([`crate::math_linear`]). Never fails: what isn't -/// understood stays as text. -pub fn parse_linear(s: &str) -> Arg { - crate::math_linear::parse(s) -} - -pub use crate::math_linear::FUNCTION_NAMES; - -/// Spacing class of a math character (TeX's atom classes). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum MClass { - #[default] - Ord, - Op, - Bin, - Rel, - Open, - Close, - Punct, - Inner, -} - -/// The spacing class of a character in an equation. -pub fn math_class(c: char) -> MClass { - match c { - '+' | '−' | '-' | '±' | '∓' | '×' | '÷' | '·' | '∙' | '∗' | '*' | '∘' | '⊕' | '⊖' | '⊗' | '⊘' | '⊙' | '⊚' | '⊛' | '∪' | '∩' | '∧' | '∨' - | '∖' | '⋅' | '⋆' | '⊎' | '⊓' | '⊔' | '⋄' | '⊞' | '⊟' | '⊠' | '⊡' | '⋉' | '⋊' | '⋋' | '⋌' | '⋎' | '⋏' | '⋒' | '⋓' | '∔' | '∸' | '≀' | '⨿' - | '⊼' | '⊻' | '⊽' => MClass::Bin, - '=' | '<' | '>' | '≤' | '≥' | '≠' | '≈' | '≡' | '≢' | '∼' | '≃' | '≅' | '≐' | '∝' | '≪' | '≫' | '≺' | '≻' | '≼' | '≽' | '⊂' | '⊃' | '⊆' - | '⊇' | '⊄' | '⊅' | '⊈' | '⊉' | '⊊' | '⊋' | '∈' | '∉' | '∋' | '∌' | '⊥' | '∥' | '∦' | '∣' | '∤' | '≔' | '≕' | '≝' | '≜' | '≞' | '≟' | '→' - | '←' | '↔' | '⇒' | '⇐' | '⇔' | '↦' | '⟼' | '⟶' | '⟵' | '⟷' | '⟹' | '⟸' | '⟺' | '↑' | '↓' | '↕' | '⇑' | '⇓' | '⇕' | '↗' | '↘' | '↙' | '↖' - | '⊢' | '⊣' | '⊨' | '⊩' | '⊪' | '⊬' | '⊭' | '⊮' | '⊯' | '≲' | '≳' | '≍' | '⊏' | '⊐' | '⊑' | '⊒' | '⋢' | '⋣' | '≶' | '≷' | '≮' | '≯' | '≰' - | '≱' | '≦' | '≧' | '⩽' | '⩾' | ':' | '∶' | '∷' | '∺' | '∻' | '≗' | '≙' | '≚' | '≑' | '≒' | '≓' | '≖' | '⇌' | '⇋' | '⇄' | '⇆' | '⇇' | '⇉' - | '⇈' | '⇊' | '↪' | '↩' | '↼' | '↽' | '⇀' | '⇁' | '↿' | '↾' | '⇃' | '⇂' | '↶' | '↷' | '↺' | '↻' | '⊸' | '↜' | '↝' | '↞' | '↠' | '↢' | '↣' - | '↫' | '↬' | '↭' | '⇚' | '⇛' | '↰' | '↱' | '⇝' | '≁' | '≄' | '≉' | '≇' | '≭' | '≨' | '≩' | '⊀' | '⊁' | '⋠' | '⋡' | '⋦' | '⋧' | '⋨' | '⋩' - | '⋪' | '⋫' | '⋬' | '⋭' | '⋈' | '∴' | '∵' | '≏' | '≎' | '⋘' | '⋙' | '⊲' | '⊳' | '⊴' | '⊵' | '⋐' | '⋑' => { - MClass::Rel - } - '(' | '[' | '{' | '⟨' | '⌈' | '⌊' | '⟦' | '〈' => MClass::Open, - ')' | ']' | '}' | '⟩' | '⌉' | '⌋' | '⟧' | '〉' | '!' => MClass::Close, - ',' | ';' => MClass::Punct, - c if is_nary_char(c) => MClass::Op, - _ => MClass::Ord, - } -} - -/// Merge adjacent runs with the same formatting; recurse into every structure. -pub fn merge_runs(a: Arg) -> Arg { - let mut out: Arg = Vec::with_capacity(a.len()); - for n in a { - let n = match n { - MNode::Run(r) => { - if let Some(MNode::Run(prev)) = out.last_mut() - && prev.sty == r.sty - && prev.scr == r.scr - && prev.nor == r.nor - && prev.lit == r.lit - && prev.size == r.size - && prev.color == r.color - && prev.font == r.font - && r.brk.is_none() - { - prev.text.push_str(&r.text); - continue; - } - MNode::Run(r) - } - other => crate::math_linear::map_args(other, &merge_runs), - }; - out.push(n); - } - out -} - -/// Deepest node (a leaf counts 1) the parsers wrap further. Half of [`MAX_DEPTH`]: serde_json -/// stops reading at 128 nested containers and a node costs two (object and array). -pub const WRAP_DEPTH: usize = MAX_DEPTH / 2; - -/// Calls `f` with every argument of `n`. -fn each_arg<'a>(n: &'a MNode, f: &mut impl FnMut(&'a [MNode])) { - match n { - MNode::Run(_) => {} - MNode::Frac { num, den, .. } => { - f(num); - f(den); - } - MNode::Script { base, sub, sup, .. } => { - f(base); - f(sub); - f(sup); - } - MNode::Rad { deg, e, .. } => { - f(deg); - f(e); - } - MNode::Nary { sub, sup, e, .. } => { - f(sub); - f(sup); - f(e); - } - MNode::Delim { elems, .. } => elems.iter().for_each(|a| f(a)), - MNode::Func { name, e } => { - f(name); - f(e); - } - MNode::Lim { e, lim, .. } => { - f(e); - f(lim); - } - MNode::Acc { e, .. } - | MNode::Bar { e, .. } - | MNode::BorderBox { e, .. } - | MNode::Boxed { e } - | MNode::GroupChr { e, .. } - | MNode::Phant { e, .. } => f(e), - MNode::EqArr { rows } => rows.iter().for_each(|a| f(a)), - MNode::Matrix { rows, .. } => rows.iter().flatten().for_each(|a| f(a)), - } -} - -/// `n` is already nested too deeply to be wrapped in another structure. The parsers use this so -/// `x^1^1^1…` stays shallow (a left-nested tree 10 000 deep overflows the stack in `Clone`, -/// `Drop`, `PartialEq` and serde). Iterative, stops at the first too-deep path; costs at most the -/// size of `n`, which the parsers' input caps bound. -pub fn too_deep(n: &MNode) -> bool { - let mut stack: Vec<(&MNode, usize)> = vec![(n, 1)]; - while let Some((n, d)) = stack.pop() { - if d >= WRAP_DEPTH { - return true; - } - each_arg(n, &mut |a| stack.extend(a.iter().map(|c| (c, d + 1)))); - } - false -} - -/// Any of `a` is too deep to be wrapped ([`too_deep`]). -pub fn any_too_deep(a: &[MNode]) -> bool { - a.iter().any(too_deep) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn run(t: &str) -> MNode { - MNode::Run(MRun::new(t)) - } - - #[test] - fn quadratic_formula_parses_into_structures() { - let n = parse_linear("x=(-b±√(b^2-4ac))/2a"); - assert_eq!(n.len(), 2, "{n:#?}"); - assert_eq!(n[0], run("x=")); - let MNode::Frac { num, den, .. } = &n[1] else { panic!("{n:#?}") }; - assert_eq!(den, &vec![run("2a")]); - assert!(num.iter().any(|m| matches!(m, MNode::Rad { .. })), "{num:#?}"); - let MNode::Rad { e, .. } = num.iter().find(|m| matches!(m, MNode::Rad { .. })).unwrap() else { panic!() }; - assert!(matches!(e.first(), Some(MNode::Script { kind: ScriptKind::Sup, .. })), "{e:#?}"); - } - - #[test] - fn scripts_and_nary() { - let n = parse_linear("∑_(i=1)^n▒i^2"); - let MNode::Nary { chr, sub, sup, e, .. } = &n[0] else { panic!("{n:#?}") }; - assert_eq!(*chr, '∑'); - assert_eq!(sub, &vec![run("i=1")]); - assert_eq!(sup, &vec![run("n")]); - assert!(matches!(e.first(), Some(MNode::Script { .. }))); - let n = parse_linear("x_i^2"); - assert!(matches!(&n[0], MNode::Script { kind: ScriptKind::SubSup, .. }), "{n:#?}"); - } - - #[test] - fn functions_and_matrices() { - let n = parse_linear("sin x"); - assert!(matches!(&n[0], MNode::Func { .. }), "{n:#?}"); - let n = parse_linear("■(a&b@c&d)"); - let MNode::Matrix { rows, .. } = &n[0] else { panic!("{n:#?}") }; - assert_eq!(rows.len(), 2); - assert_eq!(rows[0].len(), 2); - } - - #[test] - fn linear_round_trip_keeps_structure() { - for s in [ - "x=(-b±√(b^2-4ac))/2a", - "∑_(i=1)^n▒i", - "a/b+c", - "E=mc^2", - "√(3&x)", - "├]a[┤", - "{█(x@y)┤", - "|x|+‖v‖", - "⏞(a+b)┴k", - "_1^n▒Y", - "sin^(−1)⁡x", - "lim┬(n→∞)⁡a_n", - "A⃗+x̂", - "▭(a)+¯(b)+▁(c)", - "(a│b)", - "E=mc^2#(1)", - ] { - let n = parse_linear(s); - let back = parse_linear(&to_linear(&n)); - assert_eq!(n, back, "{s} → {}", to_linear(&n)); - } - } - - //T1 - /// Deepest nesting of `a` (a leaf counts 1), without recursion. - fn depth_of(a: &[MNode]) -> usize { - let mut best = 0; - let mut stack: Vec<(&MNode, usize)> = a.iter().map(|n| (n, 1)).collect(); - while let Some((n, d)) = stack.pop() { - best = best.max(d); - each_arg(n, &mut |a| stack.extend(a.iter().map(|c| (c, d + 1)))); - } - best - } - - /// Run `f` on a thread with a stack far below the 8 MB of a main thread (#1456). - fn on_small_stack(f: impl FnOnce() + Send + 'static) { - std::thread::Builder::new().stack_size(512 * 1024).spawn(f).unwrap().join().unwrap(); - } - - #[test] - fn deeply_chained_input_stays_shallow() { - on_small_stack(|| { - let cases: Vec<(&str, Arg)> = vec![ - ("x^1^1…", parse_linear(&format!("x{}", "^1".repeat(9999)))), - ("a/a/a…", parse_linear(&format!("a{}", "/a".repeat(9999)))), - ("accents", parse_linear(&format!("x{}", "\u{302}".repeat(9999)))), - ("limits", parse_linear(&format!("x{}", "┬a".repeat(9000)))), - ("(a b)^1^1…", parse_linear(&format!("(a b){}", "^1".repeat(9000)))), - ("latex x^1^1…", crate::math_latex::parse_latex(&format!("x{}", "^1".repeat(9999)))), - ("latex \\sum…", crate::math_latex::parse_latex(&"\\sum".repeat(5000))), - ("latex \\sin x^1…", crate::math_latex::parse_latex(&format!("\\sin x{}", "^1".repeat(9000)))), - ]; - for (name, n) in cases { - assert!(!n.is_empty(), "{name}"); - assert!(depth_of(&n) <= 70, "{name}: depth {}", depth_of(&n)); - let copy = n.clone(); - assert_eq!(copy, n, "{name}"); - let _ = to_linear(&n); - let _ = crate::math_latex::to_latex(&n); - let json = serde_json::to_string(&n).unwrap(); - let back: Arg = serde_json::from_str(&json).unwrap(); - assert_eq!(back, n, "{name}"); - } - }); - } - - #[test] - fn ordinary_nesting_is_still_built() { - // Well below the cap: scripts, fractions and operators still wrap. - let n = parse_linear(&format!("x{}", "^1".repeat(10))); - assert_eq!(depth_of(&n), 11, "{n:#?}"); - let n = crate::math_latex::parse_latex(r"\sum_{i}\sum_{j}a_{ij}"); - assert!(matches!(&n[0], MNode::Nary { e, .. } if matches!(e.first(), Some(MNode::Nary { .. }))), "{n:#?}"); - } - - //T2 - #[test] - fn latex_text_escapes_what_latex_reads_as_markup() { - for text in ["a}b\\c", "{x}", "50% of #1", "a_b&c$d~e^f", "\\", "\\alpha", "\\{"] { - let n = vec![MNode::Run(MRun { text: text.into(), nor: true, sty: Some(MSty::Plain), ..Default::default() })]; - let tex = crate::math_latex::to_latex(&n); - assert_eq!(crate::math_latex::parse_latex(&tex), n, "{text:?} → {tex}"); - } - let n = vec![MNode::Run(MRun::new("a\\b"))]; - assert_eq!(crate::math_latex::parse_latex(&crate::math_latex::to_latex(&n)), n); - } - - //T3 - #[test] - fn latex_runs_merge_inside_every_structure() { - use crate::math_latex::parse_latex; - for (tex, lin) in [(r"\overline{ab}", "¯(ab)"), (r"\underline{ab}", "▁(ab)"), (r"\boxed{ab}", "▭(ab)"), (r"\phantom{ab}", "⟡(ab)")] { - assert_eq!(parse_latex(tex), parse_linear(lin), "{tex}"); - } - let n = parse_latex(r"\begin{matrix}a b&c d\\e f&g\end{matrix}"); - let MNode::Matrix { rows, .. } = &n[0] else { panic!("{n:#?}") }; - assert!(rows.iter().flatten().all(|c| c.len() == 1), "{rows:#?}"); - let n = parse_latex(r"\hat{ab}\underbrace{ab}\underset{ab}{cd}"); - let runs = |a: &Arg| a.iter().filter(|n| matches!(n, MNode::Run(_))).count(); - assert!(matches!(&n[0], MNode::Acc { e, .. } if runs(e) == 1), "{n:#?}"); - assert!(matches!(&n[1], MNode::GroupChr { e, .. } if runs(e) == 1), "{n:#?}"); - assert!(matches!(&n[2], MNode::Lim { e, lim, .. } if runs(e) == 1 && runs(lim) == 1), "{n:#?}"); - } - - #[test] - fn multi_letter_scripts_round_trip() { - // Word for Mac 16.113.4 (#1609): a script is the whole run up to a space or operator, and Word's - // Linear format shows it bare (`V_dd`); the parenthesised form reads back identically. - let script = |kind, sub: &str, sup: &str| MNode::Script { kind, base: vec![run("V")], sub: parse_linear(sub), sup: parse_linear(sup) }; - for n in [ - vec![script(ScriptKind::Sub, "dd", "")], - vec![script(ScriptKind::Sup, "", "ab")], - vec![script(ScriptKind::SubSup, "in", "2")], - vec![MNode::Nary { - chr: '∑', - lim_loc: None, - grow: false, - sub_hide: false, - sup_hide: false, - sub: vec![run("ij")], - sup: vec![run("n")], - e: vec![run("x")], - }], - vec![MNode::Lim { upper: false, e: vec![run("⟶")], lim: vec![run("yields")] }], - ] { - assert_eq!(parse_linear(&to_linear(&n)), n, "{}", to_linear(&n)); - } - // Numbers and single letters stay bare. - assert_eq!(to_linear(&[script(ScriptKind::SubSup, "10", "2.5")]), "V_10^2.5"); - assert_eq!(to_linear(&[script(ScriptKind::Sub, "d", "")]), "V_d"); - assert_eq!(to_linear(&[script(ScriptKind::Sub, "dd", "")]), "V_dd"); - assert_eq!(to_linear(&[script(ScriptKind::Sup, "", "ab")]), "V^ab"); - } - - #[test] - fn multi_letter_script_operands_follow_word() { - // #1609, Word for Mac 16.113.4: `V_dd` and `V_(dd)` build up the same; `dd` is all subscript. - let sub_dd = MNode::Script { kind: ScriptKind::Sub, base: vec![run("V")], sub: vec![run("dd")], sup: vec![] }; - assert_eq!(parse_linear("V_dd"), vec![sub_dd.clone()]); - assert_eq!(parse_linear("V_(dd)"), vec![sub_dd]); - let sup_ab = MNode::Script { kind: ScriptKind::Sup, base: vec![run("x")], sub: vec![], sup: vec![run("ab")] }; - assert_eq!(parse_linear("x^ab"), vec![sup_ab.clone()]); - assert_eq!(parse_linear("x^(ab)"), vec![sup_ab]); - // Delimiters still end the operand. - let both = MNode::Script { kind: ScriptKind::SubSup, base: vec![run("x")], sub: vec![run("i")], sup: vec![run("2")] }; - assert_eq!(parse_linear("x_i^2"), vec![both]); - let a_i = MNode::Script { kind: ScriptKind::Sub, base: vec![run("a")], sub: vec![run("i")], sup: vec![] }; - assert_eq!(parse_linear("a_i b"), vec![a_i, run("b")]); - let both2 = MNode::Script { kind: ScriptKind::SubSup, base: vec![run("x")], sub: vec![run("ij")], sup: vec![run("kl")] }; - assert_eq!(parse_linear("x_ij^kl"), vec![both2]); - // Round trip is Word's bare form. - assert_eq!(to_linear(&parse_linear("V_(dd)")), "V_dd"); - } - - #[test] - fn a_bare_script_does_not_absorb_what_follows() { - // #1609 review: `x_i` then `y` must not read back as x with subscript `iy`. - let sc = - |kind, base: &str, sub: &str, sup: &str| MNode::Script { kind, base: vec![run(base)], sub: parse_linear(sub), sup: parse_linear(sup) }; - let sin_x = MNode::Func { name: vec![MNode::Run(MRun::plain("sin"))], e: vec![run("x")] }; - let lim = MNode::Lim { upper: false, e: vec![run("a")], lim: vec![run("n")] }; - for n in [ - vec![sc(ScriptKind::Sub, "x", "i", ""), run("y")], - vec![sc(ScriptKind::Sub, "a", "i", ""), sc(ScriptKind::Sub, "b", "j", "")], - vec![sc(ScriptKind::Sup, "x", "", "2"), run("y")], - vec![sc(ScriptKind::Sup, "x", "", "2"), run("3")], - vec![sc(ScriptKind::Sub, "x", "i", ""), sin_x], - vec![lim.clone(), run("y")], - vec![sc(ScriptKind::Sub, "x", "ab", ""), run("y")], - vec![sc(ScriptKind::Sub, "x", "i", ""), run(".5")], - ] { - let lin = to_linear(&n); - assert_eq!(parse_linear(&lin), n, "{lin}"); - } - assert_eq!(to_linear(&[sc(ScriptKind::Sub, "x", "i", ""), run("y")]), "x_i y"); - // Nothing extra when the script already ends in a bracket or a symbol follows. - assert_eq!(to_linear(&[sc(ScriptKind::Sub, "x", "i", ""), run("+")]), "x_i+"); - } - - #[test] - fn a_function_name_in_a_script_stays_upright() { - // Word for Mac (#1609): `x_max` and `x_(max)` both give an upright function name. - let up = vec![MNode::Script { kind: ScriptKind::Sub, base: vec![run("x")], sub: vec![MNode::Run(MRun::plain("max"))], sup: vec![] }]; - assert_eq!(parse_linear("x_max"), up); - assert_eq!(parse_linear("x_(max)"), up); - assert_eq!(parse_linear(&to_linear(&up)), up, "{}", to_linear(&up)); - // Other upright text is not a function name: it keeps its parentheses. - let other = vec![MNode::Script { kind: ScriptKind::Sub, base: vec![run("x")], sub: vec![MNode::Run(MRun::plain("ab"))], sup: vec![] }]; - assert_eq!(to_linear(&other), "x_(ab)"); - } - - #[test] - fn digits_then_letters_continue_a_script() { - // Word for Mac (#1609): a script runs to a space or operator, so `x_2a` is subscript "2a". - let sub = |t: &str| vec![MNode::Script { kind: ScriptKind::Sub, base: vec![run("x")], sub: vec![run(t)], sup: vec![] }]; - assert_eq!(parse_linear("x_2a"), sub("2a")); - assert_eq!(parse_linear("x_a2"), sub("a2")); - assert_eq!(parse_linear("x_αβ"), sub("αβ")); - assert_eq!(parse_linear("x_10"), sub("10")); - assert_eq!(parse_linear("x_2.5"), sub("2.5")); - assert_eq!(to_linear(&sub("2a")), "x_2a"); - assert_eq!(parse_linear(&to_linear(&sub("2a"))), sub("2a")); - } - - #[test] - fn hostile_linear_never_panics() { - for s in ["", "/", "^", "_", "√", "∑", "((((", "))))", "■(", "█(@@&&", "a/", "/b", "^^^__", "√(&)", "∫_^▒", "lim_", "sin"] { - let _ = to_linear(&parse_linear(s)); - } - let deep = "(".repeat(5000) + &")".repeat(5000); - let _ = parse_linear(&deep); - let deep = "√".repeat(3000); - let _ = parse_linear(&deep); - } - - #[test] - fn a_long_run_of_letters_parses_in_linear_time() { - // Each letter that doesn't start a function name used to rescan (and copy) the rest of - // the run, so a 20,000-letter equation did 200 million steps. (No wall-clock assert: a - // loaded runner would fail it; a regression shows as a slow test.) - for _ in 0..5 { - let e = parse_linear(&"x".repeat(30_000)); - assert!(matches!(e.as_slice(), [MNode::Run(r)] if r.text.len() == 20_000), "{:?}", e.len()); - } - // Function names are still found at the start of a run, and only as whole runs. - assert!(matches!(parse_linear("sin x").as_slice(), [MNode::Func { .. }])); - assert!(matches!(parse_linear("arcsin x").as_slice(), [MNode::Func { .. }])); - assert!(matches!(parse_linear("sinx").as_slice(), [MNode::Run(_)])); - assert!(matches!(parse_linear("arcsinhx").as_slice(), [MNode::Run(_)])); - } -} diff --git a/crates/wordmodel/src/math_latex.rs b/crates/wordmodel/src/math_latex.rs deleted file mode 100644 index 92979534c..000000000 --- a/crates/wordmodel/src/math_latex.rs +++ /dev/null @@ -1,1242 +0,0 @@ -//! LaTeX ↔ math tree (Word's LaTeX equation input and conversion). -//! -//! Input covers the commands people type in equations: `\frac`, `\sqrt[n]{}`, scripts, big -//! operators with limits, `\left…\middle…\right`, matrix and `cases`/`aligned` environments, -//! accents, `\overbrace`/`\underbrace`, `\text`/`\mathrm`/`\mathbf`/`\mathbb`…, function names, -//! spacing and every named symbol in [`crate::math_symbols`]. Unknown commands stay as text. - -use crate::math::{ - Arg, ColJc, FracKind, MAX_DEPTH, MNode, MRun, MScr, MSty, ScriptKind, WRAP_DEPTH, any_too_deep, is_nary_char, math_class, merge_runs, too_deep, -}; -use crate::math_linear::{FUNCTION_NAMES, map_args}; -use crate::math_symbols::{name_of, symbol}; - -#[derive(Clone, Debug, PartialEq)] -enum Tok { - Cmd(String), - Open, - Close, - Sup, - Sub, - Amp, - Row, - Ch(char), -} - -/// Commands whose `{…}` argument is text, where spaces are part of the content. -const TEXT_COMMANDS: &[&str] = &["text", "textrm", "mbox", "textnormal", "textbf", "textit"]; - -fn tokenize(s: &str) -> Vec { - let cs: Vec = s.chars().take(20_000).collect(); - let mut out = Vec::new(); - let mut i = 0; - // A text command was just read: its `{` starts text, where spaces count (#448). - let mut text_next = false; - // Brace depth inside a text argument. - let mut text_depth: Option = None; - while let Some(&c) = cs.get(i) { - i += 1; - if text_depth.is_some() && matches!(c, ' ' | '\t' | '\n' | '\r') { - // A run of white space is one space, as in LaTeX. - if out.last() != Some(&Tok::Ch(' ')) { - out.push(Tok::Ch(' ')); - } - continue; - } - let was_text_next = std::mem::take(&mut text_next); - match c { - '\\' => { - let start = i; - while cs.get(i).is_some_and(char::is_ascii_alphabetic) { - i += 1; - } - if i == start { - match cs.get(i) { - Some('\\') => { - i += 1; - out.push(Tok::Row); - } - Some(&c) => { - i += 1; - out.push(Tok::Cmd(c.to_string())); - } - None => {} - } - } else { - let name: String = cs.get(start..i).unwrap_or(&[]).iter().collect(); - text_next = text_depth.is_none() && TEXT_COMMANDS.contains(&name.as_str()); - out.push(Tok::Cmd(name)); - } - } - '{' => { - text_depth = match text_depth { - Some(d) => Some(d.saturating_add(1)), - None if was_text_next => Some(0), - None => None, - }; - out.push(Tok::Open); - } - '}' => { - text_depth = match text_depth { - Some(0) | None => None, - Some(d) => Some(d.saturating_sub(1)), - }; - out.push(Tok::Close); - } - '^' => out.push(Tok::Sup), - '_' => out.push(Tok::Sub), - '&' => out.push(Tok::Amp), - // White space between a text command and its `{` doesn't end the wait. - ' ' | '\t' | '\n' | '\r' => text_next = was_text_next, - '~' => out.push(Tok::Ch('\u{A0}')), - c => out.push(Tok::Ch(c)), - } - } - out -} - -/// Parse LaTeX math into nodes. Never fails. -pub fn parse_latex(s: &str) -> Arg { - let mut p = Lp { t: tokenize(s), i: 0, depth: 0 }; - let mut out = Vec::new(); - while p.i < p.t.len() { - out.extend(p.seq(false)); - // A stray `}` or `&`/`\\` at the top level. - match p.t.get(p.i) { - Some(Tok::Amp) => out.push(run("&")), - Some(Tok::Row) => {} - Some(Tok::Close) => {} - _ => {} - } - p.i += 1; - } - let out = merge_runs(out); - if out.iter().any(|n| matches!(n, MNode::Run(r) if !r.nor && r.text.contains('#'))) { - return vec![MNode::EqArr { rows: vec![out] }]; - } - out -} - -fn run(t: &str) -> MNode { - MNode::Run(MRun::new(t)) -} - -struct Lp { - t: Vec, - i: usize, - depth: usize, -} - -impl Lp { - fn peek(&self) -> Option<&Tok> { - self.t.get(self.i) - } - - /// A sequence up to `}` (in a group), `&`, `\\`, `\right`, `\middle` or `\end`. - fn seq(&mut self, in_group: bool) -> Arg { - self.depth += 1; - let mut out: Arg = Vec::new(); - if self.depth > MAX_DEPTH { - self.i = self.t.len(); - self.depth -= 1; - return out; - } - while let Some(tok) = self.peek().cloned() { - match tok { - Tok::Close if in_group => break, - Tok::Close | Tok::Amp | Tok::Row => break, - Tok::Cmd(ref c) if matches!(c.as_str(), "right" | "middle" | "end") => break, - Tok::Sup | Tok::Sub => { - self.i += 1; - let s = self.arg(); - if out.last().is_some_and(too_deep) { - // Too deep to wrap (`x^1^1^1…`): the operator is dropped, the script kept. - out.extend(s); - } else { - let base = out.pop(); - out.push(script(base, tok == Tok::Sup, s)); - } - } - Tok::Cmd(ref c) if c == "limits" || c == "nolimits" || c == "displaystyle" || c == "textstyle" => { - self.i += 1; - if c == "nolimits" - && let Some(MNode::Nary { lim_loc, .. }) = out.last_mut() - { - *lim_loc = Some(crate::math::LimLoc::SubSup); - } - if c == "limits" - && let Some(MNode::Nary { lim_loc, .. }) = out.last_mut() - { - *lim_loc = Some(crate::math::LimLoc::UndOvr); - } - } - _ => { - let a = self.atom(); - out.extend(a); - } - } - } - self.depth -= 1; - // Big operators take the following term as their operand. - nary_operands(out, self.depth) - } - - /// A command argument: a `{group}` or one token. - fn arg(&mut self) -> Arg { - match self.peek() { - Some(Tok::Open) => { - self.i += 1; - let g = self.seq(true); - if self.peek() == Some(&Tok::Close) { - self.i += 1; - } - g - } - Some(_) => self.atom(), - None => Vec::new(), - } - } - - /// `[optional]` argument. - fn opt(&mut self) -> Option { - if self.peek() != Some(&Tok::Ch('[')) { - return None; - } - self.i += 1; - let mut toks = Vec::new(); - let mut level = 0i32; - while let Some(t) = self.peek().cloned() { - self.i += 1; - match t { - Tok::Ch(']') if level == 0 => break, - Tok::Open => level += 1, - Tok::Close => level -= 1, - _ => {} - } - toks.push(t); - } - let mut p = Lp { t: toks, i: 0, depth: self.depth }; - Some(p.seq(false)) - } - - /// The text of a `{…}` argument, verbatim. - fn raw_arg(&mut self) -> String { - let mut s = String::new(); - if self.peek() != Some(&Tok::Open) { - if let Some(Tok::Ch(c)) = self.peek().cloned() { - self.i += 1; - s.push(c); - } - return s; - } - self.i += 1; - let mut level = 0i32; - // The previous token was a control word (`\backslash{}`: the empty braces end its name). - let mut after_word = false; - while let Some(t) = self.peek().cloned() { - self.i += 1; - let was_word = std::mem::replace(&mut after_word, matches!(&t, Tok::Cmd(c) if c.chars().all(|c| c.is_ascii_alphabetic()))); - match t { - Tok::Close if level == 0 => break, - Tok::Open if was_word && self.peek() == Some(&Tok::Close) => self.i += 1, - Tok::Open => { - level += 1; - s.push('{'); - } - Tok::Close => { - level -= 1; - s.push('}'); - } - Tok::Ch(c) => s.push(c), - Tok::Cmd(c) => { - if c.chars().all(|c| c.is_ascii_alphabetic()) { - match symbol(&c) { - Some(sym) => s.push(sym), - None => s.push_str(&c), - } - } else { - s.push_str(&c); - } - if c == " " { - s.push(' '); - } - } - Tok::Sup => s.push('^'), - Tok::Sub => s.push('_'), - Tok::Amp => s.push('&'), - Tok::Row => s.push('\n'), - } - } - s - } - - /// A delimiter after `\left`, `\right`, `\middle`, `\big…`. - fn delim(&mut self) -> Option { - let t = self.peek().cloned()?; - self.i += 1; - match t { - Tok::Ch('.') => None, - Tok::Ch(c) => Some(c), - Tok::Cmd(c) => match c.as_str() { - "{" => Some('{'), - "}" => Some('}'), - "|" => Some('‖'), - other => symbol(other), - }, - _ => None, - } - } - - fn atom(&mut self) -> Arg { - if self.depth > MAX_DEPTH { - self.i = self.t.len(); - return Vec::new(); - } - self.depth += 1; - let out = self.atom_inner(); - self.depth -= 1; - out - } - - fn atom_inner(&mut self) -> Arg { - let Some(t) = self.peek().cloned() else { return Vec::new() }; - self.i += 1; - match t { - Tok::Open => { - let g = self.seq(true); - if self.peek() == Some(&Tok::Close) { - self.i += 1; - } - g - } - Tok::Ch(c) => vec![run(&c.to_string())], - Tok::Close | Tok::Amp | Tok::Row | Tok::Sup | Tok::Sub => Vec::new(), - Tok::Cmd(c) => self.command(&c), - } - } - - fn command(&mut self, c: &str) -> Arg { - let styled = |a: Arg, sty: Option, scr: MScr, nor: bool| -> Arg { restyle(a, sty, scr, nor) }; - let one = |n: MNode| vec![n]; - match c { - "frac" | "dfrac" | "tfrac" | "cfrac" => { - let num = self.arg(); - let den = self.arg(); - one(MNode::Frac { kind: FracKind::Bar, num, den }) - } - "genfrac" => { - let beg = delim_arg(&self.arg()); - let end = delim_arg(&self.arg()); - let thickness = self.arg(); - let _style = self.arg(); - let num = self.arg(); - let den = self.arg(); - let kind = if is_zero_length(&thickness) { FracKind::NoBar } else { FracKind::Bar }; - let frac = MNode::Frac { kind, num, den }; - if beg.is_none() && end.is_none() { - one(frac) - } else { - one(MNode::Delim { beg, end, sep: None, grow: true, shp_match: false, elems: vec![vec![frac]] }) - } - } - "binom" | "dbinom" | "tbinom" => { - let n = self.arg(); - let k = self.arg(); - one(MNode::Delim { - beg: Some('('), - end: Some(')'), - sep: None, - grow: true, - shp_match: false, - elems: vec![vec![MNode::Frac { kind: FracKind::NoBar, num: n, den: k }]], - }) - } - "sqrt" => { - let deg = self.opt().unwrap_or_default(); - let e = self.arg(); - one(MNode::Rad { deg_hide: deg.is_empty(), deg, e }) - } - "left" => { - let beg = self.delim(); - let mut elems = vec![self.seq(false)]; - let mut sep = None; - let mut end = None; - while let Some(Tok::Cmd(k)) = self.peek().cloned() { - self.i += 1; - if k == "middle" { - sep = self.delim().or(Some('|')); - elems.push(self.seq(false)); - } else { - if k == "right" { - end = self.delim(); - } - break; - } - } - one(MNode::Delim { beg, end, sep, grow: true, shp_match: false, elems }) - } - "bigl" | "Bigl" | "biggl" | "Biggl" | "bigr" | "Bigr" | "biggr" | "Biggr" | "big" | "Big" | "bigg" | "Bigg" => { - self.delim().map(|d| vec![run(&d.to_string())]).unwrap_or_default() - } - "begin" => { - let env = self.raw_arg(); - self.environment(env.trim()) - } - "text" | "textrm" | "mbox" | "textnormal" => { - let t = self.raw_arg(); - one(MNode::Run(MRun { text: t, nor: true, sty: Some(MSty::Plain), ..Default::default() })) - } - "textbf" => { - let t = self.raw_arg(); - one(MNode::Run(MRun { text: t, nor: true, sty: Some(MSty::Bold), ..Default::default() })) - } - "textit" => { - let t = self.raw_arg(); - one(MNode::Run(MRun { text: t, nor: true, sty: Some(MSty::Italic), ..Default::default() })) - } - "operatorname" => { - let starred = self.peek() == Some(&Tok::Ch('*')); - if starred { - self.i += 1; - } - let t = self.raw_arg(); - let name = vec![MNode::Run(MRun::plain(t))]; - if starred && self.peek() == Some(&Tok::Sub) { - self.i += 1; - let lim = self.arg(); - vec![MNode::Lim { upper: false, e: name, lim }] - } else { - self.apply(name) - } - } - "tag" => { - let starred = self.peek() == Some(&Tok::Ch('*')); - if starred { - self.i += 1; - } - let t = self.raw_arg(); - let num_str = if starred { format!("#{t}") } else { format!("# ({t})") }; - let mut r = MRun::new(num_str); - r.lit = false; - vec![MNode::Run(r)] - } - "mathrm" | "rm" => { - let a = self.arg(); - styled(a, Some(MSty::Plain), MScr::Roman, false) - } - "mathbf" | "bf" => { - let a = self.arg(); - styled(a, Some(MSty::Bold), MScr::Roman, false) - } - "mathit" => { - let a = self.arg(); - styled(a, Some(MSty::Italic), MScr::Roman, false) - } - "boldsymbol" | "bm" => { - let a = self.arg(); - styled(a, Some(MSty::BoldItalic), MScr::Roman, false) - } - "mathbb" => { - let a = self.arg(); - styled(a, None, MScr::DoubleStruck, false) - } - "mathcal" | "mathscr" => { - let a = self.arg(); - styled(a, None, MScr::Script, false) - } - "mathfrak" => { - let a = self.arg(); - styled(a, None, MScr::Fraktur, false) - } - "mathsf" => { - let a = self.arg(); - styled(a, None, MScr::SansSerif, false) - } - "mathtt" => { - let a = self.arg(); - styled(a, None, MScr::Monospace, false) - } - "overline" => one(MNode::Bar { top: true, e: self.arg() }), - "underline" => one(MNode::Bar { top: false, e: self.arg() }), - "boxed" | "fbox" => one(MNode::BorderBox { hide: [false; 4], strike: [false; 4], e: self.arg() }), - "phantom" => one(MNode::Phant { show: false, zero_wid: false, zero_asc: false, zero_desc: false, e: self.arg() }), - "overbrace" | "underbrace" | "overparen" | "underparen" | "overbracket" | "underbracket" => { - let top = c.starts_with("over"); - let chr = match c { - "overbrace" => '⏞', - "underbrace" => '⏟', - "overparen" => '⏜', - "underparen" => '⏝', - "overbracket" => '⎴', - _ => '⎵', - }; - let e = self.arg(); - let g = MNode::GroupChr { chr, top, e }; - let want = if top { Tok::Sup } else { Tok::Sub }; - if self.peek() == Some(&want) { - self.i += 1; - let lim = self.arg(); - one(MNode::Lim { upper: top, e: vec![g], lim }) - } else { - one(g) - } - } - "stackrel" | "overset" => { - let over = self.arg(); - let base = self.arg(); - one(MNode::Lim { upper: true, e: base, lim: over }) - } - "underset" => { - let under = self.arg(); - let base = self.arg(); - one(MNode::Lim { upper: false, e: base, lim: under }) - } - "hat" | "widehat" | "tilde" | "widetilde" | "dot" | "ddot" | "dddot" | "check" | "breve" | "acute" | "grave" | "vec" | "bar" - | "overrightarrow" | "overleftarrow" | "overleftrightarrow" | "mathring" => { - let chr = match c { - "hat" | "widehat" => '\u{302}', - "tilde" | "widetilde" => '\u{303}', - "dot" => '\u{307}', - "ddot" => '\u{308}', - "dddot" => '\u{20DB}', - "check" => '\u{30C}', - "breve" => '\u{306}', - "acute" => '\u{301}', - "grave" => '\u{300}', - "bar" => '\u{305}', - "overleftarrow" => '\u{20D6}', - "overleftrightarrow" => '\u{20E1}', - "mathring" => '\u{30A}', - _ => '\u{20D7}', - }; - one(MNode::Acc { chr, e: self.arg() }) - } - "," | "thinspace" => vec![run("\u{2009}")], - ":" | ">" | "medspace" => vec![run("\u{205F}")], - ";" | "thickspace" => vec![run("\u{2005}")], - "!" | "negthinspace" => Vec::new(), - " " => vec![run(" ")], - "qquad" => vec![run("\u{2003}\u{2003}")], - "{" | "}" | "%" | "$" | "#" | "&" | "_" => { - // Escaped characters (a literal `#` must not number the equation). - let mut r = MRun::new(c); - r.lit = c == "#"; - vec![MNode::Run(r)] - } - "|" => vec![run("‖")], - name if FUNCTION_NAMES.contains(&name) => { - let plain = vec![MNode::Run(MRun::plain(name))]; - let limits = matches!(name, "lim" | "liminf" | "limsup" | "max" | "min" | "sup" | "inf" | "det" | "gcd" | "Pr"); - let named = if limits && self.peek() == Some(&Tok::Sub) { - self.i += 1; - let lim = self.arg(); - vec![MNode::Lim { upper: false, e: plain, lim }] - } else if matches!(self.peek(), Some(Tok::Sup) | Some(Tok::Sub)) { - let sup = self.peek() == Some(&Tok::Sup); - self.i += 1; - let s = self.arg(); - vec![script(Some(MNode::Run(MRun::plain(name))), sup, s)] - } else { - plain - }; - self.apply(named) - } - name => match symbol(name) { - Some(ch) if is_nary_char(ch) => vec![MNode::Nary { - chr: ch, - lim_loc: None, - grow: false, - sub_hide: true, - sup_hide: true, - sub: Vec::new(), - sup: Vec::new(), - e: Vec::new(), - }], - Some(ch) => vec![run(&ch.to_string())], - None => vec![run(&format!("\\{name}"))], - }, - } - } - - /// A function name applied to the next atom (with its scripts). - fn apply(&mut self, name: Arg) -> Arg { - let mut e = self.atom(); - while let Some(t @ (Tok::Sup | Tok::Sub)) = self.peek().cloned() { - self.i += 1; - let s = self.arg(); - if any_too_deep(&e) { - e.extend(s); - continue; - } - let base = if e.len() == 1 { e.pop() } else { Some(group(std::mem::take(&mut e))) }; - e.push(script(base, t == Tok::Sup, s)); - } - if e.is_empty() { - return name; - } - vec![MNode::Func { name, e }] - } - - fn environment(&mut self, env: &str) -> Arg { - let base = env.trim_end_matches('*'); - let mut col_jc = Vec::new(); - if base == "array" || base == "tabular" { - let _ = self.opt(); - let spec = self.raw_arg(); - for ch in spec.chars() { - match ch { - 'l' | 'L' => col_jc.push(ColJc::Left), - 'r' | 'R' => col_jc.push(ColJc::Right), - 'c' | 'C' => col_jc.push(ColJc::Center), - _ => {} - } - } - } else if base == "aligned" { - let _ = self.opt(); - } - // Rows of `&`-separated cells up to `\end{…}`. - let mut rows: Vec> = vec![vec![]]; - loop { - let cell = self.seq(false); - if let Some(r) = rows.last_mut() { - r.push(cell); - } - match self.peek().cloned() { - Some(Tok::Amp) => self.i += 1, - Some(Tok::Row) => { - self.i += 1; - rows.push(vec![]); - } - Some(Tok::Cmd(k)) if k == "end" => { - self.i += 1; - let _ = self.raw_arg(); - break; - } - Some(Tok::Close) => self.i += 1, - Some(_) => self.i += 1, - None => break, - } - } - // A trailing `\\` leaves an empty last row. - if rows.len() > 1 && rows.last().is_some_and(|r| r.iter().all(|c| c.is_empty())) { - rows.pop(); - } - let delims = match base { - "pmatrix" => Some((Some('('), Some(')'))), - "bmatrix" => Some((Some('['), Some(']'))), - "Bmatrix" => Some((Some('{'), Some('}'))), - "vmatrix" => Some((Some('|'), Some('|'))), - "Vmatrix" => Some((Some('‖'), Some('‖'))), - _ => None, - }; - let wrap = |inner: MNode, beg: Option, end: Option| MNode::Delim { - beg, - end, - sep: None, - grow: true, - shp_match: false, - elems: vec![vec![inner]], - }; - match base { - "matrix" | "pmatrix" | "bmatrix" | "Bmatrix" | "vmatrix" | "Vmatrix" | "smallmatrix" | "array" | "tabular" => { - let m = MNode::Matrix { rows, col_jc }; - match delims { - Some((b, e)) => vec![wrap(m, b, e)], - None => vec![m], - } - } - "cases" | "dcases" => { - let eq = MNode::EqArr { rows: rows.into_iter().map(join_cells).collect() }; - vec![wrap(eq, Some('{'), None)] - } - "rcases" => { - let eq = MNode::EqArr { rows: rows.into_iter().map(join_cells).collect() }; - vec![wrap(eq, None, Some('}'))] - } - _ => vec![MNode::EqArr { rows: rows.into_iter().map(join_cells).collect() }], - } - } -} - -/// Cells of an aligned row joined with `&` alignment marks. -fn join_cells(cells: Vec) -> Arg { - let mut out = Vec::new(); - for (i, c) in cells.into_iter().enumerate() { - if i > 0 { - out.push(run("&")); - } - out.extend(c); - } - merge_runs(out) -} - -fn group(a: Arg) -> MNode { - MNode::Delim { beg: None, end: None, sep: None, grow: true, shp_match: false, elems: vec![a] } -} - -fn script(base: Option, sup: bool, s: Arg) -> MNode { - match base { - Some(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup: sp, e }) if e.is_empty() => { - if sup { - MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide: false, sub, sup: s, e } - } else { - MNode::Nary { chr, lim_loc, grow, sub_hide: false, sup_hide, sub: s, sup: sp, e } - } - } - Some(MNode::Script { kind: ScriptKind::Sub, base, sub, .. }) if sup => MNode::Script { kind: ScriptKind::SubSup, base, sub, sup: s }, - Some(MNode::Script { kind: ScriptKind::Sup, base, sup: sp, .. }) if !sup => MNode::Script { kind: ScriptKind::SubSup, base, sub: s, sup: sp }, - b => { - let base = b.map(|b| vec![b]).unwrap_or_default(); - if sup { - MNode::Script { kind: ScriptKind::Sup, base, sub: Vec::new(), sup: s } - } else { - MNode::Script { kind: ScriptKind::Sub, base, sub: s, sup: Vec::new() } - } - } - } -} - -/// Big operators without an operand take the atoms after them up to a relation or binary operator. -/// `depth` is how deep the operators already sit; past [`WRAP_DEPTH`] they stay without operand -/// (`\sum\sum\sum…` must not nest once per operator). -fn nary_operands(a: Arg, depth: usize) -> Arg { - let mut out: Arg = Vec::with_capacity(a.len()); - let mut it = a.into_iter().peekable(); - while let Some(n) = it.next() { - match n { - MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } if e.is_empty() && depth < WRAP_DEPTH => { - let mut body = Vec::new(); - while let Some(next) = it.peek() { - let stop = match next { - MNode::Run(r) => r.text.chars().next().is_some_and(|c| { - matches!(math_class(c), crate::math::MClass::Rel | crate::math::MClass::Bin | crate::math::MClass::Punct) || c == '&' - }), - _ => false, - }; - if stop { - break; - } - if let Some(x) = it.next() { - body.push(x); - } - } - let body = merge_runs(nary_operands(body, depth + 1)); - if any_too_deep(&body) { - // No room to wrap the operand: it follows the operator. - out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e }); - out.extend(body); - } else { - out.push(MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e: body }); - } - } - other => out.push(other), - } - } - out -} - -/// Set a style on every run inside. -fn restyle(a: Arg, sty: Option, scr: MScr, nor: bool) -> Arg { - a.into_iter() - .map(|n| match n { - MNode::Run(mut r) => { - if sty.is_some() { - r.sty = sty; - } - if scr != MScr::Roman { - r.scr = scr; - } - r.nor |= nor; - MNode::Run(r) - } - other => map_args(other, &|a| restyle(a, sty, scr, nor)), - }) - .collect() -} - -// --------------------------------------------------------------------------------------------- -// Out. - -/// The equation as LaTeX. -pub fn to_latex(nodes: &[MNode]) -> String { - let mut s = String::new(); - arg(nodes, &mut s, 0); - s.trim().to_string() -} - -fn arg(a: &[MNode], s: &mut String, depth: usize) { - if depth > MAX_DEPTH { - return; - } - for n in a { - node(n, s, depth + 1); - } -} - -fn braced(a: &[MNode], s: &mut String, depth: usize) { - s.push('{'); - arg(a, s, depth); - s.push('}'); -} - -fn chars(text: &str, s: &mut String) { - for c in text.chars() { - match c { - '{' | '}' | '%' | '$' | '#' | '&' | '_' => { - s.push('\\'); - s.push(c); - } - '\\' => s.push_str("\\backslash "), - '−' => s.push('-'), - c if c.is_ascii() => s.push(c), - c => match name_of(c) { - Some(n) if !matches!(n, "sqrt" | "matrix" | "of" | "above" | "below" | "box" | "rect") => { - s.push('\\'); - s.push_str(n); - s.push(' '); - } - _ => s.push(c), - }, - } - } -} - -/// Text inside `\text{…}`: only what LaTeX would read as markup is escaped. -fn text_chars(text: &str, s: &mut String) { - for c in text.chars() { - match c { - '{' | '}' | '%' | '$' | '#' | '&' | '_' | '~' => { - s.push('\\'); - s.push(c); - } - // `{}` ends the command name without adding a space (spaces count in text). - '\\' => s.push_str("\\backslash{}"), - c => s.push(c), - } - } -} - -/// The delimiter of a `\genfrac` argument: one character, or none for `{}` and anything else. -fn delim_arg(a: &[MNode]) -> Option { - let [MNode::Run(r)] = a else { return None }; - let mut cs = r.text.chars(); - match (cs.next(), cs.next()) { - (Some(c), None) if c != '.' => Some(c), - _ => None, - } -} - -/// Whether a `\genfrac` thickness is a zero length such as `0pt`, `0.0pt` or `0 em`. An empty -/// thickness means the default rule and anything that is not a plain number and unit is not zero. -fn is_zero_length(a: &[MNode]) -> bool { - let mut text = String::new(); - for n in a { - let MNode::Run(r) = n else { return false }; - text.extend(r.text.chars().filter(|c| !c.is_whitespace() && !matches!(c, '\u{2009}' | '\u{205F}' | '\u{2005}'))); - } - let number = text.trim_end_matches(|c: char| c.is_ascii_alphabetic()); - let unit = &text[number.len()..]; - !number.is_empty() - && matches!(unit, "pt" | "em" | "ex" | "mm" | "cm" | "in" | "bp" | "pc" | "sp" | "mu") - && number.parse::().is_ok_and(|v| v == 0.0) -} - -fn delim_tex(c: Option) -> String { - match c { - None => ".".into(), - Some('{') => "\\{".into(), - Some('}') => "\\}".into(), - Some('‖') => "\\|".into(), - Some(c) if c.is_ascii() => c.to_string(), - Some(c) => name_of(c).map(|n| format!("\\{n} ")).unwrap_or_else(|| c.to_string()), - } -} - -fn group_chr_tex(chr: char, top: bool) -> &'static str { - match chr { - '⏜' => "\\overparen", - '⏝' => "\\underparen", - '⎴' => "\\overbracket", - '⎵' => "\\underbracket", - _ if top => "\\overbrace", - _ => "\\underbrace", - } -} - -fn node(node: &MNode, output: &mut String, depth: usize) { - match node { - MNode::Run(run) => run_node(run, output), - MNode::Frac { kind, num, den } => fraction_node(*kind, num, den, output, depth), - MNode::Script { kind, base, sub, sup } => script_node(*kind, base, sub, sup, output, depth), - MNode::Rad { deg, deg_hide, e } => radical_node(deg, *deg_hide, e, output, depth), - MNode::Nary { .. } => nary_node(node, output, depth), - MNode::Delim { beg, end, sep, elems, .. } => delimiter_node(*beg, *end, *sep, elems, output, depth), - MNode::Func { name, e } => { - arg(name, output, depth); - braced(e, output, depth); - } - MNode::Lim { upper, e, lim } => limit_node(*upper, e, lim, output, depth), - MNode::Acc { chr, e } => accent_node(*chr, e, output, depth), - MNode::Bar { top, e } => { - output.push_str(if *top { "\\overline" } else { "\\underline" }); - braced(e, output, depth); - } - MNode::BorderBox { e, .. } => { - output.push_str("\\boxed"); - braced(e, output, depth); - } - MNode::Boxed { e } => braced(e, output, depth), - MNode::GroupChr { chr, top, e } => { - output.push_str(group_chr_tex(*chr, *top)); - braced(e, output, depth); - } - MNode::EqArr { rows } => equation_array_node(rows, output, depth), - MNode::Matrix { rows, .. } => matrix_node(rows, output, depth), - MNode::Phant { e, .. } => { - output.push_str("\\phantom"); - braced(e, output, depth); - } - } -} - -fn run_node(run: &MRun, output: &mut String) { - let (open, close) = if run.nor { - ("\\text{", "}") - } else { - match (run.sty, run.scr) { - (_, MScr::DoubleStruck) => ("\\mathbb{", "}"), - (_, MScr::Script) => ("\\mathcal{", "}"), - (_, MScr::Fraktur) => ("\\mathfrak{", "}"), - (_, MScr::SansSerif) => ("\\mathsf{", "}"), - (_, MScr::Monospace) => ("\\mathtt{", "}"), - (Some(MSty::Bold), _) => ("\\mathbf{", "}"), - (Some(MSty::BoldItalic), _) => ("\\boldsymbol{", "}"), - (Some(MSty::Plain), _) if run.text.chars().any(char::is_alphabetic) => { - if FUNCTION_NAMES.contains(&run.text.as_str()) { - output.push('\\'); - output.push_str(&run.text); - output.push(' '); - return; - } - ("\\mathrm{", "}") - } - _ => ("", ""), - } - }; - output.push_str(open); - if run.nor { - text_chars(&run.text, output) - } else { - chars(&run.text, output) - } - output.push_str(close); -} - -fn fraction_node(kind: FracKind, numerator: &[MNode], denominator: &[MNode], output: &mut String, depth: usize) { - match kind { - FracKind::NoBar => { - output.push_str("\\genfrac{}{}{0pt}{}"); - braced(numerator, output, depth); - braced(denominator, output, depth); - } - FracKind::Linear | FracKind::Skewed => { - braced(numerator, output, depth); - output.push('/'); - braced(denominator, output, depth); - } - FracKind::Bar => { - output.push_str("\\frac"); - braced(numerator, output, depth); - braced(denominator, output, depth); - } - } -} - -fn script_node(kind: ScriptKind, base: &[MNode], sub: &[MNode], sup: &[MNode], output: &mut String, depth: usize) { - if kind == ScriptKind::Pre { - output.push_str("{}_"); - braced(sub, output, depth); - output.push('^'); - braced(sup, output, depth); - braced(base, output, depth); - return; - } - braced(base, output, depth); - if matches!(kind, ScriptKind::Sub | ScriptKind::SubSup) { - output.push('_'); - braced(sub, output, depth); - } - if matches!(kind, ScriptKind::Sup | ScriptKind::SubSup) { - output.push('^'); - braced(sup, output, depth); - } -} - -fn radical_node(degree: &[MNode], degree_hidden: bool, expression: &[MNode], output: &mut String, depth: usize) { - output.push_str("\\sqrt"); - if !degree_hidden && !degree.is_empty() { - output.push('['); - arg(degree, output, depth); - output.push(']'); - } - braced(expression, output, depth); -} - -fn nary_node(node: &MNode, output: &mut String, depth: usize) { - let MNode::Nary { chr, lim_loc, sub_hide, sup_hide, sub, sup, e, .. } = node else { return }; - match name_of(*chr) { - Some(name) => { - output.push('\\'); - output.push_str(name); - } - None => output.push(*chr), - } - match lim_loc { - Some(crate::math::LimLoc::UndOvr) => output.push_str("\\limits"), - Some(crate::math::LimLoc::SubSup) if !crate::math::is_integral(*chr) => output.push_str("\\nolimits"), - _ => {} - } - if !sub_hide && !sub.is_empty() { - output.push('_'); - braced(sub, output, depth); - } - if !sup_hide && !sup.is_empty() { - output.push('^'); - braced(sup, output, depth); - } - output.push(' '); - braced(e, output, depth); -} - -fn delimiter_node(begin: Option, end: Option, separator: Option, elements: &[Arg], output: &mut String, depth: usize) { - output.push_str("\\left"); - output.push_str(&delim_tex(begin)); - let middle = separator.map(|c| format!("\\middle{}", delim_tex(Some(c)))).unwrap_or_else(|| "\\middle|".to_string()); - for (index, element) in elements.iter().enumerate() { - if index > 0 { - output.push_str(&middle); - } - arg(element, output, depth); - } - output.push_str("\\right"); - output.push_str(&delim_tex(end)); -} - -fn limit_node(upper: bool, expression: &[MNode], limit: &[MNode], output: &mut String, depth: usize) { - let simple = matches!(expression, [MNode::Run(run)] if run.sty == Some(MSty::Plain) && FUNCTION_NAMES.contains(&run.text.as_str())); - if simple && !upper { - arg(expression, output, depth); - output.push('_'); - braced(limit, output, depth); - } else if let [MNode::GroupChr { chr, top, e: inner }] = expression { - output.push_str(group_chr_tex(*chr, *top)); - braced(inner, output, depth); - output.push(if upper { '^' } else { '_' }); - braced(limit, output, depth); - } else { - output.push_str(if upper { "\\overset" } else { "\\underset" }); - braced(limit, output, depth); - braced(expression, output, depth); - } -} - -fn accent_node(character: char, expression: &[MNode], output: &mut String, depth: usize) { - let command = match character { - '\u{302}' => "hat", - '\u{303}' => "tilde", - '\u{307}' => "dot", - '\u{308}' => "ddot", - '\u{20DB}' => "dddot", - '\u{30C}' => "check", - '\u{306}' => "breve", - '\u{301}' => "acute", - '\u{300}' => "grave", - '\u{305}' | '\u{AF}' => "bar", - '\u{20D6}' => "overleftarrow", - '\u{20E1}' => "overleftrightarrow", - _ => "vec", - }; - output.push('\\'); - output.push_str(command); - braced(expression, output, depth); -} - -fn equation_array_node(rows: &[Arg], output: &mut String, depth: usize) { - if rows.len() == 1 - && let Some(row) = rows.first() - && crate::math::has_number_mark(row) - { - arg(row, output, depth); - return; - } - output.push_str("\\begin{aligned}"); - for (index, row) in rows.iter().enumerate() { - if index > 0 { - output.push_str("\\\\"); - } - for child_node in row { - match child_node { - MNode::Run(run) if !run.lit && run.text.contains('&') => { - for (index, part) in run.text.split('&').enumerate() { - if index > 0 { - output.push('&'); - } - let mut piece = run.clone(); - piece.text = part.to_string(); - run_node(&piece, output); - } - } - other => node(other, output, depth + 1), - } - } - } - output.push_str("\\end{aligned}"); -} - -fn matrix_node(rows: &[Vec], output: &mut String, depth: usize) { - output.push_str("\\begin{matrix}"); - for (row_index, row) in rows.iter().enumerate() { - if row_index > 0 { - output.push_str("\\\\"); - } - for (column, cell) in row.iter().enumerate() { - if column > 0 { - output.push('&'); - } - arg(cell, output, depth); - } - } - output.push_str("\\end{matrix}"); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::math::{LimLoc, MathJc}; - - #[test] - fn latex_structures() { - let n = parse_latex(r"x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}"); - assert_eq!(crate::math::to_linear(&n), "x=(-b±√(b^2-4ac))/2a"); - let n = parse_latex(r"\sum_{i=1}^{n} i^2 = \frac{n(n+1)(2n+1)}{6}"); - let MNode::Nary { chr, sub, sup, e, .. } = &n[0] else { panic!("{n:#?}") }; - assert_eq!((*chr, sub.len(), sup.len()), ('∑', 1, 1)); - assert!(matches!(e.first(), Some(MNode::Script { .. })), "{e:#?}"); - let n = parse_latex(r"\begin{pmatrix} a & b \\ c & d \end{pmatrix}"); - assert!( - matches!(&n[0], MNode::Delim { beg: Some('('), elems, .. } if matches!(elems[0][0], MNode::Matrix { ref rows, .. } if rows.len() == 2)) - ); - let n = parse_latex(r"f(x)=\begin{cases} x & x\ge 0\\ -x & x<0\end{cases}"); - assert!(n.iter().any(|m| matches!(m, MNode::Delim { beg: Some('{'), end: None, .. })), "{n:#?}"); - let n = parse_latex(r"\lim_{n\to\infty}\left(1+\frac{1}{n}\right)^n"); - assert!(matches!(&n[0], MNode::Func { .. }), "{n:#?}"); - let n = parse_latex(r"\mathbb{R}\hat{x}\text{ if }\overbrace{a+b}^{k}"); - assert!(matches!(&n[0], MNode::Run(r) if r.scr == MScr::DoubleStruck)); - let _ = (LimLoc::UndOvr, MathJc::Center); - } - - #[test] - fn latex_binom_round_trip() { - let a = parse_latex(r"\binom{n}{k}"); - let l = to_latex(&a); - assert_eq!(parse_latex(&l), a, "exported as {l}"); - let nobar = |n: &str, k: &str| MNode::Frac { kind: FracKind::NoBar, num: parse_latex(n), den: parse_latex(k) }; - let b = parse_latex(r"\genfrac{}{}{0pt}{}{n}{k}"); - assert_eq!(b, vec![nobar("n", "k")]); - assert_eq!(parse_latex(&to_latex(&b)), b); - // Other spellings of a zero thickness. - for z in ["0 pt", "0.0pt", r"0\,pt", "0em"] { - let c = parse_latex(&format!(r"\genfrac{{}}{{}}{{{z}}}{{}}{{n}}{{k}}")); - assert_eq!(c, vec![nobar("n", "k")], "thickness {z}"); - } - // A non-zero or default thickness keeps the bar. - for t in ["1pt", "0.4pt", ""] { - let c = parse_latex(&format!(r"\genfrac{{}}{{}}{{{t}}}{{}}{{n}}{{k}}")); - let bar = MNode::Frac { kind: FracKind::Bar, num: parse_latex("n"), den: parse_latex("k") }; - assert_eq!(c, vec![bar], "thickness {t:?}"); - } - // Delimiters are kept. - let d = parse_latex(r"\genfrac(){0pt}{}{n}{k}"); - assert_eq!(d, a); - let e = parse_latex(r"\genfrac[]{0pt}{}{n}{k}"); - assert!(matches!(&e[..], [MNode::Delim { beg: Some('['), end: Some(']'), .. }]), "{e:#?}"); - assert_eq!(parse_latex(&to_latex(&e)), e); - } - - #[test] - fn latex_round_trip() { - for lin in ["x=(-b±√(b^2-4ac))/2a", "∑_(i=1)^n▒i^2", "√(3&x)", "a⃗+x̂", "■(a&b@c&d)", "sin⁡x+cos⁡y", "e^(-x^2)"] { - let n = crate::math::parse_linear(lin); - let tex = to_latex(&n); - let back = parse_latex(&tex); - assert_eq!(crate::math::to_linear(&back), crate::math::to_linear(&n), "{lin} → {tex}"); - } - } - - #[test] - fn group_character_latex_roundtrip() { - for cmd in ["\\overparen{a+b}", "\\underparen{a+b}", "\\overbracket{x}", "\\underbracket{x}"] { - let n = parse_latex(cmd); - let tex = to_latex(&n); - assert_eq!(tex, cmd); - } - } - - #[test] - fn hostile_latex_never_panics() { - for s in ["", "\\", "{", "}", "^", "_", "\\frac", "\\sqrt[", "\\left(", "\\right)", "\\begin{matrix", "&&\\\\", "{{{{", "\\end{x}", "a^^b__c"] - { - let _ = to_latex(&parse_latex(s)); - } - let deep = "{".repeat(5000); - let _ = parse_latex(&deep); - let deep = "\\frac{".repeat(3000); - let _ = parse_latex(&deep); - let deep = "\\sqrt".repeat(5000); - let _ = parse_latex(&deep); - let deep = "\\hat".repeat(5000) + "x"; - let _ = parse_latex(&deep); - } - - #[test] - fn array_column_specs_and_aligned_optional_args() { - let n = parse_latex(r"\begin{array}{lcr} a & b & c \\ d & e & f \end{array}"); - let MNode::Matrix { rows, col_jc } = &n[0] else { panic!("{n:#?}") }; - assert_eq!(col_jc, &[ColJc::Left, ColJc::Center, ColJc::Right]); - assert_eq!(rows.len(), 2); - assert_eq!(crate::math::to_linear(&rows[0][0]), "a"); - assert_eq!(crate::math::to_linear(&rows[0][1]), "b"); - assert_eq!(crate::math::to_linear(&rows[0][2]), "c"); - - let n = parse_latex(r"\begin{aligned}[t] x &= 1 \\ y &= 2 \end{aligned}"); - assert!(matches!(&n[0], MNode::EqArr { rows } if rows.len() == 2)); - } - - #[test] - fn operatorname_starred_and_tag() { - let n = parse_latex(r"\operatorname*{argmin}_{x} f(x)"); - assert!(matches!(&n[0], MNode::Lim { upper: false, .. })); - - let n = parse_latex(r"E=mc^2 \tag{1}"); - let MNode::EqArr { rows } = &n[0] else { panic!("{n:#?}") }; - assert!(crate::math::has_number_mark(&rows[0])); - - let n = parse_latex(r"E=mc^2 \tag*{(A)}"); - let MNode::EqArr { rows } = &n[0] else { panic!("{n:#?}") }; - assert!(crate::math::has_number_mark(&rows[0])); - } - - #[test] - fn delim_preserves_custom_separator() { - let delim = MNode::Delim { - beg: Some('{'), - end: Some('}'), - sep: Some('‖'), - grow: true, - shp_match: false, - elems: vec![vec![MNode::Run(MRun::new("x"))], vec![MNode::Run(MRun::new("y"))]], - }; - let tex = to_latex(&[delim]); - assert!(tex.contains(r"\middle\|"), "Expected \\middle\\|, got: {tex}"); - } -} diff --git a/crates/wordmodel/src/math_linear.rs b/crates/wordmodel/src/math_linear.rs deleted file mode 100644 index 39a25ab5d..000000000 --- a/crates/wordmodel/src/math_linear.rs +++ /dev/null @@ -1,762 +0,0 @@ -//! The linear format (UnicodeMath, the plain text Word builds equations from) → math tree. -//! -//! Grammar, informally: an expression is a sequence of atoms and operators. Atoms are characters, -//! bracket groups (`(…)`, `[…]`, `{…}`, `|…|`, `〖…〗` invisible, `├…┤` one-sided, `│` -//! separators), prefix structures (`√x`, `∑_a^b▒x`, `█(…)`, `■(…)`, `▭x`, `□x`, `¯x`, `▁x`, -//! `⏞x`, `⏟x`, `⟡x`), function names (`sin x`, `log_2 x`, `lim┬(n→∞) x`) and placeholders (`⬚`). -//! Postfix operators apply to the atom before them: `^` `_` scripts, combining accents, `┬` `┴` -//! limits. `/` `⁄` `∕` `¦` take the operands on either side (an operand is a run of atoms up to -//! an operator or space; a parenthesised operand loses its parentheses). `^`/`_` with nothing -//! before them are pre-scripts. A top-level `#` numbers the equation (`E=mc^2#(1)`). - -use crate::math::{Arg, FracKind, MAX_DEPTH, MClass, MNode, MRun, ScriptKind, any_too_deep, is_nary_char, math_class, merge_runs, too_deep}; - -/// Function names set upright and applied to the following operand. -pub const FUNCTION_NAMES: &[&str] = &[ - "arccos", "arccot", "arccsc", "arcsec", "arcsin", "arctan", "arg", "cos", "cosh", "cot", "coth", "csc", "csch", "deg", "det", "dim", "exp", - "gcd", "hom", "inf", "ker", "lg", "lim", "liminf", "limsup", "ln", "log", "max", "min", "mod", "Pr", "sec", "sech", "sin", "sinh", "sup", "tan", - "tanh", -]; - -/// Length of the longest of [`FUNCTION_NAMES`]. -const MAX_NAME: usize = { - let (mut max, mut i) = (0, 0); - while i < FUNCTION_NAMES.len() { - if FUNCTION_NAMES[i].len() > max { - max = FUNCTION_NAMES[i].len(); - } - i += 1; - } - max -}; - -/// Function names whose subscripts go underneath (`lim_(n→∞)`). -const LIMIT_NAMES: &[&str] = &["lim", "liminf", "limsup", "max", "min", "sup", "inf", "det", "gcd", "Pr"]; - -/// The empty-argument placeholder. -pub const PLACEHOLDER: char = '⬚'; - -/// Parse linear-format text. Never fails: what isn't understood stays as text. -pub fn parse(s: &str) -> Arg { - let mut p = P { c: s.chars().take(20_000).collect(), i: 0, depth: 0, no_limits: 0, steps: 0 }; - let mut out = Vec::new(); - // Stray closers at the top level are text. - while p.i < p.c.len() { - out.extend(p.expr(&[])); - if let Some(c) = p.peek() { - p.i += 1; - out.push(text(c)); - } - } - let mut out = clean(out); - // `expr#(n)`: a numbered equation. - if out.iter().any(|n| matches!(n, MNode::Run(r) if r.text.contains('#'))) { - out = vec![MNode::EqArr { rows: vec![out] }]; - } - out -} - -fn text(c: char) -> MNode { - MNode::Run(MRun::new(c.to_string())) -} - -/// Closing bracket for an opening one. -pub fn closer(c: char) -> Option { - Some(match c { - '(' => ')', - '[' => ']', - '{' => '}', - '⟨' => '⟩', - '〈' => '〉', - '⌈' => '⌉', - '⌊' => '⌋', - '⟦' => '⟧', - '〖' => '〗', - _ => return None, - }) -} - -fn is_closer(c: char) -> bool { - matches!(c, ')' | ']' | '}' | '⟩' | '〉' | '⌉' | '⌋' | '⟧' | '〗' | '┤') -} - -/// Prefix characters that make a structure from the operand after them. -fn is_prefix(c: char) -> bool { - matches!(c, '√' | '∛' | '∜' | '█' | '■' | '▭' | '□' | '¯' | '▁' | '⟡' | '⏞' | '⏟' | '⏜' | '⏝' | '⎴' | '⎵') || is_nary_char(c) -} - -/// Combining marks used as accents. -pub fn is_combining(c: char) -> bool { - matches!(c as u32, 0x300..=0x36F | 0x20D0..=0x20FF) -} - -/// Characters that end an operand. -fn is_boundary(c: char) -> bool { - matches!(c, ' ' | '&' | '@' | '#' | '▒' | '│' | '/' | '⁄' | '∕' | '¦' | '\u{2061}') - || is_closer(c) - || matches!(math_class(c), MClass::Bin | MClass::Rel | MClass::Punct) -} - -struct P { - c: Vec, - i: usize, - depth: usize, - /// Inside a brace's operand: limits are left for the brace. - no_limits: usize, - /// Work done (bounds hostile input). - steps: usize, -} - -const MAX_STEPS: usize = 400_000; - -impl P { - fn peek(&self) -> Option { - self.c.get(self.i).copied() - } - fn peek_at(&self, k: usize) -> Option { - self.c.get(self.i + k).copied() - } - fn tired(&mut self) -> bool { - self.steps += 1; - self.steps > MAX_STEPS || self.depth > MAX_DEPTH - } - /// The rest of the input as text (when too deep or too long). - fn rest(&mut self) -> Arg { - let t: String = self.c.get(self.i..).unwrap_or(&[]).iter().collect(); - self.i = self.c.len(); - if t.is_empty() { Vec::new() } else { vec![MNode::Run(MRun::new(t))] } - } - - /// A sequence up to one of `stops` (not consumed) or the end. - fn expr(&mut self, stops: &[char]) -> Arg { - self.depth += 1; - let mut out: Arg = Vec::new(); - if self.tired() { - self.depth -= 1; - return self.rest(); - } - while let Some(c) = self.peek() { - if stops.contains(&c) || (is_closer(c) && !stops.is_empty()) { - break; - } - if self.tired() { - out.extend(self.rest()); - break; - } - match c { - '/' | '⁄' | '∕' | '¦' => { - self.i += 1; - let start = operand_start(&out); - // A numerator that is already deep stays as it is: `/` is text then. - if out.get(start..).is_some_and(any_too_deep) { - out.push(text(c)); - continue; - } - let num = strip_parens(out.drain(start..).collect()); - let den = self.operand(stops); - let kind = match c { - '⁄' => FracKind::Skewed, - '∕' => FracKind::Linear, - '¦' => FracKind::NoBar, - _ => FracKind::Bar, - }; - out.push(MNode::Frac { kind, num, den }); - } - '^' | '_' => { - let pre = match out.last() { - None => true, - Some(MNode::Run(r)) => r.text == " " || r.text.chars().all(|c| is_boundary(c) && c != '#'), - _ => false, - }; - if pre { - out.push(self.prescript(stops)); - } else { - self.i += 1; - let s = self.script_operand(stops); - if out.last().is_some_and(too_deep) { - out.push(text(c)); - out.extend(s); - } else { - let base = out.pop(); - out.push(attach_script(base, c == '^', s)); - } - } - } - '┬' | '┴' => { - self.i += 1; - let lim = self.script_operand(stops); - if out.last().is_some_and(too_deep) { - out.push(text(c)); - out.extend(lim); - } else { - let e = out.pop().map(|n| vec![n]).unwrap_or_default(); - out.push(MNode::Lim { upper: c == '┴', e: strip_parens(e), lim }); - } - } - c if is_combining(c) => { - self.i += 1; - if out.last().is_some_and(too_deep) { - out.push(text(c)); - } else { - let e = out.pop().map(|n| strip_parens(vec![n])).unwrap_or_default(); - out.push(MNode::Acc { chr: c, e }); - } - } - '▒' | '\u{2061}' => self.i += 1, - ' ' => { - self.i += 1; - out.push(text(' ')); - } - _ => { - let atom = self.atom(stops); - out.extend(atom); - } - } - } - self.depth -= 1; - out - } - - /// One atom (a character, a bracket group, a structure or a function application). - fn atom(&mut self, stops: &[char]) -> Arg { - let Some(c) = self.peek() else { return Vec::new() }; - if let Some(close) = closer(c) { - self.i += 1; - return vec![self.group(Some(c), &[close])]; - } - if c == '├' { - self.i += 1; - if let Some(d) = self.explicit_group() { - return vec![d]; - } - return vec![self.group(None, &[')', ']', '}', '⟩', '⌉', '⌋', '|', '‖'])]; - } - if (c == '|' || c == '‖') && self.c.get(self.i + 1..).is_some_and(|r| r.contains(&c)) { - self.i += 1; - return vec![self.group(Some(c), &[c])]; - } - if is_prefix(c) { - self.i += 1; - return vec![self.prefix(c, stops)]; - } - if c.is_ascii_alphabetic() { - return self.word(stops); - } - self.i += 1; - vec![text(c)] - } - - /// A bracket group after its opening character; `closers` end it. - fn group(&mut self, open: Option, closers: &[char]) -> MNode { - let mut stops: Vec = closers.to_vec(); - stops.extend(['┤', '│']); - let mut elems = Vec::new(); - let mut end = None; - loop { - let e = self.expr(&stops); - elems.push(e); - match self.peek() { - Some('│') => self.i += 1, - Some('┤') => { - self.i += 1; - break; - } - Some(c) if closers.contains(&c) => { - self.i += 1; - end = Some(c); - break; - } - // Another closer: the group ends unclosed there. - _ => break, - } - if self.tired() { - break; - } - } - let beg = open.filter(|c| *c != '〖'); - let end = end.filter(|c| *c != '〗'); - let sep = if elems.len() > 1 { Some('|') } else { None }; - MNode::Delim { beg, end, sep, grow: true, shp_match: false, elems } - } - - /// `├X … Y┤`: brackets given explicitly (any bracket characters, either side optional), the - /// cursor just after `├`. `None` (cursor unchanged) when there's no matching `┤`. - fn explicit_group(&mut self) -> Option { - let mut level = 1usize; - let mut k = self.i; - while let Some(c) = self.c.get(k) { - match c { - '├' => level += 1, - '┤' => { - level -= 1; - if level == 0 { - break; - } - } - _ => {} - } - k += 1; - } - if level != 0 { - return None; - } - let bracket = |c: &char| matches!(c, '(' | ')' | '[' | ']' | '{' | '}' | '⟨' | '⟩' | '⌈' | '⌉' | '⌊' | '⌋' | '|' | '‖' | '⟦' | '⟧'); - let mut inner: Vec = self.c.get(self.i..k).unwrap_or(&[]).to_vec(); - self.i = k + 1; - let beg = if inner.first().is_some_and(bracket) { Some(inner.remove(0)) } else { None }; - // A closing bracket at the end that pairs with an opening one inside is content. - let paired = |inner: &[char]| -> bool { - let Some((last, body)) = inner.split_last() else { return false }; - let opener = match last { - ')' => '(', - ']' => '[', - '}' => '{', - '⟩' => '⟨', - '⌉' => '⌈', - '⌋' => '⌊', - '⟧' => '⟦', - _ => return false, - }; - body.iter().filter(|c| **c == opener).count() > body.iter().filter(|c| *c == last).count() - }; - let end = if !inner.is_empty() && inner.last().is_some_and(bracket) && !paired(&inner) { inner.pop() } else { None }; - let mut sub = P { c: inner, i: 0, depth: self.depth + 1, no_limits: 0, steps: self.steps }; - let mut elems = vec![Vec::new()]; - while sub.i < sub.c.len() { - if let Some(e) = elems.last_mut() { - e.extend(sub.expr(&['│'])); - } - match sub.peek() { - Some('│') => { - sub.i += 1; - elems.push(Vec::new()); - } - Some(c) => { - sub.i += 1; - if let Some(e) = elems.last_mut() { - e.push(text(c)); - } - } - None => {} - } - if sub.tired() { - break; - } - } - self.steps = sub.steps; - let sep = if elems.len() > 1 { Some('|') } else { None }; - Some(MNode::Delim { beg, end, sep, grow: true, shp_match: false, elems }) - } - - /// A prefix structure after its character. - fn prefix(&mut self, c: char, stops: &[char]) -> MNode { - match c { - '√'..='∜' => { - let mut deg = match c { - '∛' => vec![text('3')], - '∜' => vec![text('4')], - _ => Vec::new(), - }; - let mut e = self.operand(stops); - if let Some(k) = e.iter().position(|n| matches!(n, MNode::Run(r) if r.text == "&")) { - let rest = e.split_off(k + 1); - e.pop(); - deg = e; - e = rest; - } - MNode::Rad { deg_hide: deg.is_empty(), deg, e } - } - '█' | '■' => { - if self.peek() == Some('(') { - self.i += 1; - let inner = self.expr(&[')']); - if self.peek() == Some(')') { - self.i += 1; - } - grid(c == '■', inner) - } else { - text(c) - } - } - '▭' => MNode::BorderBox { hide: [false; 4], strike: [false; 4], e: self.operand(stops) }, - '□' => MNode::Boxed { e: self.operand(stops) }, - '¯' => MNode::Bar { top: true, e: self.operand(stops) }, - '▁' => MNode::Bar { top: false, e: self.operand(stops) }, - '⟡' => MNode::Phant { show: false, zero_wid: false, zero_asc: false, zero_desc: false, e: self.operand(stops) }, - // A label (`┴`/`┬`) after the operand belongs to the whole brace. - '⏞' | '⏜' | '⎴' | '⏟' | '⏝' | '⎵' => { - self.no_limits += 1; - let e = self.operand(stops); - self.no_limits -= 1; - MNode::GroupChr { chr: c, top: matches!(c, '⏞' | '⏜' | '⎴'), e } - } - _ => { - // N-ary operator: limits, then the operand. - let (mut sub, mut sup) = (Vec::new(), Vec::new()); - let (mut has_sub, mut has_sup) = (false, false); - loop { - match self.peek() { - Some('_') => { - self.i += 1; - sub = self.script_operand(stops); - has_sub = true; - } - Some('^') => { - self.i += 1; - sup = self.script_operand(stops); - has_sup = true; - } - _ => break, - } - } - if self.peek() == Some('▒') { - self.i += 1; - } - let e = if self.peek().is_some_and(|c| c == ' ') { Vec::new() } else { self.operand(stops) }; - MNode::Nary { chr: c, lim_loc: None, grow: false, sub_hide: !has_sub, sup_hide: !has_sup, sub, sup, e } - } - } - } - - /// Letters: a function application when they spell a function name, else one letter. - fn word(&mut self, stops: &[char]) -> Arg { - let start = self.i; - let mut end = start; - // Look no further than the longest name (and one past it, to see the run is longer): a - // scan to the end of every run made a long run of letters quadratic (CWE-407). - while end - start <= MAX_NAME && self.c.get(end).is_some_and(char::is_ascii_alphabetic) { - end += 1; - } - let word: String = if end - start > MAX_NAME { String::new() } else { self.c.get(start..end).unwrap_or(&[]).iter().collect() }; - if !FUNCTION_NAMES.contains(&word.as_str()) { - self.i = start + 1; - return vec![text(self.c.get(start).copied().unwrap_or(' '))]; - } - self.i = end; - let plain = vec![MNode::Run(MRun::plain(word.clone()))]; - // Limits and scripts on the name. - let name = match self.peek() { - Some('┬') | Some('┴') => { - let upper = self.peek() == Some('┴'); - self.i += 1; - let lim = self.script_operand(stops); - vec![MNode::Lim { upper, e: plain, lim }] - } - Some('_') if LIMIT_NAMES.contains(&word.as_str()) => { - self.i += 1; - let lim = self.script_operand(stops); - vec![MNode::Lim { upper: false, e: plain, lim }] - } - Some(c @ ('^' | '_')) => { - self.i += 1; - let s = self.script_operand(stops); - let mut n = attach_script(Some(MNode::Run(MRun::plain(word.clone()))), c == '^', s); - if let Some(c2 @ ('^' | '_')) = self.peek() { - self.i += 1; - let s2 = self.script_operand(stops); - n = attach_script(Some(n), c2 == '^', s2); - } - vec![n] - } - _ => plain, - }; - // The operand, after an optional space or function-application mark. - if matches!(self.peek(), Some(' ') | Some('\u{2061}') | Some('▒')) { - self.i += 1; - } - let next = self.peek(); - let applies = next.is_some_and(|c| !stops.contains(&c) && !is_closer(c) && (!is_boundary(c) || c == '(' || matches!(c, '−' | '-' | '+'))); - if !applies { - return name; - } - let e = self.operand(stops); - if e.is_empty() { - return name; - } - vec![MNode::Func { name, e }] - } - - /// Pre-scripts (`_a^b x`): the scripts, then the base. - fn prescript(&mut self, stops: &[char]) -> MNode { - let (mut sub, mut sup) = (Vec::new(), Vec::new()); - while let Some(c @ ('^' | '_')) = self.peek() { - self.i += 1; - let s = self.script_operand(stops); - if c == '^' { - sup = s; - } else { - sub = s; - } - } - if self.peek() == Some('▒') { - self.i += 1; - } - let base = self.operand(stops); - MNode::Script { kind: ScriptKind::Pre, base, sub, sup } - } - - /// An operand: a parenthesised group (parentheses dropped) or a run of atoms with their - /// postfix scripts, accents and limits, up to an operator or space. - fn operand(&mut self, stops: &[char]) -> Arg { - if self.tired() { - return self.rest(); - } - self.depth += 1; - let mut out: Arg = Vec::new(); - if self.peek() == Some('(') { - self.i += 1; - let g = self.group(Some('('), &[')']); - let inner = match g { - MNode::Delim { elems, end: Some(')'), .. } if elems.len() == 1 => elems.into_iter().next().unwrap_or_default(), - other => vec![other], - }; - out = inner; - self.postfix(&mut out, stops, true); - } else if self.peek() == Some('〖') { - self.i += 1; - let g = self.group(Some('〖'), &['〗']); - out = match g { - MNode::Delim { elems, .. } if elems.len() == 1 => elems.into_iter().next().unwrap_or_default(), - other => vec![other], - }; - self.postfix(&mut out, stops, true); - } else { - // A signed operand (`e^-x`, `−b`). - if let Some(c @ ('−' | '-' | '+' | '±' | '∓')) = self.peek() - && self.peek_at(1).is_some_and(|n| !is_boundary(n)) - { - self.i += 1; - out.push(text(c)); - } - while let Some(c) = self.peek() { - if stops.contains(&c) || is_boundary(c) || self.tired() { - break; - } - let a = self.atom(stops); - if a.is_empty() { - break; - } - out.extend(a); - self.postfix(&mut out, stops, false); - } - if out.is_empty() - && let Some(c) = self.peek() - && c != ' ' - && !stops.contains(&c) - && !is_closer(c) - && !matches!(c, '/' | '⁄' | '∕' | '¦' | '&' | '@' | '#' | '│' | '▒') - { - // A lone operator as operand. - self.i += 1; - out.push(text(c)); - } - } - self.depth -= 1; - out - } - - /// Postfix operators on the last atom of `out` (`whole`: on all of `out` as one group). - fn postfix(&mut self, out: &mut Arg, stops: &[char], whole: bool) { - while let Some(c) = self.peek() { - if self.tired() { - break; - } - let base = |out: &mut Arg| -> Option { - if whole && out.len() != 1 { - if out.is_empty() { - return None; - } - return Some(MNode::Delim { beg: None, end: None, sep: None, grow: true, shp_match: false, elems: vec![std::mem::take(out)] }); - } - out.pop() - }; - if !(matches!(c, '^' | '_') || matches!(c, '┬' | '┴') && self.no_limits == 0 || is_combining(c)) { - break; - } - // The base is already deep: the operator is text (see `too_deep`). - let deep = if whole { any_too_deep(out) } else { out.last().is_some_and(too_deep) }; - self.i += 1; - if deep { - let s = if matches!(c, '^' | '_' | '┬' | '┴') { self.script_operand(stops) } else { Vec::new() }; - out.push(text(c)); - out.extend(s); - continue; - } - match c { - '^' | '_' => { - let s = self.script_operand(stops); - let b = base(out); - out.push(attach_script(b, c == '^', s)); - } - '┬' | '┴' => { - let lim = self.script_operand(stops); - let b = base(out); - out.push(MNode::Lim { upper: c == '┴', e: b.map(|n| vec![n]).unwrap_or_default(), lim }); - } - _ => { - let b = base(out); - out.push(MNode::Acc { chr: c, e: b.map(|n| strip_parens(vec![n])).unwrap_or_default() }); - } - } - } - } - - /// A script or limit: a parenthesised group, a structure, a number, a signed operand, a run of - /// letters (and digits) or one other character. - fn script_operand(&mut self, stops: &[char]) -> Arg { - if self.tired() { - return self.rest(); - } - self.depth += 1; - let out = match self.peek() { - Some('(') => { - self.i += 1; - match self.group(Some('('), &[')']) { - MNode::Delim { elems, end: Some(')'), .. } if elems.len() == 1 => elems.into_iter().next().unwrap_or_default(), - other => vec![other], - } - } - Some('〖') => { - self.i += 1; - match self.group(Some('〖'), &['〗']) { - MNode::Delim { elems, .. } if elems.len() == 1 => elems.into_iter().next().unwrap_or_default(), - other => vec![other], - } - } - Some(c) if is_prefix(c) || closer(c).is_some() => self.atom(stops), - Some(c) if c.is_ascii_digit() => { - let start = self.i; - while self.peek().is_some_and(|k| k.is_alphanumeric() || k == '.') { - self.i += 1; - } - let t: String = self.c.get(start..self.i).unwrap_or(&[]).iter().collect(); - vec![MNode::Run(MRun::new(t))] - } - Some(c @ ('−' | '-' | '+' | '±' | '∓')) if self.peek_at(1).is_some_and(|n| !is_boundary(n)) => { - self.i += 1; - let mut v = vec![text(c)]; - v.extend(self.script_operand(stops)); - v - } - Some(c) if c.is_alphabetic() => { - // Word (#1609): the whole run of letters and digits up to a space or operator, so - // `V_dd` is V with a subscript `dd`, exactly like `V_(dd)`. - let start = self.i; - while self.peek().is_some_and(char::is_alphanumeric) { - self.i += 1; - } - let t: String = self.c.get(start..self.i).unwrap_or(&[]).iter().collect(); - // A known function name stays upright, as Word builds it. - vec![MNode::Run(if FUNCTION_NAMES.contains(&t.as_str()) { MRun::plain(t) } else { MRun::new(t) })] - } - Some(c) if !stops.contains(&c) && c != ' ' && !is_closer(c) => { - self.i += 1; - vec![text(c)] - } - _ => Vec::new(), - }; - self.depth -= 1; - out - } -} - -/// Where the trailing operand of `out` starts (after the last operator or space). -fn operand_start(out: &[MNode]) -> usize { - let mut k = out.len(); - while k > 0 { - match out.get(k - 1) { - Some(MNode::Run(r)) if r.text.chars().all(is_boundary) => break, - _ => k -= 1, - } - } - k -} - -/// A single parenthesised group used as an operand loses its parentheses. -pub fn strip_parens(mut a: Arg) -> Arg { - if a.len() == 1 - && let Some(MNode::Delim { beg: Some('('), end: Some(')'), elems, .. } | MNode::Delim { beg: None, end: None, elems, .. }) = a.first_mut() - && elems.len() == 1 - { - return elems.pop().unwrap_or_default(); - } - a -} - -fn attach_script(base: Option, sup: bool, s: Arg) -> MNode { - match base { - Some(MNode::Script { kind: ScriptKind::Sub, base, sub, .. }) if sup => MNode::Script { kind: ScriptKind::SubSup, base, sub, sup: s }, - Some(MNode::Script { kind: ScriptKind::Sup, base, sup: sp, .. }) if !sup => MNode::Script { kind: ScriptKind::SubSup, base, sub: s, sup: sp }, - b => { - let base = b.map(|b| vec![b]).unwrap_or_default(); - if sup { - MNode::Script { kind: ScriptKind::Sup, base, sub: Vec::new(), sup: s } - } else { - MNode::Script { kind: ScriptKind::Sub, base, sub: s, sup: Vec::new() } - } - } - } -} - -/// `█(a@b)` equation array / `■(a&b@c&d)` matrix from the parsed inside. -fn grid(matrix: bool, inner: Arg) -> MNode { - let mut rows: Vec> = vec![vec![Vec::new()]]; - for n in inner { - match &n { - MNode::Run(r) if r.text == "@" => rows.push(vec![Vec::new()]), - MNode::Run(r) if r.text == "&" && matrix => { - if let Some(row) = rows.last_mut() { - row.push(Vec::new()); - } - } - _ => { - if let Some(cell) = rows.last_mut().and_then(|r| r.last_mut()) { - cell.push(n); - } - } - } - } - if matrix { - MNode::Matrix { rows, col_jc: Vec::new() } - } else { - MNode::EqArr { rows: rows.into_iter().map(|r| r.into_iter().flatten().collect()).collect() } - } -} - -/// Drop placeholders and build-up spaces, merge runs; recursively. -fn clean(a: Arg) -> Arg { - let mut out = Vec::with_capacity(a.len()); - for n in a { - match n { - MNode::Run(r) if r.text == " " || r.text.chars().all(|c| c == PLACEHOLDER) => {} - other => out.push(map_args(other, &clean)), - } - } - merge_runs(out) -} - -/// Apply `f` to every argument of a node. -pub fn map_args(n: MNode, f: &dyn Fn(Arg) -> Arg) -> MNode { - match n { - MNode::Run(r) => MNode::Run(r), - MNode::Frac { kind, num, den } => MNode::Frac { kind, num: f(num), den: f(den) }, - MNode::Script { kind, base, sub, sup } => MNode::Script { kind, base: f(base), sub: f(sub), sup: f(sup) }, - MNode::Rad { deg, deg_hide, e } => MNode::Rad { deg: f(deg), deg_hide, e: f(e) }, - MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub, sup, e } => { - MNode::Nary { chr, lim_loc, grow, sub_hide, sup_hide, sub: f(sub), sup: f(sup), e: f(e) } - } - MNode::Delim { beg, end, sep, grow, shp_match, elems } => { - MNode::Delim { beg, end, sep, grow, shp_match, elems: elems.into_iter().map(f).collect() } - } - MNode::Func { name, e } => MNode::Func { name: f(name), e: f(e) }, - MNode::Lim { upper, e, lim } => MNode::Lim { upper, e: f(e), lim: f(lim) }, - MNode::Acc { chr, e } => MNode::Acc { chr, e: f(e) }, - MNode::Bar { top, e } => MNode::Bar { top, e: f(e) }, - MNode::BorderBox { hide, strike, e } => MNode::BorderBox { hide, strike, e: f(e) }, - MNode::Boxed { e } => MNode::Boxed { e: f(e) }, - MNode::GroupChr { chr, top, e } => MNode::GroupChr { chr, top, e: f(e) }, - MNode::EqArr { rows } => MNode::EqArr { rows: rows.into_iter().map(f).collect() }, - MNode::Matrix { rows, col_jc } => MNode::Matrix { rows: rows.into_iter().map(|r| r.into_iter().map(f).collect()).collect(), col_jc }, - MNode::Phant { show, zero_wid, zero_asc, zero_desc, e } => MNode::Phant { show, zero_wid, zero_asc, zero_desc, e: f(e) }, - } -} diff --git a/crates/wordmodel/src/math_symbols.rs b/crates/wordmodel/src/math_symbols.rs deleted file mode 100644 index 28b3a7f83..000000000 --- a/crates/wordmodel/src/math_symbols.rs +++ /dev/null @@ -1,390 +0,0 @@ -//! Named math characters: Math AutoCorrect while typing in an equation (`\alpha` + space → α) -//! and LaTeX conversion. Names are the usual TeX names plus the linear-format operators -//! (`\sqrt` → √, `\above` → ┴, `\matrix` → ■…). - -/// (name without the backslash, character). When a character has several names the first one -/// listed is the one LaTeX output uses. -pub const SYMBOLS: &[(&str, char)] = &[ - // Greek. - ("alpha", 'α'), - ("beta", 'β'), - ("gamma", 'γ'), - ("delta", 'δ'), - ("epsilon", 'ϵ'), - ("varepsilon", 'ε'), - ("zeta", 'ζ'), - ("eta", 'η'), - ("theta", 'θ'), - ("vartheta", 'ϑ'), - ("iota", 'ι'), - ("kappa", 'κ'), - ("varkappa", 'ϰ'), - ("lambda", 'λ'), - ("mu", 'μ'), - ("nu", 'ν'), - ("xi", 'ξ'), - ("omicron", 'ο'), - ("pi", 'π'), - ("varpi", 'ϖ'), - ("rho", 'ρ'), - ("varrho", 'ϱ'), - ("sigma", 'σ'), - ("varsigma", 'ς'), - ("tau", 'τ'), - ("upsilon", 'υ'), - ("phi", 'ϕ'), - ("varphi", 'φ'), - ("chi", 'χ'), - ("psi", 'ψ'), - ("omega", 'ω'), - ("Alpha", 'Α'), - ("Beta", 'Β'), - ("Gamma", 'Γ'), - ("Delta", 'Δ'), - ("Epsilon", 'Ε'), - ("Zeta", 'Ζ'), - ("Eta", 'Η'), - ("Theta", 'Θ'), - ("Iota", 'Ι'), - ("Kappa", 'Κ'), - ("Lambda", 'Λ'), - ("Mu", 'Μ'), - ("Nu", 'Ν'), - ("Xi", 'Ξ'), - ("Omicron", 'Ο'), - ("Pi", 'Π'), - ("Rho", 'Ρ'), - ("Sigma", 'Σ'), - ("Tau", 'Τ'), - ("Upsilon", 'Υ'), - ("Phi", 'Φ'), - ("Chi", 'Χ'), - ("Psi", 'Ψ'), - ("Omega", 'Ω'), - ("digamma", 'ϝ'), - // Binary operators. - ("pm", '±'), - ("mp", '∓'), - ("times", '×'), - ("div", '÷'), - ("cdot", '⋅'), - ("bullet", '∙'), - ("ast", '∗'), - ("star", '⋆'), - ("circ", '∘'), - ("oplus", '⊕'), - ("ominus", '⊖'), - ("otimes", '⊗'), - ("oslash", '⊘'), - ("odot", '⊙'), - ("cup", '∪'), - ("cap", '∩'), - ("uplus", '⊎'), - ("sqcup", '⊔'), - ("sqcap", '⊓'), - ("vee", '∨'), - ("wedge", '∧'), - ("setminus", '∖'), - ("backslash", '\\'), - ("wr", '≀'), - ("diamond", '⋄'), - ("dagger", '†'), - ("ddagger", '‡'), - ("amalg", '⨿'), - ("dotplus", '∔'), - ("ltimes", '⋉'), - ("rtimes", '⋊'), - // Relations. - ("le", '≤'), - ("leq", '≤'), - ("ge", '≥'), - ("geq", '≥'), - ("ne", '≠'), - ("neq", '≠'), - ("approx", '≈'), - ("equiv", '≡'), - ("sim", '∼'), - ("simeq", '≃'), - ("cong", '≅'), - ("propto", '∝'), - ("ll", '≪'), - ("gg", '≫'), - ("prec", '≺'), - ("succ", '≻'), - ("preceq", '≼'), - ("succeq", '≽'), - ("subset", '⊂'), - ("supset", '⊃'), - ("subseteq", '⊆'), - ("supseteq", '⊇'), - ("sqsubseteq", '⊑'), - ("sqsupseteq", '⊒'), - ("in", '∈'), - ("ni", '∋'), - ("notin", '∉'), - ("perp", '⊥'), - ("parallel", '∥'), - ("mid", '∣'), - ("nmid", '∤'), - ("asymp", '≍'), - ("doteq", '≐'), - ("models", '⊨'), - ("vdash", '⊢'), - ("dashv", '⊣'), - ("bowtie", '⋈'), - ("coloneq", '≔'), - ("defeq", '≝'), - ("triangleq", '≜'), - ("nless", '≮'), - ("ngtr", '≯'), - ("nleq", '≰'), - ("ngeq", '≱'), - ("nsim", '≁'), - ("ncong", '≇'), - ("nequiv", '≢'), - ("nsubset", '⊄'), - ("nsupset", '⊅'), - ("nsubseteq", '⊈'), - ("nsupseteq", '⊉'), - ("subsetneq", '⊊'), - ("supsetneq", '⊋'), - ("lesssim", '≲'), - ("gtrsim", '≳'), - ("therefore", '∴'), - ("because", '∵'), - // Arrows. - ("rightarrow", '→'), - ("to", '→'), - ("leftarrow", '←'), - ("gets", '←'), - ("leftrightarrow", '↔'), - ("uparrow", '↑'), - ("downarrow", '↓'), - ("updownarrow", '↕'), - ("Rightarrow", '⇒'), - ("Leftarrow", '⇐'), - ("Leftrightarrow", '⇔'), - ("Uparrow", '⇑'), - ("Downarrow", '⇓'), - ("Updownarrow", '⇕'), - ("longrightarrow", '⟶'), - ("longleftarrow", '⟵'), - ("longleftrightarrow", '⟷'), - ("Longrightarrow", '⟹'), - ("Longleftarrow", '⟸'), - ("Longleftrightarrow", '⟺'), - ("implies", '⟹'), - ("iff", '⟺'), - ("mapsto", '↦'), - ("longmapsto", '⟼'), - ("nearrow", '↗'), - ("searrow", '↘'), - ("swarrow", '↙'), - ("nwarrow", '↖'), - ("hookleftarrow", '↩'), - ("hookrightarrow", '↪'), - ("leftharpoonup", '↼'), - ("leftharpoondown", '↽'), - ("rightharpoonup", '⇀'), - ("rightharpoondown", '⇁'), - ("rightleftharpoons", '⇌'), - ("leftrightharpoons", '⇋'), - ("circlearrowleft", '↺'), - ("circlearrowright", '↻'), - // Big operators. - ("sum", '∑'), - ("prod", '∏'), - ("coprod", '∐'), - ("int", '∫'), - ("iint", '∬'), - ("iiint", '∭'), - ("oint", '∮'), - ("oiint", '∯'), - ("oiiint", '∰'), - ("bigcup", '⋃'), - ("bigcap", '⋂'), - ("bigvee", '⋁'), - ("bigwedge", '⋀'), - ("bigoplus", '⨁'), - ("bigotimes", '⨂'), - ("bigodot", '⨀'), - ("biguplus", '⨄'), - ("bigsqcup", '⨆'), - // Brackets. - ("langle", '⟨'), - ("rangle", '⟩'), - ("lceil", '⌈'), - ("rceil", '⌉'), - ("lfloor", '⌊'), - ("rfloor", '⌋'), - ("lbrack", '['), - ("rbrack", ']'), - ("lbrace", '{'), - ("rbrace", '}'), - ("Vert", '‖'), - ("norm", '‖'), - ("vert", '|'), - ("lbbrack", '⟦'), - ("rbbrack", '⟧'), - // Miscellaneous. - ("infty", '∞'), - ("partial", '∂'), - ("nabla", '∇'), - ("forall", '∀'), - ("exists", '∃'), - ("nexists", '∄'), - ("neg", '¬'), - ("lnot", '¬'), - ("emptyset", '∅'), - ("varnothing", '∅'), - ("hbar", 'ℏ'), - ("ell", 'ℓ'), - ("Re", 'ℜ'), - ("Im", 'ℑ'), - ("wp", '℘'), - ("aleph", 'ℵ'), - ("beth", 'ℶ'), - ("gimel", 'ℷ'), - ("daleth", 'ℸ'), - ("degree", '°'), - ("angle", '∠'), - ("measuredangle", '∡'), - ("sphericalangle", '∢'), - ("rightangle", '∟'), - ("triangle", '△'), - ("square", '□'), - ("blacksquare", '■'), - ("qed", '∎'), - ("top", '⊤'), - ("bot", '⊥'), - ("cdots", '⋯'), - ("ldots", '…'), - ("dots", '…'), - ("vdots", '⋮'), - ("ddots", '⋱'), - ("adots", '⋰'), - ("prime", '′'), - ("dprime", '″'), - ("trprime", '‴'), - ("dd", 'ⅆ'), - ("Dd", 'ⅅ'), - ("ee", 'ⅇ'), - ("ii", 'ⅈ'), - ("jj", 'ⅉ'), - ("imath", 'ı'), - ("jmath", 'ȷ'), - ("complement", '∁'), - ("Angstrom", 'Å'), - ("mho", '℧'), - ("eth", 'ð'), - ("flat", '♭'), - ("natural", '♮'), - ("sharp", '♯'), - ("clubsuit", '♣'), - ("diamondsuit", '♢'), - ("heartsuit", '♡'), - ("spadesuit", '♠'), - // Spaces. - ("quad", '\u{2003}'), - ("emsp", '\u{2003}'), - ("ensp", '\u{2002}'), - ("thicksp", '\u{2005}'), - ("medsp", '\u{205F}'), - ("thinsp", '\u{2009}'), - ("hairsp", '\u{200A}'), - ("nbsp", '\u{A0}'), - ("zwsp", '\u{200B}'), - // Linear-format operators (build-up). - ("sqrt", '√'), - ("cbrt", '∛'), - ("qdrt", '∜'), - ("matrix", '■'), - ("eqarray", '█'), - ("above", '┴'), - ("below", '┬'), - ("of", '▒'), - ("naryand", '▒'), - ("funcapply", '\u{2061}'), - ("box", '□'), - ("rect", '▭'), - ("overbar", '¯'), - ("underbar", '▁'), - ("phantom", '⟡'), - ("overbrace", '⏞'), - ("underbrace", '⏟'), - ("overparen", '⏜'), - ("underparen", '⏝'), - ("atop", '¦'), - ("over", '/'), - ("ldiv", '∕'), - ("sdiv", '⁄'), - ("begin", '〖'), - ("end", '〗'), - ("left", '├'), - ("right", '┤'), - ("eqno", '#'), - ("placeholder", '⬚'), - // Accents (combining, typed after the base). - ("hat", '\u{302}'), - ("check", '\u{30C}'), - ("tilde", '\u{303}'), - ("acute", '\u{301}'), - ("grave", '\u{300}'), - ("dot", '\u{307}'), - ("ddot", '\u{308}'), - ("dddot", '\u{20DB}'), - ("breve", '\u{306}'), - ("bar", '\u{305}'), - ("Bar", '\u{33F}'), - ("vec", '\u{20D7}'), - ("tvec", '\u{20E1}'), - ("lhvec", '\u{20D0}'), - ("hvec", '\u{20D1}'), -]; - -/// The character for a name (`alpha` → α), including `doubleR`, `scriptL`, `frakturg`. -pub fn symbol(name: &str) -> Option { - if let Some((_, c)) = SYMBOLS.iter().find(|(n, _)| *n == name) { - return Some(*c); - } - for (prefix, scr) in [("double", crate::math::MScr::DoubleStruck), ("script", crate::math::MScr::Script), ("fraktur", crate::math::MScr::Fraktur)] - { - if let Some(rest) = name.strip_prefix(prefix) { - let mut cs = rest.chars(); - if let (Some(c), None) = (cs.next(), cs.next()) - && c.is_ascii_alphanumeric() - { - let m = crate::math::math_alnum(c, false, false, scr); - if m != c { - return Some(m); - } - } - } - } - None -} - -/// The (first) name of a character. -pub fn name_of(c: char) -> Option<&'static str> { - SYMBOLS.iter().find(|(_, s)| *s == c).map(|(n, _)| *n) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn names_resolve() { - assert_eq!(symbol("alpha"), Some('α')); - assert_eq!(symbol("doubleR"), Some('ℝ')); - assert_eq!(symbol("scriptL"), Some('ℒ')); - assert_eq!(symbol("nonesuch"), None); - assert_eq!(name_of('≤'), Some("le")); - // No name is listed twice. - let mut names: Vec<&str> = SYMBOLS.iter().map(|(n, _)| *n).collect(); - names.sort_unstable(); - let before = names.len(); - names.dedup(); - assert_eq!(before, names.len()); - } -} diff --git a/crates/wordmodel/src/math_tree.rs b/crates/wordmodel/src/math_tree.rs deleted file mode 100644 index 45d2c603a..000000000 --- a/crates/wordmodel/src/math_tree.rs +++ /dev/null @@ -1,104 +0,0 @@ -//! Structural access to an equation tree: the arguments of a node, and every run in a tree. - -use crate::math::{Arg, MAX_DEPTH, MNode, MRun}; - -/// Number of child arguments of a node. -pub fn child_count(n: &MNode) -> usize { - match n { - MNode::Run(_) => 0, - MNode::Frac { .. } | MNode::Func { .. } | MNode::Lim { .. } => 2, - MNode::Rad { .. } => 2, - MNode::Script { .. } | MNode::Nary { .. } => 3, - MNode::Delim { elems, .. } => elems.len(), - MNode::EqArr { rows } => rows.len(), - MNode::Matrix { rows, .. } => rows.iter().map(|r| r.len()).sum(), - MNode::Acc { .. } | MNode::Bar { .. } | MNode::BorderBox { .. } | MNode::Boxed { .. } | MNode::GroupChr { .. } | MNode::Phant { .. } => 1, - } -} - -/// Child argument `i` of a node (see [`child_count`] for the numbering: fraction num 0 / den 1; -/// script base 0 / sub 1 / sup 2; radical degree 0 / base 1; n-ary sub 0 / sup 1 / operand 2; -/// function name 0 / argument 1; limit base 0 / limit 1; matrices row by row). -pub fn child(n: &MNode, i: usize) -> Option<&Arg> { - match (n, i) { - (MNode::Frac { num, .. }, 0) => Some(num), - (MNode::Frac { den, .. }, 1) => Some(den), - (MNode::Script { base, .. }, 0) => Some(base), - (MNode::Script { sub, .. }, 1) => Some(sub), - (MNode::Script { sup, .. }, 2) => Some(sup), - (MNode::Rad { deg, .. }, 0) => Some(deg), - (MNode::Rad { e, .. }, 1) => Some(e), - (MNode::Nary { sub, .. }, 0) => Some(sub), - (MNode::Nary { sup, .. }, 1) => Some(sup), - (MNode::Nary { e, .. }, 2) => Some(e), - (MNode::Delim { elems, .. }, i) => elems.get(i), - (MNode::Func { name, .. }, 0) => Some(name), - (MNode::Func { e, .. }, 1) => Some(e), - (MNode::Lim { e, .. }, 0) => Some(e), - (MNode::Lim { lim, .. }, 1) => Some(lim), - ( - MNode::Acc { e, .. } - | MNode::Bar { e, .. } - | MNode::BorderBox { e, .. } - | MNode::Boxed { e } - | MNode::GroupChr { e, .. } - | MNode::Phant { e, .. }, - 0, - ) => Some(e), - (MNode::EqArr { rows }, i) => rows.get(i), - (MNode::Matrix { rows, .. }, i) => rows.iter().flatten().nth(i), - _ => None, - } -} - -pub fn child_mut(n: &mut MNode, i: usize) -> Option<&mut Arg> { - match (n, i) { - (MNode::Frac { num, .. }, 0) => Some(num), - (MNode::Frac { den, .. }, 1) => Some(den), - (MNode::Script { base, .. }, 0) => Some(base), - (MNode::Script { sub, .. }, 1) => Some(sub), - (MNode::Script { sup, .. }, 2) => Some(sup), - (MNode::Rad { deg, .. }, 0) => Some(deg), - (MNode::Rad { e, .. }, 1) => Some(e), - (MNode::Nary { sub, .. }, 0) => Some(sub), - (MNode::Nary { sup, .. }, 1) => Some(sup), - (MNode::Nary { e, .. }, 2) => Some(e), - (MNode::Delim { elems, .. }, i) => elems.get_mut(i), - (MNode::Func { name, .. }, 0) => Some(name), - (MNode::Func { e, .. }, 1) => Some(e), - (MNode::Lim { e, .. }, 0) => Some(e), - (MNode::Lim { lim, .. }, 1) => Some(lim), - ( - MNode::Acc { e, .. } - | MNode::Bar { e, .. } - | MNode::BorderBox { e, .. } - | MNode::Boxed { e } - | MNode::GroupChr { e, .. } - | MNode::Phant { e, .. }, - 0, - ) => Some(e), - (MNode::EqArr { rows }, i) => rows.get_mut(i), - (MNode::Matrix { rows, .. }, i) => rows.iter_mut().flatten().nth(i), - _ => None, - } -} - -/// Call `f` on every run in the tree. -pub fn each_run_mut(root: &mut Arg, f: &mut dyn FnMut(&mut MRun)) { - fn go(a: &mut Arg, f: &mut dyn FnMut(&mut MRun), depth: usize) { - if depth > MAX_DEPTH { - return; - } - for n in a.iter_mut() { - if let MNode::Run(r) = n { - f(r); - } - for c in 0..child_count(n) { - if let Some(ch) = child_mut(n, c) { - go(ch, f, depth + 1); - } - } - } - } - go(root, f, 0); -} diff --git a/crates/wordmodel/src/props/color.rs b/crates/wordmodel/src/props/color.rs index baf2d948d..9fce90039 100644 --- a/crates/wordmodel/src/props/color.rs +++ b/crates/wordmodel/src/props/color.rs @@ -1,83 +1,7 @@ use super::{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: D) -> Result { - 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(self, s: &str) -> Result { - Rgb::parse(s).ok_or_else(|| E::invalid_value(Unexpected::Str(s), &self)) - } - fn visit_seq>(self, mut a: A) -> Result { - 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::()?.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 { - 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 - } -} +/// An sRGB colour: craft-math's, which equations and text share. +pub use craft_math::Rgb; /// A text colour: automatic (black on light backgrounds, white on dark) or a fixed colour. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Default)] @@ -115,57 +39,3 @@ impl<'de> Deserialize<'de> for TextColor { } } } - -/// `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)) -} diff --git a/crates/wordmodel/src/symbol_fonts.rs b/crates/wordmodel/src/symbol_fonts.rs deleted file mode 100644 index 1d5175540..000000000 --- a/crates/wordmodel/src/symbol_fonts.rs +++ /dev/null @@ -1,316 +0,0 @@ -//! Word's symbol fonts — Symbol, Wingdings, Wingdings 2, Wingdings 3 and Webdings — mapped to -//! standard Unicode characters, so text set in them shows the intended symbols when the font -//! isn't installed (they are proprietary and never bundled) and exports as real characters. -//! -//! Word stores a symbol-font character either as the private-use code U+F000 + the font's own -//! 8-bit code (U+F020–U+F0FF, what Insert › Symbol and `w:sym` write) or as the plain 8-bit code -//! (U+0020–U+00FF) in a run whose font is the symbol font. Both map the same way. The stored text -//! never changes: the mapping is for display, layout and plain-text output only. -//! -//! Sources (Unicode's own published data; no font file was read): -//! - Symbol: the Adobe Symbol encoding table published by Unicode, -//! (codes it maps to the -//! private use area are left out; 0x6D maps to GREEK SMALL LETTER MU). -//! - Wingdings, Wingdings 2, Wingdings 3, Webdings: the correspondence tables in "Status of -//! encoding of Wingdings and Webdings Symbols", Unicode L2/12-368 (WG2 N4384, M. Suignard, -//! 2012), the frozen repertoire of the Unicode 7.0 encoding (earlier drafts L2/11-052, -//! L2/11-344), checked against the Unicode Character Database names. The one character not -//! encoded (Wingdings 0xFF) has no entry. -//! -//! Tables are indexed by code − 0x20; 0 = no Unicode equivalent. - -/// The symbol table for a single family name, by lowercase family name. -fn single_table(family: &str) -> Option<&'static [u32; 224]> { - let f = family.trim(); - if f.eq_ignore_ascii_case("symbol") || f.eq_ignore_ascii_case("symbol (pcl6)") { - Some(&SYMBOL) - } else if f.eq_ignore_ascii_case("wingdings") { - Some(&WINGDINGS) - } else if f.eq_ignore_ascii_case("wingdings 2") { - Some(&WINGDINGS_2) - } else if f.eq_ignore_ascii_case("wingdings 3") { - Some(&WINGDINGS_3) - } else if f.eq_ignore_ascii_case("webdings") { - Some(&WEBDINGS) - } else { - None - } -} - -/// The symbol fonts this module knows, by lowercase family name. -fn table(family: &str) -> Option<&'static [u32; 224]> { - if family.contains(';') { family.split(';').map(str::trim).filter(|s| !s.is_empty()).find_map(single_table) } else { single_table(family) } -} - -/// Whether `family` is one of Word's symbol fonts (Symbol, Wingdings, Wingdings 2, Wingdings 3, -/// Webdings), whose characters [`symbol_char`] maps. -pub fn is_symbol_font(family: &str) -> bool { - table(family).is_some() -} - -/// The standard Unicode character for `c` in a run set in the symbol font `family`: `c` is the -/// font's code stored as U+F020–U+F0FF or as U+0020–U+00FF (except the soft hyphen U+00AD). -/// `None` for other fonts, other characters and codes with no Unicode equivalent. -pub fn symbol_char(family: &str, c: char) -> Option { - let t = table(family)?; - let u = u32::from(c); - let code = match u { - 0xF020..=0xF0FF => u - 0xF000, - // A soft hyphen stays a soft hyphen (Word stores `w:softHyphen` as U+00AD). - 0xAD => return None, - 0x20..=0xFF => u, - _ => return None, - }; - let i = usize::try_from(code.checked_sub(0x20)?).ok()?; - t.get(i).copied().filter(|v| *v != 0).and_then(char::from_u32) -} - -/// `text` (in a run set in `family`) with every symbol-font character replaced by its Unicode -/// equivalent; unchanged for other fonts. -pub fn map_text(family: &str, text: &str) -> String { - if !is_symbol_font(family) { - return text.to_string(); - } - text.chars().map(|c| symbol_char(family, c).unwrap_or(c)).collect() -} - -const SYMBOL: [u32; 224] = [ - 0x0020, 0x0021, 0x2200, 0x0023, 0x2203, 0x0025, 0x0026, 0x220B, // 0x20 - 0x0028, 0x0029, 0x2217, 0x002B, 0x002C, 0x2212, 0x002E, 0x002F, // 0x28 - 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, // 0x30 - 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, // 0x38 - 0x2245, 0x0391, 0x0392, 0x03A7, 0x0394, 0x0395, 0x03A6, 0x0393, // 0x40 - 0x0397, 0x0399, 0x03D1, 0x039A, 0x039B, 0x039C, 0x039D, 0x039F, // 0x48 - 0x03A0, 0x0398, 0x03A1, 0x03A3, 0x03A4, 0x03A5, 0x03C2, 0x03A9, // 0x50 - 0x039E, 0x03A8, 0x0396, 0x005B, 0x2234, 0x005D, 0x22A5, 0x005F, // 0x58 - 0x0000, 0x03B1, 0x03B2, 0x03C7, 0x03B4, 0x03B5, 0x03C6, 0x03B3, // 0x60 - 0x03B7, 0x03B9, 0x03D5, 0x03BA, 0x03BB, 0x03BC, 0x03BD, 0x03BF, // 0x68 - 0x03C0, 0x03B8, 0x03C1, 0x03C3, 0x03C4, 0x03C5, 0x03D6, 0x03C9, // 0x70 - 0x03BE, 0x03C8, 0x03B6, 0x007B, 0x007C, 0x007D, 0x223C, 0x0000, // 0x78 - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x80 - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x88 - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x90 - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0x98 - 0x20AC, 0x03D2, 0x2032, 0x2264, 0x2044, 0x221E, 0x0192, 0x2663, // 0xA0 - 0x2666, 0x2665, 0x2660, 0x2194, 0x2190, 0x2191, 0x2192, 0x2193, // 0xA8 - 0x00B0, 0x00B1, 0x2033, 0x2265, 0x00D7, 0x221D, 0x2202, 0x2022, // 0xB0 - 0x00F7, 0x2260, 0x2261, 0x2248, 0x2026, 0x0000, 0x0000, 0x21B5, // 0xB8 - 0x2135, 0x2111, 0x211C, 0x2118, 0x2297, 0x2295, 0x2205, 0x2229, // 0xC0 - 0x222A, 0x2283, 0x2287, 0x2284, 0x2282, 0x2286, 0x2208, 0x2209, // 0xC8 - 0x2220, 0x2207, 0x0000, 0x0000, 0x0000, 0x220F, 0x221A, 0x22C5, // 0xD0 - 0x00AC, 0x2227, 0x2228, 0x21D4, 0x21D0, 0x21D1, 0x21D2, 0x21D3, // 0xD8 - 0x25CA, 0x2329, 0x0000, 0x0000, 0x0000, 0x2211, 0x0000, 0x0000, // 0xE0 - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xE8 - 0x0000, 0x232A, 0x222B, 0x2320, 0x0000, 0x2321, 0x0000, 0x0000, // 0xF0 - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF8 -]; - -const WEBDINGS: [u32; 224] = [ - 0x0020, 0x1F577, 0x1F578, 0x1F572, 0x1F576, 0x1F3C6, 0x1F396, 0x1F587, // 0x20 - 0x1F5E8, 0x1F5E9, 0x1F5F0, 0x1F5F1, 0x1F336, 0x1F397, 0x1F67E, 0x1F67C, // 0x28 - 0x1F5D5, 0x1F5D6, 0x1F5D7, 0x23F4, 0x23F5, 0x23F6, 0x23F7, 0x23EA, // 0x30 - 0x23E9, 0x23EE, 0x23ED, 0x23F8, 0x23F9, 0x23FA, 0x1F5DA, 0x1F5F3, // 0x38 - 0x1F6E0, 0x1F3D7, 0x1F3D8, 0x1F3D9, 0x1F3DA, 0x1F3DC, 0x1F3ED, 0x1F3DB, // 0x40 - 0x1F3E0, 0x1F3D6, 0x1F3DD, 0x1F6E3, 0x1F50D, 0x1F3D4, 0x1F441, 0x1F442, // 0x48 - 0x1F3DE, 0x1F3D5, 0x1F6E4, 0x1F3DF, 0x1F6F3, 0x1F56C, 0x1F56B, 0x1F568, // 0x50 - 0x1F508, 0x1F394, 0x1F395, 0x1F5EC, 0x1F67D, 0x1F5ED, 0x1F5EA, 0x1F5EB, // 0x58 - 0x2B94, 0x2714, 0x1F6B2, 0x2B1C, 0x1F6E1, 0x1F4E6, 0x1F6F1, 0x2B1B, // 0x60 - 0x1F691, 0x1F6C8, 0x1F6E9, 0x1F6F0, 0x1F7C8, 0x1F574, 0x2B24, 0x1F6E5, // 0x68 - 0x1F694, 0x1F5D8, 0x1F5D9, 0x2753, 0x1F6F2, 0x1F687, 0x1F68D, 0x26F3, // 0x70 - 0x29B8, 0x2296, 0x1F6AD, 0x1F5EE, 0x23D0, 0x1F5EF, 0x1F5F2, 0x0000, // 0x78 - 0x1F6B9, 0x1F6BA, 0x1F6C9, 0x1F6CA, 0x1F6BC, 0x1F47D, 0x1F3CB, 0x26F7, // 0x80 - 0x1F3C2, 0x1F3CC, 0x1F3CA, 0x1F3C4, 0x1F3CD, 0x1F3CE, 0x1F698, 0x1F5E0, // 0x88 - 0x1F6E2, 0x1F4B0, 0x1F3F7, 0x1F4B3, 0x1F46A, 0x1F5E1, 0x1F5E2, 0x1F5E3, // 0x90 - 0x272F, 0x1F584, 0x1F585, 0x1F583, 0x1F586, 0x1F5B9, 0x1F5BA, 0x1F5BB, // 0x98 - 0x1F575, 0x1F570, 0x1F5BD, 0x1F5BE, 0x1F4CB, 0x1F5D2, 0x1F5D3, 0x1F56E, // 0xA0 - 0x1F4DA, 0x1F5DE, 0x1F5DF, 0x1F5C3, 0x1F5C2, 0x1F5BC, 0x1F3AD, 0x1F39C, // 0xA8 - 0x1F398, 0x1F399, 0x1F3A7, 0x1F4BF, 0x1F39E, 0x1F4F7, 0x1F39F, 0x1F3AC, // 0xB0 - 0x1F4FD, 0x1F4F9, 0x1F4FE, 0x1F4FB, 0x1F39A, 0x1F39B, 0x1F4FA, 0x1F4BB, // 0xB8 - 0x1F5A5, 0x1F5A6, 0x1F5A7, 0x1F579, 0x1F3AE, 0x1F57B, 0x1F57C, 0x1F4DF, // 0xC0 - 0x1F581, 0x1F580, 0x1F5A8, 0x1F5A9, 0x1F5BF, 0x1F5AA, 0x1F5DC, 0x1F512, // 0xC8 - 0x1F513, 0x1F5DD, 0x1F4E5, 0x1F4E4, 0x1F573, 0x1F323, 0x1F324, 0x1F325, // 0xD0 - 0x1F326, 0x2601, 0x1F328, 0x1F327, 0x1F329, 0x1F32A, 0x1F32C, 0x1F32B, // 0xD8 - 0x1F31C, 0x1F321, 0x1F6CB, 0x1F6CF, 0x1F37D, 0x1F378, 0x1F6CE, 0x1F6CD, // 0xE0 - 0x24C5, 0x267F, 0x1F6C6, 0x1F588, 0x1F393, 0x1F5E4, 0x1F5E5, 0x1F5E6, // 0xE8 - 0x1F5E7, 0x1F6EA, 0x1F43F, 0x1F426, 0x1F41F, 0x1F415, 0x1F408, 0x1F66C, // 0xF0 - 0x1F66E, 0x1F66D, 0x1F66F, 0x1F5FA, 0x1F30D, 0x1F30F, 0x1F30E, 0x1F54A, // 0xF8 -]; - -const WINGDINGS: [u32; 224] = [ - 0x0020, 0x1F589, 0x2702, 0x2701, 0x1F453, 0x1F56D, 0x1F56E, 0x1F56F, // 0x20 - 0x1F57F, 0x2706, 0x1F582, 0x1F583, 0x1F4EA, 0x1F4EB, 0x1F4EC, 0x1F4ED, // 0x28 - 0x1F5C0, 0x1F5C1, 0x1F5CE, 0x1F5CF, 0x1F5D0, 0x1F5C4, 0x23F3, 0x1F5AE, // 0x30 - 0x1F5B0, 0x1F5B2, 0x1F5B3, 0x1F5B4, 0x1F5AB, 0x1F5AC, 0x2707, 0x270D, // 0x38 - 0x1F58E, 0x270C, 0x1F58F, 0x1F44D, 0x1F44E, 0x261C, 0x261E, 0x261D, // 0x40 - 0x1F597, 0x1F590, 0x263A, 0x1F610, 0x2639, 0x1F4A3, 0x1F571, 0x1F3F3, // 0x48 - 0x1F3F1, 0x2708, 0x263C, 0x1F322, 0x2744, 0x1F546, 0x271E, 0x1F548, // 0x50 - 0x2720, 0x2721, 0x262A, 0x262F, 0x1F549, 0x2638, 0x2648, 0x2649, // 0x58 - 0x264A, 0x264B, 0x264C, 0x264D, 0x264E, 0x264F, 0x2650, 0x2651, // 0x60 - 0x2652, 0x2653, 0x1F670, 0x1F675, 0x26AB, 0x1F53E, 0x25FC, 0x1F78F, // 0x68 - 0x1F790, 0x2751, 0x2752, 0x1F79F, 0x29EB, 0x25C6, 0x2756, 0x1F799, // 0x70 - 0x2327, 0x2BB9, 0x2318, 0x1F3F5, 0x1F3F6, 0x1F676, 0x1F677, 0x0000, // 0x78 - 0x1F10B, 0x2780, 0x2781, 0x2782, 0x2783, 0x2784, 0x2785, 0x2786, // 0x80 - 0x2787, 0x2788, 0x2789, 0x1F10C, 0x278A, 0x278B, 0x278C, 0x278D, // 0x88 - 0x278E, 0x278F, 0x2790, 0x2791, 0x2792, 0x2793, 0x1F662, 0x1F660, // 0x90 - 0x1F661, 0x1F663, 0x1F65E, 0x1F65C, 0x1F65D, 0x1F65F, 0x2219, 0x2022, // 0x98 - 0x2B1D, 0x2B58, 0x1F786, 0x1F788, 0x1F78A, 0x1F78B, 0x1F53F, 0x25AA, // 0xA0 - 0x1F78E, 0x1F7C1, 0x1F7C5, 0x2605, 0x1F7CB, 0x1F7CF, 0x1F7D3, 0x1F7D1, // 0xA8 - 0x2BD0, 0x2316, 0x2BCE, 0x2BCF, 0x2BD1, 0x272A, 0x2730, 0x1F550, // 0xB0 - 0x1F551, 0x1F552, 0x1F553, 0x1F554, 0x1F555, 0x1F556, 0x1F557, 0x1F558, // 0xB8 - 0x1F559, 0x1F55A, 0x1F55B, 0x2BB0, 0x2BB1, 0x2BB2, 0x2BB3, 0x2BB4, // 0xC0 - 0x2BB5, 0x2BB6, 0x2BB7, 0x1F66A, 0x1F66B, 0x1F655, 0x1F654, 0x1F657, // 0xC8 - 0x1F656, 0x1F650, 0x1F651, 0x1F652, 0x1F653, 0x232B, 0x2326, 0x2B98, // 0xD0 - 0x2B9A, 0x2B99, 0x2B9B, 0x2B88, 0x2B8A, 0x2B89, 0x2B8B, 0x1F868, // 0xD8 - 0x1F86A, 0x1F869, 0x1F86B, 0x1F86C, 0x1F86D, 0x1F86F, 0x1F86E, 0x1F878, // 0xE0 - 0x1F87A, 0x1F879, 0x1F87B, 0x1F87C, 0x1F87D, 0x1F87F, 0x1F87E, 0x21E6, // 0xE8 - 0x21E8, 0x21E7, 0x21E9, 0x2B04, 0x21F3, 0x2B01, 0x2B00, 0x2B03, // 0xF0 - 0x2B02, 0x1F8AC, 0x1F8AD, 0x1F5F6, 0x2713, 0x1F5F7, 0x1F5F9, 0x0000, // 0xF8 -]; - -const WINGDINGS_2: [u32; 224] = [ - 0x0020, 0x1F58A, 0x1F58B, 0x1F58C, 0x1F58D, 0x2704, 0x2700, 0x1F57E, // 0x20 - 0x1F57D, 0x1F5C5, 0x1F5C6, 0x1F5C7, 0x1F5C8, 0x1F5C9, 0x1F5CA, 0x1F5CB, // 0x28 - 0x1F5CC, 0x1F5CD, 0x1F4CB, 0x1F5D1, 0x1F5D4, 0x1F5B5, 0x1F5B6, 0x1F5B7, // 0x30 - 0x1F5B8, 0x1F5AD, 0x1F5AF, 0x1F5B1, 0x1F592, 0x1F593, 0x1F598, 0x1F599, // 0x38 - 0x1F59A, 0x1F59B, 0x1F448, 0x1F449, 0x1F59C, 0x1F59D, 0x1F59E, 0x1F59F, // 0x40 - 0x1F5A0, 0x1F5A1, 0x1F446, 0x1F447, 0x1F5A2, 0x1F5A3, 0x1F591, 0x1F5F4, // 0x48 - 0x1F5F8, 0x1F5F5, 0x2611, 0x2BBD, 0x2612, 0x2BBE, 0x2BBF, 0x1F6C7, // 0x50 - 0x29B8, 0x1F671, 0x1F674, 0x1F672, 0x1F673, 0x203D, 0x1F679, 0x1F67A, // 0x58 - 0x1F67B, 0x1F666, 0x1F664, 0x1F665, 0x1F667, 0x1F65A, 0x1F658, 0x1F659, // 0x60 - 0x1F65B, 0x24EA, 0x2460, 0x2461, 0x2462, 0x2463, 0x2464, 0x2465, // 0x68 - 0x2466, 0x2467, 0x2468, 0x2469, 0x24FF, 0x2776, 0x2777, 0x2778, // 0x70 - 0x2779, 0x277A, 0x277B, 0x277C, 0x277D, 0x277E, 0x277F, 0x0000, // 0x78 - 0x2609, 0x1F315, 0x263D, 0x263E, 0x2E3F, 0x271D, 0x1F547, 0x1F55C, // 0x80 - 0x1F55D, 0x1F55E, 0x1F55F, 0x1F560, 0x1F561, 0x1F562, 0x1F563, 0x1F564, // 0x88 - 0x1F565, 0x1F566, 0x1F567, 0x1F668, 0x1F669, 0x22C5, 0x1F784, 0x2981, // 0x90 - 0x25CF, 0x25CB, 0x1F785, 0x1F787, 0x1F789, 0x2299, 0x29BF, 0x1F78C, // 0x98 - 0x1F78D, 0x25FE, 0x25A0, 0x25A1, 0x1F791, 0x1F792, 0x1F793, 0x1F794, // 0xA0 - 0x25A3, 0x1F795, 0x1F796, 0x1F797, 0x1F798, 0x2B29, 0x2B25, 0x25C7, // 0xA8 - 0x1F79A, 0x25C8, 0x1F79B, 0x1F79C, 0x1F79D, 0x1F79E, 0x2B2A, 0x2B27, // 0xB0 - 0x25CA, 0x1F7A0, 0x25D6, 0x25D7, 0x2BCA, 0x2BCB, 0x2BC0, 0x2BC1, // 0xB8 - 0x2B1F, 0x2BC2, 0x2B23, 0x2B22, 0x2BC3, 0x2BC4, 0x1F7A1, 0x1F7A2, // 0xC0 - 0x1F7A3, 0x1F7A4, 0x1F7A5, 0x1F7A6, 0x1F7A7, 0x1F7A8, 0x1F7A9, 0x1F7AA, // 0xC8 - 0x1F7AB, 0x1F7AC, 0x1F7AD, 0x1F7AE, 0x1F7AF, 0x1F7B0, 0x1F7B1, 0x1F7B2, // 0xD0 - 0x1F7B3, 0x1F7B4, 0x1F7B5, 0x1F7B6, 0x1F7B7, 0x1F7B8, 0x1F7B9, 0x1F7BA, // 0xD8 - 0x1F7BB, 0x1F7BC, 0x1F7BD, 0x1F7BE, 0x1F7BF, 0x1F7C0, 0x1F7C2, 0x1F7C4, // 0xE0 - 0x1F7C6, 0x1F7C9, 0x1F7CA, 0x2736, 0x1F7CC, 0x1F7CE, 0x1F7D0, 0x1F7D2, // 0xE8 - 0x2739, 0x1F7C3, 0x1F7C7, 0x272F, 0x1F7CD, 0x1F7D4, 0x2BCC, 0x2BCD, // 0xF0 - 0x203B, 0x2042, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF8 -]; - -const WINGDINGS_3: [u32; 224] = [ - 0x0020, 0x2B60, 0x2B62, 0x2B61, 0x2B63, 0x2B66, 0x2B67, 0x2B69, // 0x20 - 0x2B68, 0x2B70, 0x2B72, 0x2B71, 0x2B73, 0x2B76, 0x2B78, 0x2B7B, // 0x28 - 0x2B7D, 0x2B64, 0x2B65, 0x2B6A, 0x2B6C, 0x2B6B, 0x2B6D, 0x2B4D, // 0x30 - 0x2BA0, 0x2BA1, 0x2BA2, 0x2BA3, 0x2BA4, 0x2BA5, 0x2BA6, 0x2BA7, // 0x38 - 0x2B90, 0x2B91, 0x2B92, 0x2B93, 0x2B80, 0x2B83, 0x2B7E, 0x2B7F, // 0x40 - 0x2B84, 0x2B86, 0x2B85, 0x2B87, 0x2B8F, 0x2B8D, 0x2B8E, 0x2B8C, // 0x48 - 0x2B6E, 0x2B6F, 0x238B, 0x2324, 0x2303, 0x2325, 0x2423, 0x237D, // 0x50 - 0x21EA, 0x2BB8, 0x1F8A0, 0x1F8A1, 0x1F8A2, 0x1F8A3, 0x1F8A4, 0x1F8A5, // 0x58 - 0x1F8A6, 0x1F8A7, 0x1F8A8, 0x1F8A9, 0x1F8AA, 0x1F8AB, 0x1F850, 0x1F852, // 0x60 - 0x1F851, 0x1F853, 0x1F854, 0x1F855, 0x1F857, 0x1F856, 0x1F858, 0x1F859, // 0x68 - 0x25B2, 0x25BC, 0x25B3, 0x25BD, 0x25C0, 0x25B6, 0x25C1, 0x25B7, // 0x70 - 0x25E3, 0x25E2, 0x25E4, 0x25E5, 0x1F780, 0x1F782, 0x1F781, 0x0000, // 0x78 - 0x1F783, 0x2BC5, 0x2BC6, 0x2BC7, 0x2BC8, 0x2B9C, 0x2B9E, 0x2B9D, // 0x80 - 0x2B9F, 0x1F810, 0x1F812, 0x1F811, 0x1F813, 0x1F814, 0x1F816, 0x1F815, // 0x88 - 0x1F817, 0x1F818, 0x1F81A, 0x1F819, 0x1F81B, 0x1F81C, 0x1F81E, 0x1F81D, // 0x90 - 0x1F81F, 0x1F800, 0x1F802, 0x1F801, 0x1F803, 0x1F804, 0x1F806, 0x1F805, // 0x98 - 0x1F807, 0x1F808, 0x1F80A, 0x1F809, 0x1F80B, 0x1F820, 0x1F822, 0x1F824, // 0xA0 - 0x1F826, 0x1F828, 0x1F82A, 0x1F82C, 0x1F89C, 0x1F89D, 0x1F89E, 0x1F89F, // 0xA8 - 0x1F82E, 0x1F830, 0x1F832, 0x1F834, 0x1F836, 0x1F838, 0x1F83A, 0x1F839, // 0xB0 - 0x1F83B, 0x1F898, 0x1F89A, 0x1F899, 0x1F89B, 0x1F83C, 0x1F83E, 0x1F83D, // 0xB8 - 0x1F83F, 0x1F840, 0x1F842, 0x1F841, 0x1F843, 0x1F844, 0x1F846, 0x1F845, // 0xC0 - 0x1F847, 0x2BA8, 0x2BA9, 0x2BAA, 0x2BAB, 0x2BAC, 0x2BAD, 0x2BAE, // 0xC8 - 0x2BAF, 0x1F860, 0x1F862, 0x1F861, 0x1F863, 0x1F864, 0x1F865, 0x1F867, // 0xD0 - 0x1F866, 0x1F870, 0x1F872, 0x1F871, 0x1F873, 0x1F874, 0x1F875, 0x1F877, // 0xD8 - 0x1F876, 0x1F880, 0x1F882, 0x1F881, 0x1F883, 0x1F884, 0x1F885, 0x1F887, // 0xE0 - 0x1F886, 0x1F890, 0x1F892, 0x1F891, 0x1F893, 0x1F894, 0x1F896, 0x1F895, // 0xE8 - 0x1F897, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF0 - 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, // 0xF8 -]; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn both_storage_forms_map_alike() { - for f in ["Symbol", "Wingdings", "Wingdings 2", "Wingdings 3", "Webdings"] { - assert!(is_symbol_font(f)); - for code in 0x20u32..=0xFF { - let (Some(pua), Some(plain)) = (char::from_u32(0xF000 + code), char::from_u32(code)) else { continue }; - if code == 0xAD { - assert_eq!(symbol_char(f, plain), None); - continue; - } - assert_eq!(symbol_char(f, pua), symbol_char(f, plain), "{f} {code:#X}"); - } - } - } - - #[test] - fn pcl6_symbol_encoding() { - for family in ["Symbol (PCL6)", " symbol (pcl6) "] { - assert!(is_symbol_font(family)); - for code in 0x20..=0xFF { - let c = char::from_u32(code).unwrap(); - assert_eq!(symbol_char(family, c), symbol_char("Symbol", c)); - let pua = char::from_u32(0xF000 + code).unwrap(); - assert_eq!(symbol_char(family, pua), symbol_char("Symbol", pua)); - } - assert_eq!(symbol_char(family, '\u{F0B3}'), Some('≥')); - } - assert!(!is_symbol_font("Symbol (unknown)")); - } - - #[test] - fn symbol_uses_the_adobe_symbol_encoding() { - assert_eq!(symbol_char("Symbol", '\u{F061}'), Some('α')); - assert_eq!(symbol_char("symbol", 'a'), Some('α')); - assert_eq!(symbol_char("Symbol", 'm'), Some('μ')); - assert_eq!(symbol_char("Symbol", 'W'), Some('Ω')); - assert_eq!(symbol_char("Symbol", '\u{F0B7}'), Some('•')); - assert_eq!(symbol_char("Symbol", '\u{F0A5}'), Some('∞')); - assert_eq!(symbol_char("Symbol", '\u{F0D6}'), Some('√')); - // The radical extender is private use in the Adobe table: no equivalent. - assert_eq!(symbol_char("Symbol", '\u{F060}'), None); - } - - #[test] - fn wingdings_codes() { - assert_eq!(symbol_char("Wingdings", '\u{F04A}'), Some('☺')); - assert_eq!(symbol_char("Wingdings", 'J'), Some('☺')); - assert_eq!(symbol_char("Wingdings", 'L'), Some('☹')); - assert_eq!(symbol_char("Wingdings", '\u{F0FC}'), Some('✓')); - assert_eq!(symbol_char("Wingdings", '\u{F0FE}'), Some('\u{1F5F9}')); - assert_eq!(symbol_char("Wingdings", '\u{F06E}'), Some('◼')); - assert_eq!(symbol_char("Wingdings", '\u{F0A7}'), Some('▪')); - assert_eq!(symbol_char("Wingdings", '\u{F076}'), Some('❖')); - assert_eq!(symbol_char("Wingdings", '\u{F028}'), Some('\u{1F57F}')); - // The one character Unicode didn't encode. - assert_eq!(symbol_char("Wingdings", '\u{F0FF}'), None); - } - - #[test] - fn wingdings_2_3_and_webdings_codes() { - assert_eq!(symbol_char("Wingdings 2", '\u{F050}'), Some('\u{1F5F8}')); - assert_eq!(symbol_char("Wingdings 2", 'O'), Some('\u{1F5F4}')); - assert_eq!(symbol_char("Wingdings 2", '\u{F0FA}'), None); - assert_eq!(symbol_char("Wingdings 3", '\u{F021}'), Some('⭠')); - assert_eq!(symbol_char("Wingdings 3", '\u{F0F1}'), None); - assert_eq!(symbol_char("Webdings", '\u{F059}'), Some('\u{1F394}')); - assert_eq!(symbol_char("Webdings", 'a'), Some('\u{2714}')); - assert_eq!(symbol_char("Webdings", '\u{F0FF}'), Some('\u{1F54A}')); - } - - #[test] - fn other_fonts_and_characters_are_left_alone() { - assert!(!is_symbol_font("Arial")); - assert_eq!(symbol_char("Arial", '\u{F04A}'), None); - assert_eq!(symbol_char("Wingdings", '\u{1F600}'), None); - assert_eq!(symbol_char("Wingdings", '\u{10FFFF}'), None); - assert_eq!(symbol_char("Wingdings", '\u{1F}'), None); - assert_eq!(map_text("Wingdings", "J\u{F04C}é"), "☺☹\u{1F879}"); - assert_eq!(map_text("Arial", "J\u{F04C}"), "J\u{F04C}"); - } -} diff --git a/crates/xlsx/README.md b/crates/xlsx/README.md index 8d831eec2..953a7ee5a 100644 --- a/crates/xlsx/README.md +++ b/crates/xlsx/README.md @@ -35,6 +35,15 @@ from those fields, keeps the rest of each part's tree in place, and writes the k were. Part names and relationship ids are kept, so a file read and written without changes points where it did, and writing it twice gives the same bytes. The calculation chain is dropped (Excel rebuilds it). `XlsxKind` is the workbook part's content type (`.xlsx`, `.xlsm`, `.xltx`, `.xltm`). +Typed worksheet features (`SheetPart::features`), each mirroring its element with optional +attributes and the unnamed ones kept: sheet views (panes, selections), print setup (options, +margins, page setup, header and footer, page breaks), data validation (also read from the `x14` +extension), conditional formats (colour scales, data bars, icon sets, rule extensions kept), +hyperlinks (with their URLs), the legacy drawing. Comments (`Comments`, from the comments part) +and the note shapes of the VML drawing (`NoteShape`). A sheet that gains comments and has no VML +drawing gets one (`notes_vml`, in a shape block no kept drawing uses); an existing VML drawing is +kept as read. Extensions in `extLst` are carried through unchanged. + The parts this crate writes use the transitional namespaces; a strict file's kept parts stay as they were. A binary workbook is not written. diff --git a/crates/xlsx/src/document.rs b/crates/xlsx/src/document.rs index 25b75daeb..859630301 100644 --- a/crates/xlsx/src/document.rs +++ b/crates/xlsx/src/document.rs @@ -3,6 +3,7 @@ use craft_opc::{ContentTypes, PackageOut, RT_BASE, Rel, RelOut, Rels, RelsOut, relative, rels_path}; +use crate::features::{Comments, NoteShape, notes_vml}; use crate::sheet::{CellValue, Row, SheetPart}; use crate::strings::StringItem; use crate::styles::Styles; @@ -12,6 +13,8 @@ use crate::{Error, Limits, Package}; const CT_WORKSHEET: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"; const CT_STRINGS: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"; const CT_STYLES: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"; +const CT_COMMENTS: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml"; +const CT_VML: &str = "application/vnd.openxmlformats-officedocument.vmlDrawing"; /// The relationship type namespace of strict files: kept relationships are written with the /// transitional one, as the parts this crate writes are. const RT_STRICT: &str = "http://purl.oclc.org/ooxml/officeDocument/relationships/"; @@ -64,11 +67,16 @@ impl XlsxKind { } } -/// One worksheet: its rows and the rest of its part. +/// One worksheet: its rows, the rest of its part, and its comments. #[derive(Clone, Debug, Default)] pub struct Worksheet { pub rows: Vec, pub part: SheetPart, + /// The comments part, written from here. `None` removes it. + pub comments: Option, + /// How the comments are shown, from the legacy VML drawing. The drawing itself is kept as + /// read; these are used only to draw the comments of a sheet that has no VML drawing. + pub notes: Vec, } /// A part carried through unchanged: drawings, charts, the theme, document properties, a VBA @@ -131,7 +139,11 @@ impl Document { rows.push(row); Ok(()) }) { - Ok(part) => worksheets.push(Some(Worksheet { rows, part })), + Ok(part) => { + let comments = p.comments(&part)?; + let notes = if comments.is_some() { p.note_shapes(&part)? } else { Vec::new() }; + worksheets.push(Some(Worksheet { rows, part, comments, notes })); + } Err(Error::TooLarge(m)) => return Err(Error::TooLarge(m)), Err(e) => { p.warn(format!("sheet \"{}\" kept unread: {e}", entry.name)); @@ -144,6 +156,9 @@ impl Document { for ws in worksheets.iter().flatten() { made.push(ws.part.part.clone()); made.push(rels_path(&ws.part.part)); + if ws.comments.is_some() { + made.extend(ws.part.rels.first("comments").filter(|r| !r.external).map(|r| r.target.clone())); + } } made.extend(workbook_rels.list.iter().filter(|r| !r.external && regenerated(r)).map(|r| r.target.clone())); let opc = p.opc(); @@ -223,11 +238,9 @@ impl Document { package.add("officeDocument", book_part, false); out.rels("", &package); } + let mut blocks = vml_blocks(&self.parts); for (name, ws) in &sheet_parts { - out.xml_part(name, CT_WORKSHEET, ws.part.to_xml(&ws.rows)); - let mut sheet_rels = RelsOut::default(); - sheet_rels.items.extend(ws.part.rels.list.iter().map(kept)); - out.rels(name, &sheet_rels); + write_sheet(&mut out, name, ws, &mut taken, &mut blocks); } if let Some(name) = &strings { let count: usize = @@ -252,6 +265,61 @@ impl Document { } } +/// A worksheet, its relationships, and its comments with a VML drawing to show them when it has +/// none. A hyperlink to a URL its relationship does not lead to gets a relationship of its own. +fn write_sheet(out: &mut PackageOut, name: &str, ws: &Worksheet, taken: &mut Vec, blocks: &mut Vec) { + let mut part = ws.part.clone(); + let mut rels = RelsOut::default(); + rels.items.extend(ws.part.rels.list.iter().filter(|r| ws.comments.is_some() || r.kind() != "comments").map(kept)); + for h in &mut part.features.hyperlinks { + let Some(url) = &h.url else { continue }; + let ok = h.rel_id.as_deref().and_then(|id| ws.part.rels.by_id(id)).is_some_and(|r| r.external && r.target == *url); + if !ok { + let id = fresh_id(&rels); + rels.items.push(RelOut { id: id.clone(), ty: format!("{RT_BASE}hyperlink"), target: url.clone(), external: true }); + h.rel_id = Some(id); + } + } + if let Some(c) = &ws.comments { + let target = match ws.part.rels.first("comments").filter(|r| !r.external) { + Some(r) => r.target.clone(), + None => { + let target = fresh(taken, |n| format!("xl/comments{n}.xml")); + let id = fresh_id(&rels); + rels.items.push(RelOut { id, ty: format!("{RT_BASE}comments"), target: relative(name, &target), external: false }); + target + } + }; + taken.push(target.to_ascii_lowercase()); + out.xml_part(&target, CT_COMMENTS, c.to_xml()); + if !rels.items.iter().any(|r| r.ty.ends_with("/vmlDrawing")) && !c.comments.is_empty() { + let block = (1..).find(|b| !blocks.contains(b)).unwrap_or(1); + blocks.push(block); + let vml = fresh(taken, |n| format!("xl/drawings/vmlDrawing{n}.vml")); + taken.push(vml.to_ascii_lowercase()); + let id = fresh_id(&rels); + rels.items.push(RelOut { id: id.clone(), ty: format!("{RT_BASE}vmlDrawing"), target: relative(name, &vml), external: false }); + part.features.legacy_drawing = Some(id); + out.binary(&vml, CT_VML, notes_vml(&c.comments, &ws.notes, block)); + } + } + out.xml_part(name, CT_WORKSHEET, part.to_xml(&ws.rows)); + out.rels(name, &rels); +} + +/// The shape-id blocks (`o:idmap`) the kept VML drawings use, so a new drawing takes another. +fn vml_blocks(parts: &[KeptPart]) -> Vec { + let mut used = Vec::new(); + for p in parts.iter().filter(|p| p.name.to_ascii_lowercase().ends_with(".vml")) { + let text = String::from_utf8_lossy(&p.bytes); + for (_, after) in text.match_indices("idmap").map(|(i, _)| text.split_at(i)) { + let data = after.split("data=\"").nth(1).and_then(|d| d.split('"').next()).unwrap_or(""); + used.extend(data.split(',').filter_map(|n| n.trim().parse::().ok())); + } + } + used +} + /// A workbook relationship to a part that [`Document::write`] makes again (or drops). fn regenerated(r: &Rel) -> bool { matches!(r.kind(), "sharedStrings" | "styles" | "calcChain") diff --git a/crates/xlsx/src/features.rs b/crates/xlsx/src/features.rs new file mode 100644 index 000000000..19fd56401 --- /dev/null +++ b/crates/xlsx/src/features.rs @@ -0,0 +1,919 @@ +//! The worksheet's typed features, each mirroring its SpreadsheetML element: sheet views, print +//! setup, data validation, conditional formatting, hyperlinks (ECMA-376 Part 1, 18.3), and the +//! comments part (18.7) with the legacy note shapes Excel draws comments with. +//! +//! An attribute the file may leave out is an `Option` (`None`: not written, the schema's +//! default applies), so a feature reads and writes back as it was. Attributes a type does not +//! name are kept in its `attrs` (`xr:uid` and the like). + +use craft_sheet::{CellRef, MAX_COLS, MAX_ROWS, Range, col_letters}; +use craft_xml::{El, Node}; + +use crate::strings::StringItem; +use crate::styles::Color; +use crate::write::num; + +/// An attribute value type. +trait Attr: Sized { + fn parse(s: &str) -> Option; + fn spell(&self) -> String; +} + +impl Attr for bool { + fn parse(s: &str) -> Option { + craft_xml::value::to_bool(s) + } + fn spell(&self) -> String { + if *self { "1" } else { "0" }.into() + } +} +impl Attr for u32 { + fn parse(s: &str) -> Option { + craft_xml::value::to_u32(s) + } + fn spell(&self) -> String { + self.to_string() + } +} +impl Attr for i32 { + fn parse(s: &str) -> Option { + craft_xml::value::to_i32(s) + } + fn spell(&self) -> String { + self.to_string() + } +} +impl Attr for f64 { + fn parse(s: &str) -> Option { + craft_xml::value::to_f64(s) + } + fn spell(&self) -> String { + num(*self) + } +} +impl Attr for String { + fn parse(s: &str) -> Option { + Some(s.to_string()) + } + fn spell(&self) -> String { + self.clone() + } +} + +/// The attributes of `e` not in `named`. A container's `count` (written from the items) and +/// the `ref` and `sqref` each type reads into a field of its own are left out. +fn rest(e: &El, named: &[&str]) -> Vec<(String, String)> { + e.attrs.iter().filter(|(k, _)| !matches!(k.as_str(), "count" | "ref" | "sqref") && !named.contains(&k.as_str())).cloned().collect() +} + +/// A struct whose `Option` fields are the element's attributes (`field: type = "xmlName"`), with +/// further fields after `+` that the element's own reader fills, and `attrs` for the rest. +macro_rules! element { + ( + $(#[$m:meta])* pub struct $name:ident { + $($(#[$fm:meta])* $f:ident: $t:ty = $x:literal,)* + } + $(+ { $($(#[$em:meta])* $e:ident: $et:ty,)* })? + ) => { + $(#[$m])* + #[derive(Clone, Debug, Default, PartialEq)] + pub struct $name { + $($(#[$fm])* pub $f: Option<$t>,)* + $($($(#[$em])* pub $e: $et,)*)? + /// The other attributes as written. + pub attrs: Vec<(String, String)>, + } + + impl $name { + const NAMED: &'static [&'static str] = &[$($x),*]; + + #[allow(clippy::needless_update)] + fn read_attrs(el: &El) -> $name { + $name { $($f: el.attr($x).and_then(<$t as Attr>::parse),)* attrs: rest(el, Self::NAMED), ..$name::default() } + } + + fn write_attrs(&self, el: &mut El) { + $(if let Some(v) = &self.$f { el.set_attr($x, v.spell()); })* + el.attrs.extend(self.attrs.iter().cloned()); + } + } + }; +} + +/// `sqref`: ranges separated by spaces. Unreadable ranges are dropped. +pub fn parse_sqref(s: &str) -> Vec { + s.split_whitespace().filter_map(Range::parse).collect() +} + +/// A range as a reference writes it: whole columns as `A:C`, whole rows as `1:3`. +pub fn range_ref(r: &Range) -> String { + let full_cols = r.first.row == 0 && r.last.row == MAX_ROWS - 1; + let full_rows = r.first.col == 0 && r.last.col == MAX_COLS - 1; + match (full_cols, full_rows) { + (true, false) => format!("{}:{}", col_letters(r.first.col), col_letters(r.last.col)), + (false, true) => format!("{}:{}", r.first.row + 1, r.last.row + 1), + _ => r.a1(), + } +} + +fn sqref(ranges: &[Range]) -> String { + ranges.iter().map(range_ref).collect::>().join(" ") +} + +fn kids(e: &El, except: &[&str]) -> Vec { + e.elements().filter(|c| !except.contains(&c.name.as_str())).cloned().collect() +} + +// ------------------------------------------------------------------------------- sheet views + +element! { + /// ``: the split or frozen panes of a view. + pub struct Pane { + x_split: f64 = "xSplit", + y_split: f64 = "ySplit", + top_left_cell: String = "topLeftCell", + active_pane: String = "activePane", + state: String = "state", + } +} + +element! { + /// ``. + pub struct Selection { + pane: String = "pane", + active_cell: String = "activeCell", + active_cell_id: u32 = "activeCellId", + } + + { + sqref: Vec, + } +} + +element! { + /// `` (18.3.1.87). + pub struct SheetView { + window_protection: bool = "windowProtection", + show_formulas: bool = "showFormulas", + show_grid_lines: bool = "showGridLines", + show_row_col_headers: bool = "showRowColHeaders", + show_zeros: bool = "showZeros", + right_to_left: bool = "rightToLeft", + tab_selected: bool = "tabSelected", + show_ruler: bool = "showRuler", + show_outline_symbols: bool = "showOutlineSymbols", + default_grid_color: bool = "defaultGridColor", + show_white_space: bool = "showWhiteSpace", + /// `normal`, `pageBreakPreview`, `pageLayout`. + view: String = "view", + top_left_cell: String = "topLeftCell", + color_id: u32 = "colorId", + zoom_scale: u32 = "zoomScale", + zoom_scale_normal: u32 = "zoomScaleNormal", + zoom_scale_sheet_layout_view: u32 = "zoomScaleSheetLayoutView", + zoom_scale_page_layout_view: u32 = "zoomScalePageLayoutView", + workbook_view_id: u32 = "workbookViewId", + } + + { + pane: Option, + selections: Vec, + /// `pivotSelection` and `extLst`, as written. + other: Vec, + } +} + +impl SheetView { + pub fn read(e: &El) -> SheetView { + let selection = |s: &El| Selection { sqref: s.attr("sqref").map(parse_sqref).unwrap_or_default(), ..Selection::read_attrs(s) }; + SheetView { + pane: e.child("pane").map(Pane::read_attrs), + selections: e.children("selection").map(selection).collect(), + other: kids(e, &["pane", "selection"]), + ..SheetView::read_attrs(e) + } + } + + pub fn to_el(&self) -> El { + let mut e = El::new("sheetView"); + self.write_attrs(&mut e); + if let Some(p) = &self.pane { + let mut pe = El::new("pane"); + p.write_attrs(&mut pe); + e.push(pe); + } + for s in &self.selections { + let mut se = El::new("selection"); + s.write_attrs(&mut se); + if !s.sqref.is_empty() { + se.set_attr("sqref", sqref(&s.sqref)); + } + e.push(se); + } + e.children.extend(self.other.iter().cloned().map(Node::El)); + e + } +} + +// ------------------------------------------------------------------------------- printing + +element! { + /// ``. + pub struct PrintOptions { + horizontal_centered: bool = "horizontalCentered", + vertical_centered: bool = "verticalCentered", + headings: bool = "headings", + grid_lines: bool = "gridLines", + grid_lines_set: bool = "gridLinesSet", + } +} + +element! { + /// ``, inches. + pub struct PageMargins { + left: f64 = "left", + right: f64 = "right", + top: f64 = "top", + bottom: f64 = "bottom", + header: f64 = "header", + footer: f64 = "footer", + } +} + +element! { + /// ``. + pub struct PageSetup { + paper_size: u32 = "paperSize", + /// With its unit (`297mm`). + paper_height: String = "paperHeight", + paper_width: String = "paperWidth", + scale: u32 = "scale", + first_page_number: u32 = "firstPageNumber", + fit_to_width: u32 = "fitToWidth", + fit_to_height: u32 = "fitToHeight", + /// `downThenOver`, `overThenDown`. + page_order: String = "pageOrder", + /// `default`, `portrait`, `landscape`. + orientation: String = "orientation", + use_printer_defaults: bool = "usePrinterDefaults", + black_and_white: bool = "blackAndWhite", + draft: bool = "draft", + /// `none`, `asDisplayed`, `atEnd`. + cell_comments: String = "cellComments", + use_first_page_number: bool = "useFirstPageNumber", + /// `displayed`, `blank`, `dash`, `NA`. + errors: String = "errors", + horizontal_dpi: u32 = "horizontalDpi", + vertical_dpi: u32 = "verticalDpi", + copies: u32 = "copies", + /// The printer settings part. + rel_id: String = "r:id", + } +} + +element! { + /// ``: the header and footer texts with their `&` codes as written. + pub struct HeaderFooter { + different_odd_even: bool = "differentOddEven", + different_first: bool = "differentFirst", + scale_with_doc: bool = "scaleWithDoc", + align_with_margins: bool = "alignWithMargins", + } + + { + odd_header: Option, + odd_footer: Option, + even_header: Option, + even_footer: Option, + first_header: Option, + first_footer: Option, + } +} + +const HEADER_FOOTER: [&str; 6] = ["oddHeader", "oddFooter", "evenHeader", "evenFooter", "firstHeader", "firstFooter"]; + +impl HeaderFooter { + fn texts(&self) -> [&Option; 6] { + [&self.odd_header, &self.odd_footer, &self.even_header, &self.even_footer, &self.first_header, &self.first_footer] + } + + pub fn read(e: &El) -> HeaderFooter { + let t = |n: &str| e.child(n).map(|c| c.text().into_owned()); + HeaderFooter { + odd_header: t("oddHeader"), + odd_footer: t("oddFooter"), + even_header: t("evenHeader"), + even_footer: t("evenFooter"), + first_header: t("firstHeader"), + first_footer: t("firstFooter"), + ..HeaderFooter::read_attrs(e) + } + } + + pub fn to_el(&self) -> El { + let mut e = El::new("headerFooter"); + self.write_attrs(&mut e); + for (name, text) in HEADER_FOOTER.iter().zip(self.texts()) { + if let Some(t) = text { + let mut c = El::new(*name); + if t.starts_with(' ') || t.ends_with(' ') { + c.set_attr("xml:space", "preserve"); + } + e.push(if t.is_empty() { c } else { c.with_text(t.as_str()) }); + } + } + e + } +} + +element! { + /// ``: a page break before row or column `id` (zero-based is `id - 1`: the break + /// follows that many rows or columns). + pub struct Break { + id: u32 = "id", + min: u32 = "min", + max: u32 = "max", + /// Inserted by hand. + man: bool = "man", + /// Inserted for a pivot table. + pt: bool = "pt", + } +} + +fn breaks_el(name: &str, breaks: &[Break]) -> Option { + if breaks.is_empty() { + return None; + } + let manual = breaks.iter().filter(|b| b.man == Some(true)).count(); + let mut e = El::new(name).with_attr("count", breaks.len()).with_attr("manualBreakCount", manual); + for b in breaks { + let mut be = El::new("brk"); + b.write_attrs(&mut be); + e.push(be); + } + Some(e) +} + +/// How a worksheet prints. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Print { + pub options: Option, + pub margins: Option, + pub setup: Option, + pub header_footer: Option, + pub row_breaks: Vec, + pub col_breaks: Vec, +} + +// ------------------------------------------------------------------------------- data validation + +element! { + /// `` (18.3.1.32). The formulas are as the file spells them, without `=`. + pub struct DataValidation { + /// `none`, `whole`, `decimal`, `list`, `date`, `time`, `textLength`, `custom`. + kind: String = "type", + /// `stop`, `warning`, `information`. + error_style: String = "errorStyle", + ime_mode: String = "imeMode", + operator: String = "operator", + allow_blank: bool = "allowBlank", + /// Set: the in-cell list arrow is *hidden*. + show_drop_down: bool = "showDropDown", + show_input_message: bool = "showInputMessage", + show_error_message: bool = "showErrorMessage", + error_title: String = "errorTitle", + error: String = "error", + prompt_title: String = "promptTitle", + prompt: String = "prompt", + } + + { + sqref: Vec, + formula1: Option, + formula2: Option, + } +} + +impl DataValidation { + /// A `dataValidation` of the main namespace, or of an `x14:dataValidations` extension (its + /// formulas in `xm:f` and its ranges in `xm:sqref`). + pub fn read(v: &El) -> DataValidation { + let formula = |n: &str| { + let f = v.child(n).or_else(|| v.child(format!("x14:{n}").as_str()))?; + Some(f.child("xm:f").map_or_else(|| f.text(), |x| x.text()).trim().to_string()) + }; + let sq = v.attr("sqref").map(str::to_string).or_else(|| v.child("xm:sqref").map(|s| s.text().into_owned())); + DataValidation { + sqref: sq.as_deref().map(parse_sqref).unwrap_or_default(), + formula1: formula("formula1"), + formula2: formula("formula2"), + ..DataValidation::read_attrs(v) + } + } + + /// The main-namespace element. + pub fn to_el(&self) -> El { + let mut e = El::new("dataValidation"); + self.write_attrs(&mut e); + e.set_attr("sqref", sqref(&self.sqref)); + for (n, f) in [("formula1", &self.formula1), ("formula2", &self.formula2)] { + if let Some(f) = f { + e.push(El::new(n).with_text(f.as_str())); + } + } + e + } +} + +element! { + /// ``. + pub struct DataValidations { + disable_prompts: bool = "disablePrompts", + x_window: u32 = "xWindow", + y_window: u32 = "yWindow", + } + + { + items: Vec, + } +} + +// ------------------------------------------------------------------------------- conditional formats + +element! { + /// ``: a threshold of a colour scale, data bar or icon set. + pub struct Cfvo { + /// `num`, `percent`, `max`, `min`, `formula`, `percentile`. + kind: String = "type", + val: String = "val", + gte: bool = "gte", + } +} + +fn cfvos(e: &El) -> Vec { + e.children("cfvo").map(Cfvo::read_attrs).collect() +} + +fn push_cfvos(e: &mut El, cfvos: &[Cfvo]) { + for c in cfvos { + let mut ce = El::new("cfvo"); + c.write_attrs(&mut ce); + e.push(ce); + } +} + +/// ``. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ColorScale { + pub cfvos: Vec, + pub colors: Vec, +} + +element! { + /// ``. + pub struct DataBar { + min_length: u32 = "minLength", + max_length: u32 = "maxLength", + show_value: bool = "showValue", + } + + { + cfvos: Vec, + color: Option, + } +} + +element! { + /// ``. + pub struct IconSet { + icon_set: String = "iconSet", + show_value: bool = "showValue", + percent: bool = "percent", + reverse: bool = "reverse", + } + + { + cfvos: Vec, + } +} + +element! { + /// `` (18.3.1.10). + pub struct CfRule { + /// `expression`, `cellIs`, `colorScale`, `dataBar`, `iconSet`, `top10`, `uniqueValues`, + /// `duplicateValues`, `containsText`, ..., `aboveAverage`. + kind: String = "type", + /// An index into [`crate::Styles::dxfs`]. + dxf_id: u32 = "dxfId", + priority: u32 = "priority", + stop_if_true: bool = "stopIfTrue", + above_average: bool = "aboveAverage", + percent: bool = "percent", + bottom: bool = "bottom", + operator: String = "operator", + text: String = "text", + time_period: String = "timePeriod", + rank: u32 = "rank", + std_dev: i32 = "stdDev", + equal_average: bool = "equalAverage", + } + + { + /// `` texts, as written. + formulas: Vec, + color_scale: Option, + data_bar: Option, + icon_set: Option, + /// The rule's `extLst` (the id that links a data bar to its Excel 2010 extension). + ext: Option, + } +} + +impl CfRule { + pub fn read(r: &El) -> CfRule { + CfRule { + formulas: r.children("formula").map(|f| f.text().into_owned()).collect(), + color_scale: r.child("colorScale").map(|c| ColorScale { cfvos: cfvos(c), colors: c.children("color").map(Color::read).collect() }), + data_bar: r.child("dataBar").map(|d| DataBar { cfvos: cfvos(d), color: d.child("color").map(Color::read), ..DataBar::read_attrs(d) }), + icon_set: r.child("iconSet").map(|i| IconSet { cfvos: cfvos(i), ..IconSet::read_attrs(i) }), + ext: r.child("extLst").cloned(), + ..CfRule::read_attrs(r) + } + } + + pub fn to_el(&self) -> El { + let mut e = El::new("cfRule"); + self.write_attrs(&mut e); + for f in &self.formulas { + e.push(El::new("formula").with_text(f.as_str())); + } + if let Some(c) = &self.color_scale { + let mut ce = El::new("colorScale"); + push_cfvos(&mut ce, &c.cfvos); + c.colors.iter().for_each(|col| ce.push(col.to_el("color"))); + e.push(ce); + } + if let Some(d) = &self.data_bar { + let mut de = El::new("dataBar"); + d.write_attrs(&mut de); + push_cfvos(&mut de, &d.cfvos); + if let Some(c) = &d.color { + de.push(c.to_el("color")); + } + e.push(de); + } + if let Some(i) = &self.icon_set { + let mut ie = El::new("iconSet"); + i.write_attrs(&mut ie); + push_cfvos(&mut ie, &i.cfvos); + e.push(ie); + } + if let Some(x) = &self.ext { + e.push(x.clone()); + } + e + } +} + +element! { + /// ``: rules over ranges. + pub struct ConditionalFormatting { + pivot: bool = "pivot", + } + + { + sqref: Vec, + rules: Vec, + ext: Option, + } +} + +impl ConditionalFormatting { + pub fn read(e: &El) -> ConditionalFormatting { + ConditionalFormatting { + sqref: e.attr("sqref").map(parse_sqref).unwrap_or_default(), + rules: e.children("cfRule").map(CfRule::read).collect(), + ext: e.child("extLst").cloned(), + ..ConditionalFormatting::read_attrs(e) + } + } + + pub fn to_el(&self) -> El { + let mut e = El::new("conditionalFormatting"); + self.write_attrs(&mut e); + e.set_attr("sqref", sqref(&self.sqref)); + self.rules.iter().for_each(|r| e.push(r.to_el())); + if let Some(x) = &self.ext { + e.push(x.clone()); + } + e + } +} + +// ------------------------------------------------------------------------------- hyperlinks + +element! { + /// ``: to a URL through a relationship, or to a place in the workbook. + pub struct Hyperlink { + rel_id: String = "r:id", + /// A place in the workbook (`Sheet2!A1`, a defined name). + location: String = "location", + tooltip: String = "tooltip", + display: String = "display", + } + + { + range: Range, + /// The relationship's target when it points outside the package, read from the sheet's + /// relationships. On writing, a link whose `rel_id` does not lead to this URL gets a + /// relationship of its own. + url: Option, + } +} + +impl Hyperlink { + fn read(e: &El, rels: &craft_opc::Rels) -> Option { + let range = Range::parse(e.attr("ref")?)?; + let h = Hyperlink::read_attrs(e); + let url = h.rel_id.as_deref().and_then(|id| rels.by_id(id)).filter(|r| r.external).map(|r| r.target.clone()); + Some(Hyperlink { range, url, ..h }) + } + + pub fn to_el(&self) -> El { + let mut e = El::new("hyperlink").with_attr("ref", range_ref(&self.range)); + self.write_attrs(&mut e); + e + } +} + +// ------------------------------------------------------------------------------- the lift + +/// The typed features of a worksheet, lifted from its tree. Each one listed here is written from +/// these fields; its element in [`crate::SheetPart::root`] is not. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Features { + pub views: Vec, + pub print: Print, + pub validations: Option, + pub conditional_formats: Vec, + pub hyperlinks: Vec, + /// `legacyDrawing/@r:id`: the VML part the sheet's notes and form controls are drawn in. + pub legacy_drawing: Option, +} + +impl Features { + pub(crate) fn read(root: &El, rels: &craft_opc::Rels) -> Features { + let attrs_of = |n: &str| root.child(n); + Features { + views: root.child("sheetViews").map(|v| v.children("sheetView").map(SheetView::read).collect()).unwrap_or_default(), + print: Print { + options: attrs_of("printOptions").map(PrintOptions::read_attrs), + margins: attrs_of("pageMargins").map(PageMargins::read_attrs), + setup: attrs_of("pageSetup").map(PageSetup::read_attrs), + header_footer: attrs_of("headerFooter").map(HeaderFooter::read), + row_breaks: root.child("rowBreaks").map(|b| b.children("brk").map(Break::read_attrs).collect()).unwrap_or_default(), + col_breaks: root.child("colBreaks").map(|b| b.children("brk").map(Break::read_attrs).collect()).unwrap_or_default(), + }, + validations: root.child("dataValidations").map(|d| DataValidations { + items: d.children("dataValidation").map(DataValidation::read).collect(), + ..DataValidations::read_attrs(d) + }), + conditional_formats: root.children("conditionalFormatting").map(ConditionalFormatting::read).collect(), + hyperlinks: root + .child("hyperlinks") + .map(|h| h.children("hyperlink").filter_map(|e| Hyperlink::read(e, rels)).collect()) + .unwrap_or_default(), + legacy_drawing: root.child("legacyDrawing").and_then(|l| l.attr_or_plain("r:id")).map(str::to_string), + } + } + + /// The elements these fields own, for [`crate::write::assemble`]. Conditional formats are + /// one element per block: the first block takes the place of the file's first, the others + /// follow it. + pub(crate) fn owned(&self) -> Vec<(&'static str, Vec)> { + let mut views = El::new("sheetViews"); + self.views.iter().for_each(|v| views.push(v.to_el())); + let el = |name: &str, write: &dyn Fn(&mut El)| { + let mut e = El::new(name); + write(&mut e); + e + }; + let validations = self.validations.as_ref().filter(|v| !v.items.is_empty()).map(|v| { + let mut e = el("dataValidations", &|e| v.write_attrs(e)); + e.attrs.insert(0, ("count".into(), v.items.len().to_string())); + v.items.iter().for_each(|i| e.push(i.to_el())); + e + }); + let mut links = El::new("hyperlinks"); + self.hyperlinks.iter().for_each(|h| links.push(h.to_el())); + let p = &self.print; + vec![ + ("sheetViews", Vec::from_iter((!self.views.is_empty()).then_some(views))), + ("conditionalFormatting", self.conditional_formats.iter().map(ConditionalFormatting::to_el).collect()), + ("dataValidations", Vec::from_iter(validations)), + ("hyperlinks", Vec::from_iter((!self.hyperlinks.is_empty()).then_some(links))), + ("printOptions", Vec::from_iter(p.options.as_ref().map(|o| el("printOptions", &|e| o.write_attrs(e))))), + ("pageMargins", Vec::from_iter(p.margins.as_ref().map(|m| el("pageMargins", &|e| m.write_attrs(e))))), + ("pageSetup", Vec::from_iter(p.setup.as_ref().map(|s| el("pageSetup", &|e| s.write_attrs(e))))), + ("headerFooter", Vec::from_iter(p.header_footer.as_ref().map(HeaderFooter::to_el))), + ("rowBreaks", Vec::from_iter(breaks_el("rowBreaks", &p.row_breaks))), + ("colBreaks", Vec::from_iter(breaks_el("colBreaks", &p.col_breaks))), + ("legacyDrawing", Vec::from_iter(self.legacy_drawing.as_ref().map(|id| El::new("legacyDrawing").with_attr("r:id", id)))), + ] + } +} + +// ------------------------------------------------------------------------------- comments + +element! { + /// ``: a note on a cell. + pub struct Comment { + author_id: u32 = "authorId", + guid: String = "guid", + shape_id: u32 = "shapeId", + } + + { + at: CellRef, + text: StringItem, + /// `commentPr`, as written. + props: Option, + } +} + +/// The comments part of a worksheet (`xl/commentsN.xml`). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Comments { + pub authors: Vec, + pub comments: Vec, + /// The whole part, for what is not lifted out (extensions). + pub root: El, +} + +impl Comments { + /// Comments with no readable `ref` are dropped. + pub fn read(root: El) -> Comments { + let authors = root.child("authors").map(|a| a.children("author").map(|a| a.text().into_owned()).collect()).unwrap_or_default(); + let comments = root + .child("commentList") + .map(|l| { + l.children("comment") + .filter_map(|c| { + Some(Comment { + at: CellRef::parse(c.attr("ref")?)?, + text: c.child("text").map(StringItem::read).unwrap_or_default(), + props: c.child("commentPr").cloned(), + ..Comment::read_attrs(c) + }) + }) + .collect() + }) + .unwrap_or_default(); + Comments { authors, comments, root } + } + + /// The author of `c`. + pub fn author(&self, c: &Comment) -> Option<&str> { + self.authors.get(c.author_id? as usize).map(String::as_str) + } + + pub fn to_xml(&self) -> Vec { + let root = if self.root.name.is_empty() { El::new("comments") } else { self.root.clone() }; + let mut authors = El::new("authors"); + self.authors.iter().for_each(|a| authors.push(El::new("author").with_text(a.as_str()))); + let mut list = El::new("commentList"); + for c in &self.comments { + let mut e = El::new("comment").with_attr("ref", c.at.a1()); + c.write_attrs(&mut e); + e.push(c.text.to_el("text")); + if let Some(p) = &c.props { + e.push(p.clone()); + } + list.push(e); + } + let mut out = + crate::write::assemble(&root, &["authors", "commentList", "extLst"], vec![("authors", Some(authors)), ("commentList", Some(list))]); + crate::ns::declare(&mut out); + let mut w = craft_xml::Writer::document(); + w.tree(&out); + w.into_bytes() + } +} + +/// How a note's box is drawn, from its legacy VML shape. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct NoteShape { + pub at: CellRef, + /// Shown all the time (``), not only on hover. + pub visible: bool, + /// `x:Anchor`: left column, offset px, top row, offset px, right column, offset px, bottom + /// row, offset px. + pub anchor: Option<[u32; 8]>, + /// The shape's width and height in points, from its style. + pub size_pt: Option<(f64, f64)>, +} + +impl NoteShape { + /// The note shapes of a VML drawing read with [`crate::ns::CANONICAL`]. Shapes may sit in + /// groups; other shapes (form controls) are skipped. + pub fn read_all(vml: &El) -> Vec { + let mut out = Vec::new(); + for shape in vml.descendants().filter(|e| e.name == "v:shape") { + let Some(cd) = shape.child("x:ClientData").filter(|cd| cd.attr("ObjectType") == Some("Note")) else { continue }; + let n = |k: &str| cd.child(k).and_then(|v| v.text().trim().parse::().ok()); + let (Some(row), Some(col)) = (n("x:Row"), n("x:Column")) else { continue }; + if row >= MAX_ROWS || col >= MAX_COLS { + continue; + } + let anchor = cd.child("x:Anchor").and_then(|a| { + let v: Vec = a.text().split(',').map(|p| p.trim().parse::().ok()).collect::>()?; + <[u32; 8]>::try_from(v).ok() + }); + let style = shape.attr("style").unwrap_or(""); + let pt = |key: &str| { + style + .split(';') + .filter_map(|d| d.split_once(':')) + .find(|(k, _)| k.trim().eq_ignore_ascii_case(key)) + .and_then(|(_, v)| v.trim().strip_suffix("pt")?.trim().parse::().ok().filter(|n| n.is_finite() && *n > 0.0 && *n < 1e6)) + }; + let size_pt = pt("width").zip(pt("height")); + out.push(NoteShape { at: CellRef::new(row, col), visible: cd.child("x:Visible").is_some(), anchor, size_pt }); + } + out + } +} + +/// A legacy VML drawing that shows `comments` as notes, Excel's way, with shape ids from block +/// `block` (`o:idmap`: ids `1024 * block + 1` on). A note with a shape in `shapes` keeps its box +/// and visibility; the others get Excel's default box, two columns right of the cell. +pub fn notes_vml(comments: &[Comment], shapes: &[NoteShape], block: u32) -> Vec { + let mut s = format!( + "" + ); + for (i, c) in comments.iter().enumerate() { + let shape = shapes.iter().find(|s| s.at == c.at); + let id = u64::from(block) * 1024 + 1 + i as u64; + let visible = shape.is_some_and(|s| s.visible); + let clamp = |v: i64, max: u32| v.clamp(0, i64::from(max) - 1); + let (row, col) = (i64::from(c.at.row), i64::from(c.at.col)); + let anchor = shape.and_then(|s| s.anchor).unwrap_or_else(|| { + let a = [clamp(col + 1, MAX_COLS), 15, clamp(row, MAX_ROWS), 2, clamp(col + 3, MAX_COLS), 15, clamp(row + 4, MAX_ROWS), 16]; + a.map(|v| u32::try_from(v).unwrap_or(0)) + }); + let (w, h) = shape.and_then(|s| s.size_pt).unwrap_or((108.0, 59.25)); + let anchor = anchor.map(|v| v.to_string()).join(", "); + s.push_str(&format!( + "
{anchor}False{}{}{}
", + num(w), + num(h), + i + 1, + if visible { "visible" } else { "hidden" }, + c.at.row, + c.at.col, + if visible { "" } else { "" } + )); + } + s.push_str("
"); + s.into_bytes() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read(xml: &str) -> El { + craft_xml::Options::canonical(&crate::ns::CANONICAL).parse(xml.as_bytes()).unwrap_or_default() + } + + #[test] + fn rules_with_scales_bars_and_icons_write_back() { + let x = r#"{1}"#; + let cf = ConditionalFormatting::read(&read(x)); + assert_eq!(cf.sqref.len(), 2); + let [scale, bar, icons, top] = &cf.rules[..] else { panic!("{:?}", cf.rules) }; + let scale = scale.color_scale.as_ref().unwrap(); + assert_eq!((scale.cfvos[1].kind.as_deref(), scale.cfvos[1].val.as_deref(), scale.colors[1].tint), (Some("percentile"), Some("50"), 0.5)); + assert_eq!(bar.data_bar.as_ref().map(|d| d.show_value), Some(Some(false))); + assert!(bar.ext.as_ref().is_some_and(|e| e.find("x14:id").is_some())); + assert_eq!( + icons.icon_set.as_ref().map(|i| (i.icon_set.as_deref(), i.reverse, i.cfvos[1].gte)), + Some((Some("3Arrows"), Some(true), Some(false))) + ); + assert_eq!((top.rank, top.percent, top.bottom, top.stop_if_true, top.dxf_id), (Some(5), Some(true), Some(true), Some(true), Some(1))); + let mut written = cf.to_el(); + crate::ns::declare(&mut written); + assert_eq!(ConditionalFormatting::read(&read(&written.to_xml())), cf); + } + + #[test] + fn extension_validations_read_into_the_same_type() { + let x = r#"Other!$A$1:$A$3G1:G4"#; + let v = DataValidation::read(&read(x)); + assert_eq!( + (v.kind.as_deref(), v.formula1.as_deref(), v.sqref.iter().map(range_ref).collect::>()), + (Some("list"), Some("Other!$A$1:$A$3"), vec!["G1:G4".to_string()]) + ); + assert_eq!( + v.to_el().to_xml(), + r#"Other!$A$1:$A$3"# + ); + } + + #[test] + fn references() { + for r in ["A1", "B2:C9", "A:C", "3:5", "A1:XFD1048576"] { + assert_eq!(range_ref(&Range::parse(r).unwrap()), r); + } + assert_eq!(parse_sqref(" A1 B2:C3 bad 4:4").len(), 3); + } +} diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 09d7b673e..24cce3292 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -50,6 +50,7 @@ //! ``` mod document; +mod features; pub mod ns; mod package; mod sheet; @@ -66,6 +67,10 @@ pub use craft_sheet::{ builtin_format, builtin_format_id, quote_sheet, sheet_needs_quotes, }; pub use document::{Document, KeptPart, Worksheet, XlsxKind}; +pub use features::{ + Break, CfRule, Cfvo, ColorScale, Comment, Comments, ConditionalFormatting, DataBar, DataValidation, DataValidations, Features, HeaderFooter, + Hyperlink, IconSet, NoteShape, PageMargins, PageSetup, Pane, Print, PrintOptions, Selection, SheetView, notes_vml, parse_sqref, range_ref, +}; pub use package::{Package, is_xlsb}; pub use sheet::{Cell, CellFormula, CellValue, Column, FormulaKind, Row, SheetPart, iso_date_serial}; pub use strings::{PhoneticProps, PhoneticRun, StringItem, TextRun, decode_xstring, encode_xstring, rich_text, t_el}; diff --git a/crates/xlsx/src/package.rs b/crates/xlsx/src/package.rs index 370812653..6fc9dc941 100644 --- a/crates/xlsx/src/package.rs +++ b/crates/xlsx/src/package.rs @@ -3,6 +3,7 @@ use craft_opc::{ContentTypes, LazyPackage, Rels}; use craft_xml::{El, Options, UnknownEntity}; +use crate::features::{Comments, NoteShape}; use crate::sheet::{Row, RowReader, SheetPart}; use crate::strings::StringItem; use crate::styles::Styles; @@ -257,6 +258,18 @@ impl<'a> Package<'a> { Ok(sheet) } + /// The comments of a worksheet read by [`Package::sheet`]; `None` when it has none. + pub fn comments(&mut self, sheet: &SheetPart) -> Result, Error> { + let Some(rel) = sheet.rels.first("comments").filter(|r| !r.external) else { return Ok(None) }; + Ok(self.optional_xml(&rel.target.clone())?.map(Comments::read)) + } + + /// The note shapes of a worksheet's legacy VML drawing: how its comments are shown. + pub fn note_shapes(&mut self, sheet: &SheetPart) -> Result, Error> { + let Some(rel) = sheet.rels.first("vmlDrawing").filter(|r| !r.external) else { return Ok(Vec::new()) }; + Ok(self.optional_xml(&rel.target.clone())?.map(|v| NoteShape::read_all(&v)).unwrap_or_default()) + } + fn required(&mut self, part: &str) -> Result, Error> { self.opc.read(part)?.ok_or_else(|| Error::Invalid(format!("missing part {part}"))) } diff --git a/crates/xlsx/src/sheet.rs b/crates/xlsx/src/sheet.rs index fc0e33bc9..243430f8e 100644 --- a/crates/xlsx/src/sheet.rs +++ b/crates/xlsx/src/sheet.rs @@ -7,6 +7,7 @@ use craft_opc::Rels; use craft_sheet::{CellError, CellRef, MAX_COLS, MAX_ROWS, Range, Value}; use craft_xml::{El, Writer}; +use crate::features::Features; use crate::strings::{StringItem, decode_xstring, encode_xstring}; use crate::write::{assemble, list, num, write_streamed}; @@ -190,6 +191,9 @@ pub struct SheetPart { /// Why reading stopped early, when the part is damaged; the rows before the damage were /// handed out. pub damaged: Option, + /// Views, print setup, data validation, conditional formats, hyperlinks: written from here, + /// not from [`SheetPart::root`]. + pub features: Features, } impl SheetPart { @@ -213,6 +217,7 @@ impl SheetPart { }); } } + self.features = Features::read(&self.root, &self.rels); if let Some(m) = self.root.child("mergeCells") { self.merges.extend(m.children("mergeCell").filter_map(|m| m.attr("ref").and_then(Range::parse)).filter(|r| !r.is_single())); } @@ -220,8 +225,8 @@ impl SheetPart { } impl SheetPart { - /// The worksheet part with `rows` as its `sheetData`. The dimension, the columns and the - /// merged cells come from the fields; everything else in [`SheetPart::root`] is written as + /// The worksheet part with `rows` as its `sheetData`. The dimension, the columns, the + /// merged cells and [`SheetPart::features`] come from the fields; everything else in [`SheetPart::root`] is written as /// kept, in place. pub fn to_xml(&self, rows: &[Row]) -> Vec { let root = if self.root.name.is_empty() { El::new("worksheet") } else { self.root.clone() }; @@ -230,12 +235,13 @@ impl SheetPart { cols.push(column_el(c)); } let merges = self.merges.iter().map(|m| El::new("mergeCell").with_attr("ref", m.a1())).collect(); - let owned = vec![ - ("dimension", Some(El::new("dimension").with_attr("ref", used(rows).map_or_else(|| "A1".to_string(), |r| r.a1())))), - ("cols", (!self.columns.is_empty()).then_some(cols)), - ("sheetData", Some(El::new("sheetData"))), - ("mergeCells", list(&root, "mergeCells", merges)), + let mut owned = vec![ + ("dimension", vec![El::new("dimension").with_attr("ref", used(rows).map_or_else(|| "A1".to_string(), |r| r.a1()))]), + ("cols", Vec::from_iter((!self.columns.is_empty()).then_some(cols))), + ("sheetData", vec![El::new("sheetData")]), + ("mergeCells", Vec::from_iter(list(&root, "mergeCells", merges))), ]; + owned.extend(self.features.owned()); let mut root = assemble(&root, WORKSHEET, owned); let prefixes: Vec<&str> = rows.iter().flat_map(|r| &r.attrs).filter_map(|(k, _)| k.split_once(':').map(|(p, _)| p)).collect(); crate::ns::declare_with(&mut root, &prefixes); diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index b9a38bd7d..132128ef2 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -21,18 +21,19 @@ pub fn num(n: f64) -> String { /// `root` with the children named in `owned` regenerated. Each `(name, new)` takes the place /// of the first child of that name, and later children of that name are dropped; when `root` -/// has none, `new` goes where `order` (the schema's sequence) puts it. `None` removes the -/// child. Every other child stays where it is, so elements the schema does not list +/// has none, `new` goes where `order` (the schema's sequence) puts it. No element (`None`) +/// removes the children; several (repeated blocks) go in a row. Every other child stays where it is, so elements the schema does not list /// (`mc:AlternateContent`) keep their place. -pub(crate) fn assemble(root: &El, order: &[&str], mut owned: Vec<(&str, Option)>) -> El { +pub(crate) fn assemble>(root: &El, order: &[&str], owned: Vec<(&str, I)>) -> El { + let mut owned: Vec<(&str, Vec)> = owned.into_iter().map(|(n, e)| (n, e.into_iter().collect())).collect(); let pos = |n: &str| order.iter().position(|o| *o == n).unwrap_or(order.len()); owned.sort_by_key(|(n, _)| pos(n)); // The children of `root` each owned name replaces, so a regenerated one is not also written // where `order` would put it. let present: Vec<&str> = owned.iter().map(|(n, _)| *n).filter(|n| root.child(*n).is_some()).collect(); let mut out = El { name: root.name.clone(), attrs: root.attrs.clone(), children: Vec::new() }; - let mut pending: Vec<(&str, Option)> = Vec::new(); - let mut later: Vec<(&str, Option)> = Vec::new(); + let mut pending: Vec<(&str, Vec)> = Vec::new(); + let mut later: Vec<(&str, Vec)> = Vec::new(); for (n, e) in owned { if present.contains(&n) { later.push((n, e)) } else { pending.push((n, e)) } } @@ -47,12 +48,12 @@ pub(crate) fn assemble(root: &El, order: &[&str], mut owned: Vec<(&str, Option"#); + assert_eq!(out.to_xml(), r#""#); let empty = assemble(&El::new("w"), &order, vec![("d", Some(El::new("d"))), ("a", Some(El::new("a")))]); assert_eq!(empty.to_xml(), ""); } diff --git a/crates/xlsx/tests/write.rs b/crates/xlsx/tests/write.rs index 4ada52b5e..c42fb522f 100644 --- a/crates/xlsx/tests/write.rs +++ b/crates/xlsx/tests/write.rs @@ -30,11 +30,11 @@ fn excel_like() -> Vec { r#" 010.1SUM(C1:C3)0.300000000000000041C1*20.2#N/A inline 0.4 -0.25 +0.25"a,b"&C&P x Data!C1:C3D1"# ); let sheet1_rels = format!( - r#""# + r#""# ); let styles = format!( r#" @@ -50,7 +50,7 @@ fn excel_like() -> Vec { let package_rels = format!( r#""# ); - let types = r#""#; + let types = r#""#; zip(&[ ("[Content_Types].xml", types.as_bytes()), ("_rels/.rels", package_rels.as_bytes()), @@ -66,10 +66,15 @@ fn excel_like() -> Vec { ("xl/styles.xml", styles.as_bytes()), ("xl/sharedStrings.xml", strings.as_bytes()), ("xl/calcChain.xml", b""), + ("xl/comments1.xml", COMMENTS.as_bytes()), + ("xl/drawings/vmlDrawing1.vml", VML.as_bytes()), ("xl/vbaProject.bin", &[0xD0, 0xCF, 0x11, 0xE0, 1, 2, 3]), ]) } +const COMMENTS: &str = "AnnAnn:\nnote"; +const VML: &str = "2, 15, 0, 2, 4, 15, 4, 1611"; + fn read(bytes: &[u8]) -> Document { Document::read(bytes, &Limits::default()).unwrap() } @@ -114,6 +119,12 @@ fn assert_same(a: &Document, b: &Document) { assert_eq!((&x.part.part, &x.part.columns, &x.part.merges), (&y.part.part, &y.part.columns, &y.part.merges)); assert_eq!(String::from_utf8(x.part.to_xml(&x.rows)), String::from_utf8(y.part.to_xml(&y.rows))); assert_eq!(x.part.rels.list, y.part.rels.list); + assert_eq!(x.part.features, y.part.features); + assert_eq!(x.notes, y.notes); + assert_eq!( + x.comments.as_ref().map(|c| (&c.authors, &c.comments, c.to_xml())), + y.comments.as_ref().map(|c| (&c.authors, &c.comments, c.to_xml())) + ); } let mut pa = a.parts.clone(); let mut pb = b.parts.clone(); @@ -169,6 +180,36 @@ fn a_workbook_round_trips() { assert!(types.contains("macroEnabled.main+xml") && types.contains(r#"Extension="bin""#) && !types.contains("calcChain"), "{types}"); } +#[test] +fn features_are_read() { + let doc = read(&excel_like()); + let ws = doc.worksheets[0].as_ref().unwrap(); + let f = &ws.part.features; + assert_eq!(f.views[0].pane.as_ref().and_then(|p| p.state.as_deref()), Some("frozen")); + assert_eq!(f.views[0].tab_selected, Some(true)); + let dv = &f.validations.as_ref().unwrap().items[0]; + assert_eq!((dv.kind.as_deref(), dv.formula1.as_deref(), dv.sqref.len()), (Some("list"), Some("\"a,b\""), 2)); + assert_eq!(dv.attrs, [("xr:uid".to_string(), "{00000000-0002-0000-0000-000000000000}".to_string())]); + assert_eq!(craft_xlsx::range_ref(&dv.sqref[1]), "C:C"); + assert_eq!(f.conditional_formats[0].rules[0].formulas, ["0.25"]); + assert_eq!(f.hyperlinks[0].url.as_deref(), Some("https://example.com/a?b&c")); + assert_eq!((f.hyperlinks[1].location.as_deref(), f.hyperlinks[1].url.as_deref()), (Some("Data!A1"), None)); + assert_eq!(f.print.options.as_ref().and_then(|o| o.grid_lines), Some(true)); + assert_eq!(f.print.setup.as_ref().and_then(|s| s.orientation.as_deref()), Some("landscape")); + let hf = f.print.header_footer.as_ref().unwrap(); + assert_eq!((hf.odd_header.as_deref(), hf.first_footer.as_deref(), hf.different_first), (Some("&C&P"), Some(" x "), Some(true))); + assert_eq!((f.print.row_breaks[0].id, f.print.row_breaks[0].man), (Some(10), Some(true))); + assert_eq!(f.legacy_drawing.as_deref(), Some("rId3")); + let comments = ws.comments.as_ref().unwrap(); + assert_eq!(comments.comments.len(), 1); + assert_eq!((comments.author(&comments.comments[0]), &*comments.comments[0].text.text), (Some("Ann"), "Ann:\nnote")); + assert_eq!( + ws.notes, + [craft_xlsx::NoteShape { at: CellRef::new(1, 1), visible: true, anchor: Some([2, 15, 0, 2, 4, 15, 4, 16]), size_pt: Some((120.0, 60.0)) }] + ); + assert!(doc.parts.iter().any(|p| p.name == "xl/drawings/vmlDrawing1.vml") && !doc.parts.iter().any(|p| p.name == "xl/comments1.xml")); +} + #[test] fn edits_are_written() { let mut doc = read(&excel_like()); @@ -196,6 +237,68 @@ fn edits_are_written() { assert_eq!(rels.all("worksheet").count(), 1); } +#[test] +fn features_are_edited() { + let mut doc = read(&excel_like()); + let ws = doc.worksheets[0].as_mut().unwrap(); + let f = &mut ws.part.features; + f.hyperlinks[0].url = Some("https://example.org/".into()); + f.conditional_formats.push(craft_xlsx::ConditionalFormatting { + sqref: vec![Range::parse("A1:A9").unwrap()], + rules: vec![craft_xlsx::CfRule { kind: Some("expression".into()), priority: Some(2), formulas: vec!["A1>1".into()], ..Default::default() }], + ..Default::default() + }); + f.print.setup = None; + // A comment on a sheet without a VML drawing gets one, in a shape block no other drawing uses. + let empty = doc.worksheets[2].as_mut().unwrap(); + empty.comments = Some(craft_xlsx::Comments { + authors: vec!["Bo".into()], + comments: vec![craft_xlsx::Comment { at: CellRef::new(0, 0), author_id: Some(0), text: StringItem::plain("hi"), ..Default::default() }], + ..Default::default() + }); + let bytes = doc.write().unwrap(); + let again = read(&bytes); + assert_same(&doc_after(&doc), &again); + let f = &again.worksheets[0].as_ref().unwrap().part.features; + assert_eq!(f.hyperlinks[0].url.as_deref(), Some("https://example.org/")); + assert_eq!(f.conditional_formats.len(), 2); + assert!(f.print.setup.is_none()); + let sheet2 = again.worksheets[2].as_ref().unwrap(); + assert_eq!(sheet2.comments.as_ref().map(|c| c.comments[0].text.text.to_string()), Some("hi".into())); + assert!(sheet2.part.features.legacy_drawing.is_some()); + assert_eq!(sheet2.notes.iter().map(|n| (n.at, n.visible)).collect::>(), [(CellRef::new(0, 0), false)]); + let vml = again.parts.iter().find(|p| p.name == "xl/drawings/vmlDrawing2.vml").unwrap(); + assert!(String::from_utf8_lossy(&vml.bytes).contains(r#"data="2""#)); + let sheet = + String::from_utf8(Package::open(&bytes, &Limits::default()).unwrap().opc().read("xl/worksheets/sheet1.xml").unwrap().unwrap()).unwrap(); + let order = [ + " = order.iter().map(|o| sheet.find(o).unwrap()).collect(); + assert!(at.windows(2).all(|w| w[0] < w[1]), "{sheet}"); + // Comments removed take their part and relationship with them. + let mut doc = again; + doc.worksheets[0].as_mut().unwrap().comments = None; + let bytes = doc.write().unwrap(); + let mut p = Package::open(&bytes, &Limits::default()).unwrap(); + assert!(p.opc().read("xl/comments1.xml").unwrap().is_none()); + let wb = p.workbook().unwrap(); + let part = p.sheet(&wb.sheets[0], |_| Ok(())).unwrap(); + assert!(part.rels.first("comments").is_none()); +} + +/// The document as written: a link to a new URL has a relationship of its own, a new comments +/// part a VML drawing. +fn doc_after(doc: &Document) -> Document { + read(&doc.write().unwrap()) +} + #[test] fn a_new_workbook() { let cell = |at: &str, value| Cell { at: CellRef::parse(at).unwrap(), value, ..Cell::default() };