From 3f31f9951c0ba93f984239a25925c2c493815b91 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 23:08:56 +0300 Subject: [PATCH 01/27] =?UTF-8?q?PPTX:=20read=20Office=202016=20charts=20(?= =?UTF-8?q?waterfall,=20funnel,=20treemap=E2=80=A6)=20and=20keep=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Waterfall, funnel, treemap, sunburst, histogram, Pareto and box & whisker charts live in a chartEx part behind an mc:AlternateContent choice requiring cx1. DeckCraft took the fallback, usually a text box saying the chart isn't available, so these charts were lost on open and on save. The cx1 choice is now taken for chartEx frames and the part is read into the chart model: categories (innermost level), values, number format, series name, title, legend and data labels. Unchanged charts are written back as their chartEx part in a cx1 choice with a plain chart of the same data as fallback, as PowerPoint writes them; an edited one is saved as a plain chart. Region maps are not read. Co-authored-by: Cursor --- crates/pptx/src/read/chart.rs | 2 +- crates/pptx/src/read/chartex.rs | 128 ++++++++++++++++++++++++++++++++ crates/pptx/src/read/mod.rs | 16 +++- crates/pptx/src/read/shapes.rs | 2 +- crates/pptx/src/write/chart.rs | 23 ++++++ crates/pptx/src/write/mod.rs | 13 ++++ crates/pptx/src/write/shapes.rs | 22 +++++- crates/pptx/tests/roundtrip.rs | 117 +++++++++++++++++++++++++++++ docs/gaps.md | 5 +- 9 files changed, 321 insertions(+), 7 deletions(-) create mode 100644 crates/pptx/src/read/chartex.rs diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 6d7541ff..7cecf457 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -26,7 +26,7 @@ fn simple_line(sp: Option<&El>) -> Option { Some(l) } -fn rich_text(t: &El) -> Option { +pub(crate) fn rich_text(t: &El) -> Option { let tx = t.child("tx")?; if let Some(r) = tx.child("rich") { let s = r.children_named("p").map(|p| p.text()).collect::>().join("\n"); diff --git a/crates/pptx/src/read/chartex.rs b/crates/pptx/src/read/chartex.rs new file mode 100644 index 00000000..ac14c97b --- /dev/null +++ b/crates/pptx/src/read/chartex.rs @@ -0,0 +1,128 @@ +//! Office 2016 charts (`cx:chartSpace`, the `chartEx` part): waterfall, funnel, treemap, +//! sunburst, histogram, Pareto and box & whisker. Their data sits in `cx:chartData` as levels of +//! points, and each series names its layout (`layoutId`). + +use super::chart::rich_text; +use crate::xml::El; +use deckcraft_model::chart::{Chart, ChartType, Series}; +use std::collections::HashMap; + +/// Points per level and levels per dimension we keep (guards against hostile parts). +const MAX_POINTS: usize = 100_000; + +/// One `cx:data`: its categories (the innermost level) and named numeric dimensions. +#[derive(Default)] +struct Data { + categories: Vec, + numbers: HashMap>, Option)>, +} + +/// A level's points, by `idx`. +fn points(lvl: &El) -> Vec { + let n = lvl.u32("ptCount").unwrap_or(0).min(MAX_POINTS as u32) as usize; + let mut out = vec![String::new(); n]; + for pt in lvl.children_named("pt").take(MAX_POINTS) { + let i = pt.u32("idx").unwrap_or(0) as usize; + if i >= MAX_POINTS { + continue; + } + if i >= out.len() { + out.resize(i + 1, String::new()); + } + if let Some(s) = out.get_mut(i) { + *s = pt.text(); + } + } + out +} + +fn data(d: &El) -> Data { + let mut out = Data::default(); + for dim in d.elements() { + // The first level is the innermost: a treemap's leaves, a waterfall's categories. + let Some(lvl) = dim.child("lvl") else { continue }; + let ty = dim.attr("type").unwrap_or(""); + if dim.is("strDim") || (dim.is("numDim") && ty == "cat") { + if ty == "cat" || out.categories.is_empty() { + out.categories = points(lvl); + } + } else if dim.is("numDim") { + let vals = points(lvl).into_iter().map(|s| s.trim().parse::().ok().filter(|v| v.is_finite())).collect(); + out.numbers.insert(ty.to_string(), (vals, lvl.attr("formatCode").map(str::to_string))); + } + } + out +} + +fn kind_of(layout: &str) -> Option { + Some(match layout { + "waterfall" => ChartType::Waterfall, + "funnel" => ChartType::Funnel, + "treemap" => ChartType::Treemap, + "sunburst" => ChartType::Sunburst, + "clusteredColumn" => ChartType::Histogram, + "boxWhisker" => ChartType::BoxWhisker, + _ => return None, + }) +} + +/// Parse a `chartEx` part. `None` for anything else, and for layouts DeckCraft has no type +/// for (region maps). +pub fn read_chartex_xml(bytes: &[u8]) -> Option { + let doc = crate::xml::parse(bytes).ok()?; + let mut root = doc.root; + super::resolve_mc(&mut root, 0); + let mut c = chartex_from(&root)?; + c.raw = Some(String::from_utf8_lossy(bytes).into_owned()); + Some(c) +} + +pub(crate) fn chartex_from(root: &El) -> Option { + if !root.is("chartSpace") { + return None; + } + let datas: HashMap = + root.child("chartData")?.children_named("data").map(|d| (d.attr("id").unwrap_or("0").to_string(), data(d))).collect(); + let ch = root.child("chart")?; + let region = ch.child("plotArea")?.child("plotAreaRegion")?; + let mut c = Chart { categories: vec![], series: vec![], title: None, legend: None, gridlines: false, ..Default::default() }; + c.title = ch.child("title").map(|t| rich_text(t).filter(|s| !s.is_empty()).unwrap_or_else(|| "Chart Title".into())); + c.legend = ch.child("legend").map(|l| l.attr("pos").unwrap_or("t").to_string()); + let mut kind = None; + for s in region.children_named("series") { + let layout = s.attr("layoutId").unwrap_or(""); + if layout == "paretoLine" { + // The cumulative line PowerPoint adds to a Pareto histogram. + kind = Some(ChartType::Pareto); + continue; + } + let k = kind_of(layout)?; + if s.bool("hidden") == Some(true) { + continue; + } + kind = Some(match kind { + Some(ChartType::Pareto) => ChartType::Pareto, + _ => k, + }); + let d = datas.get(s.child("dataId").and_then(|i| i.attr("val")).unwrap_or("0"))?; + let (values, format) = + ["val", "size"].iter().find_map(|t| d.numbers.get(*t)).or_else(|| d.numbers.values().next()).cloned().unwrap_or_default(); + if c.categories.is_empty() { + c.categories = d.categories.clone(); + } + let name = s.child("tx").and_then(|t| t.find("v")).map(|v| v.text()).unwrap_or_default(); + c.series.push(Series { + name, + values, + data_labels: s.child("dataLabels").map(|l| l.bool("hidden") != Some(true)), + number_format: format, + ..Default::default() + }); + } + c.kind = kind?; + c.data_labels = c.series.iter().any(|s| s.data_labels == Some(true)); + c.vary_colors = matches!(c.kind, ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::Waterfall); + // Waterfall, histogram and box charts have a value axis with gridlines by default. + c.gridlines = matches!(c.kind, ChartType::Waterfall | ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker); + Some(c) +} diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index 057614eb..f48e6223 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -1,12 +1,14 @@ //! PPTX import: package → [`Presentation`]. mod chart; +mod chartex; pub(crate) mod dml; mod shapes; mod theme; mod timing; pub(crate) use chart::{chart_from as chart_from_el, read_chart_xml}; +pub(crate) use chartex::{chartex_from, read_chartex_xml}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -117,7 +119,16 @@ pub fn keep_xml(el: &El, ns: &[(String, String)]) -> String { } /// Namespace prefixes whose `mc:Choice` content we understand. -const SUPPORTED_MC: [&str; 9] = ["p14", "p15", "p159", "a14", "a16", "p1510", "a15", "c14", "p188"]; +const SUPPORTED_MC: [&str; 10] = ["p14", "p15", "p159", "a14", "a16", "p1510", "a15", "c14", "p188", "cx1"]; + +/// Office 2016 charts sit in a `cx1` choice; take it only for a layout DeckCraft reads (the +/// fallback is a picture or "this chart isn't available" text). The part is checked later, when +/// the frame is read, so here only the namespace counts. +fn chartex_readable(ch: &El) -> bool { + let mut found = vec![]; + ch.find_all("graphicData", &mut found); + found.iter().any(|g| g.attr("uri").is_some_and(|u| u.ends_with("/chartex"))) +} /// Replace every `mc:AlternateContent` by the children of the branch we can read best. pub fn resolve_mc(el: &mut El, depth: usize) { @@ -132,7 +143,8 @@ pub fn resolve_mc(el: &mut El, depth: usize) { let choice = ac.children_named("Choice").find(|ch| { let req_ok = ch.attr("Requires").unwrap_or("").split_whitespace().all(|r| SUPPORTED_MC.contains(&r)); // Ink content parts are not modelled; prefer their fallback picture. - let modelled = !ch.elements().any(|e| e.is("contentPart")); + let modelled = !ch.elements().any(|e| e.is("contentPart")) + && (!ch.attr("Requires").unwrap_or("").contains("cx1") || chartex_readable(ch)); req_ok && modelled }); let branch = choice.or_else(|| ac.child("Fallback")); diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index c4b3f77f..c6226bde 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -368,7 +368,7 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S if let Some(c) = gd.child("chart") && let Some(rel) = part.rels.get(c.attr("r:id").unwrap_or("")) && let Some(bytes) = imp.pkg.get(&rel.target) - && let Some(chart) = super::read_chart_xml(bytes) + && let Some(chart) = if uri.ends_with("/chartex") { super::read_chartex_xml(bytes) } else { super::read_chart_xml(bytes) } { s.kind = ShapeKind::Chart(Box::new(chart)); return s; diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 76567c9e..98ac50cb 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -4,6 +4,9 @@ use deckcraft_model::chart::{Chart, ChartType, Series}; use deckcraft_model::style::Fill; use crate::opc::{NS_A, NS_C, NS_R}; + +/// The Office 2016 chart namespace. +pub const NS_CX: &str = "http://schemas.microsoft.com/office/drawing/2014/chartex"; use crate::xml::{A, W}; /// Column letter for a 0-based index (A, B, … Z, AA…). @@ -362,10 +365,30 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)]) w.close(tag); } +/// The kept `chartEx` part of an Office 2016 chart (waterfall, funnel…) that is unchanged since +/// it was read, without its workbook link; `None` when it was edited or isn't one. +pub fn chartex_xml(c: &Chart) -> Option> { + let raw = c.raw.as_ref()?; + let doc = crate::xml::parse(raw.as_bytes()).ok()?; + let mut root = doc.root.clone(); + crate::read::resolve_mc(&mut root, 0); + let mut back = crate::read::chartex_from(&root)?; + back.raw = c.raw.clone(); + if &back != c { + return None; + } + let mut keep = doc.root; + keep.remove_all(&["externalData"]); + let mut w = W::new(); + w.raw(&keep.to_xml()); + Some(w.finish()) +} + /// The chart part XML. pub fn chart_xml(c: &Chart) -> Vec { // Reuse the kept part when the chart is unchanged since it was read. if let Some(raw) = &c.raw + && !raw.contains(NS_CX) && let Ok(doc) = crate::xml::parse(raw.as_bytes()) && doc.root.is("chartSpace") { diff --git a/crates/pptx/src/write/mod.rs b/crates/pptx/src/write/mod.rs index 7fb6a713..48a1141c 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -30,6 +30,8 @@ const CT_PRES_PROPS: &str = "application/vnd.openxmlformats-officedocument.prese const CT_VIEW_PROPS: &str = "application/vnd.openxmlformats-officedocument.presentationml.viewProps+xml"; const CT_TABLE_STYLES: &str = "application/vnd.openxmlformats-officedocument.presentationml.tableStyles+xml"; const CT_CHART: &str = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"; +const CT_CHARTEX: &str = "application/vnd.ms-office.chartex+xml"; +const RT_CHARTEX: &str = "http://schemas.microsoft.com/office/2014/relationships/chartEx"; const CT_COMMENTS: &str = "application/vnd.openxmlformats-officedocument.presentationml.comments+xml"; const CT_AUTHORS: &str = "application/vnd.openxmlformats-officedocument.presentationml.commentAuthors+xml"; const CT_CORE: &str = "application/vnd.openxmlformats-package.core-properties+xml"; @@ -43,6 +45,7 @@ pub struct Exp<'a> { n_image: u32, n_media: u32, n_chart: u32, + n_chartex: u32, pub slide_parts: HashMap, pub table_styles: Vec, placeholder: Option, @@ -158,6 +161,15 @@ impl Exp<'_> { }; o.rels.add("image", &opc::relative(&o.name, &part), false) } + /// An unchanged Office 2016 chart's `chartEx` part and its relationship id. + pub fn chartex_rel(&mut self, o: &mut Out, c: &deckcraft_model::Chart) -> Option { + let xml = chart::chartex_xml(c)?; + self.n_chartex += 1; + let name = format!("ppt/charts/chartEx{}.xml", self.n_chartex); + self.pkg.xml_part(&name, CT_CHARTEX, xml); + Some(o.rels.add_new(RT_CHARTEX, &opc::relative(&o.name, &name), false)) + } + pub fn chart_rel(&mut self, o: &mut Out, c: &deckcraft_model::Chart) -> String { self.n_chart += 1; let name = format!("ppt/charts/chart{}.xml", self.n_chart); @@ -385,6 +397,7 @@ pub fn export(p: &Presentation) -> Result, PptxError> { n_image: 0, n_media: 0, n_chart: 0, + n_chartex: 0, slide_parts: HashMap::new(), table_styles: vec![], placeholder: None, diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 8b2ea35d..4196f5ea 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -365,12 +365,32 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth } ShapeKind::Chart(c) => { let id = cx.id_for(s); - let rid = x.chart_rel(o, c); + // An unchanged Office 2016 chart goes back as itself, with a plain chart of the same + // data for readers without `cx1`, as PowerPoint writes it. + let ex = x.chartex_rel(o, c); + if let Some(exid) = &ex { + w.open("mc:AlternateContent", A::new().a("xmlns:mc", "http://schemas.openxmlformats.org/markup-compatibility/2006")); + w.open("mc:Choice", A::new().a("xmlns:cx1", "http://schemas.microsoft.com/office/drawing/2015/9/8/chartex").a("Requires", "cx1")); + frame_open(w, x, o, id, s); + w.open("a:graphicData", A::new().a("uri", super::chart::NS_CX)); + w.empty("cx:chart", A::new().a("xmlns:cx", super::chart::NS_CX).a("r:id", exid)); + w.close("a:graphicData"); + frame_close(w); + w.close("mc:Choice"); + w.open0("mc:Fallback"); + } + let mut plain = (**c).clone(); + plain.raw = plain.raw.filter(|r| !r.contains(super::chart::NS_CX)); + let rid = x.chart_rel(o, &plain); frame_open(w, x, o, id, s); w.open("a:graphicData", A::new().a("uri", "http://schemas.openxmlformats.org/drawingml/2006/chart")); w.empty("c:chart", A::new().a("xmlns:c", crate::opc::NS_C).a("r:id", rid)); w.close("a:graphicData"); frame_close(w); + if ex.is_some() { + w.close("mc:Fallback"); + w.close("mc:AlternateContent"); + } } ShapeKind::Media(m) => { let id = cx.id_for(s); diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index db6d0beb..58d79b6f 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -991,3 +991,120 @@ fn transition_defaults_read_like_powerpoint() { assert_eq!((t.kind.as_str(), t.option.as_str(), t.duration_ms), (*kind, *option, 2000), "{el}"); } } + +/// A part's bytes, if the package has it. +fn pkg_part(pkg: &[u8], name: &str) -> Option> { + use std::io::Read; + let mut z = zip::ZipArchive::new(std::io::Cursor::new(pkg)).unwrap(); + let mut f = z.by_name(name).ok()?; + let mut v = vec![]; + f.read_to_end(&mut v).unwrap(); + Some(v) +} + +/// Rewrite a package's parts with `edit`, then add `extra` parts. +fn repack(pkg: &[u8], edit: impl Fn(&str, String) -> String, extra: &[(&str, &[u8])]) -> Vec { + use std::io::{Read, Write}; + let mut z = zip::ZipArchive::new(std::io::Cursor::new(pkg)).unwrap(); + let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + for i in 0..z.len() { + let mut f = z.by_index(i).unwrap(); + let name = f.name().to_string(); + let mut data = vec![]; + f.read_to_end(&mut data).unwrap(); + if name.ends_with(".xml") || name.ends_with(".rels") { + data = edit(&name, String::from_utf8(data).unwrap()).into_bytes(); + } + out.start_file(name, opts).unwrap(); + out.write_all(&data).unwrap(); + } + for (name, data) in extra { + out.start_file(*name, opts).unwrap(); + out.write_all(data).unwrap(); + } + out.finish().unwrap().into_inner() +} + +const WATERFALL_CX: &str = r#" +Sheet1!$A$2:$A$5StartSalesCostsEndSheet1!$B$2:$B$510040-25115Cash flowSheet1!$B$1Amount"#; + +/// A waterfall frame as PowerPoint writes it: the chart in a `cx1` choice, a text box fallback. +const WATERFALL_FRAME: &str = r#"This chart isn't available in your version of PowerPoint."#; + +/// Office 2016 charts (`chartEx`): a waterfall reads as a waterfall chart with its data, not as +/// the "isn't available" fallback text, and an unchanged one is written back as itself with a +/// plain chart fallback; an edited one is written as a plain chart. +#[test] +fn chartex_waterfall_reads_and_round_trips() { + use deckcraft_engine::Session; + let mut s = Session::with_new(); + s.execute("slide.new", &serde_json::json!({"layout": "blank"})).unwrap(); + let base = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap(); + let slide = (1..=9).map(|i| format!("ppt/slides/slide{i}.xml")).find(|n| pkg_part(&base, n).is_some()).unwrap(); + let slide_rels = slide.replace("slides/", "slides/_rels/") + ".rels"; + let cx_rels = r#""#; + let pptx = repack( + &base, + |name, x| { + if name == slide { + x.replacen("", &format!("{WATERFALL_FRAME}"), 1) + } else if name == slide_rels { + x.replace("", r#""#) + } else if name == "[Content_Types].xml" { + x.replace( + "", + r#""#, + ) + } else { + x + } + }, + &[("ppt/charts/chartEx1.xml", WATERFALL_CX.as_bytes()), ("ppt/charts/_rels/chartEx1.xml.rels", cx_rels.as_bytes())], + ); + let doc = deckcraft_pptx::import(&pptx).unwrap(); + let chart = |d: &Presentation| -> Option { + d.slides.iter().flat_map(|s| s.shapes.iter()).find_map(|sh| match &sh.kind { + ShapeKind::Chart(c) => Some((**c).clone()), + _ => None, + }) + }; + let c = chart(&doc).expect("the waterfall is a chart, not the fallback text"); + assert_eq!(c.kind, ChartType::Waterfall); + assert_eq!(c.title.as_deref(), Some("Cash flow")); + assert_eq!(c.categories, ["Start", "Sales", "Costs", "End"]); + assert_eq!(c.series.len(), 1); + assert_eq!(c.series[0].name, "Amount"); + assert_eq!(c.series[0].values, [Some(100.0), Some(40.0), Some(-25.0), Some(115.0)]); + assert!(c.data_labels); + assert_eq!(c.legend.as_deref(), Some("b")); + let no_fallback_text = doc.slides.iter().flat_map(|s| s.shapes.iter()).all(|sh| !format!("{:?}", sh.kind).contains("isn't available")); + assert!(no_fallback_text); + + // Unchanged: chartEx back in a cx1 choice, a plain chart as fallback, no workbook link. + let out = deckcraft_pptx::export(&doc).unwrap(); + let ex = String::from_utf8(pkg_part(&out, "ppt/charts/chartEx1.xml").expect("chartEx part")).unwrap(); + assert!(ex.contains(r#"layoutId="waterfall""#) && !ex.contains("externalData"), "{ex}"); + let types = String::from_utf8(pkg_part(&out, "[Content_Types].xml").unwrap()).unwrap(); + assert!(types.contains("application/vnd.ms-office.chartex+xml")); + let sx = String::from_utf8(pkg_part(&out, &slide).unwrap()).unwrap(); + assert!(sx.contains(r#"Requires="cx1""#) && sx.contains("mc:Fallback") && sx.contains("c:chart "), "{sx}"); + let srels = String::from_utf8(pkg_part(&out, &slide_rels).unwrap()).unwrap(); + assert!(srels.contains("relationships/chartEx") && srels.contains("relationships/chart\""), "{srels}"); + assert!(pkg_part(&out, "ppt/charts/chart1.xml").is_some()); + let again = deckcraft_pptx::import(&out).unwrap(); + assert_eq!(chart(&again).map(|c| (c.kind, c.categories, c.series.len())), Some((ChartType::Waterfall, c.categories.clone(), 1))); + + // Edited: the kept part no longer matches, so only a plain chart is written. + let mut edited = doc.clone(); + for sl in &mut edited.slides { + for sh in &mut Arc::make_mut(sl).shapes { + if let ShapeKind::Chart(ch) = &mut sh.kind { + ch.series[0].values[1] = Some(55.0); + } + } + } + let out = deckcraft_pptx::export(&edited).unwrap(); + assert!(pkg_part(&out, "ppt/charts/chartEx1.xml").is_none()); + assert!(!String::from_utf8(pkg_part(&out, &slide).unwrap()).unwrap().contains("cx1")); +} diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..a9cdda81 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -145,8 +145,9 @@ Detail in [file-format-parity.md](file-format-parity.md). ### Charts: data grid, elements and the newer chart types - **Evidence:** checklist rows "Edit chart data" (P0, partial), "Chart elements" (partial), "Other - chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, box & - whisker) aren't read. + chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, Pareto, + box & whisker) are read for their data and written back unchanged, but they're drawn with the + bar renderer and an edited one is saved as a plain column chart; region maps aren't read. - **Impact:** most business decks have charts; editing their data is routine. - **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h). From 0d47da07e5d07f01827a2efc9355060c7e3c3e57 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 23:16:23 +0300 Subject: [PATCH 02/27] Charts: draw waterfall, funnel, treemap and sunburst layouts These Office 2016 charts were drawn as plain columns. Now: - waterfall: bars float from the running total, total points (cx:subtotals, now read into Chart.subtotals) stand on the axis and reset it, increases/decreases/totals take accents 1-3 with a matching legend, and connectors join each bar to the next; - funnel: centred bars as wide as their value, category labels on the left and values inside; - treemap: squarified tiles in proportion to their value, labelled; - sunburst: the innermost level as a ring with an open centre. Co-authored-by: Cursor --- crates/model/src/chart.rs | 34 +++++ crates/pptx/src/read/chartex.rs | 3 + crates/render/src/chart.rs | 56 +++++++- crates/render/src/chartex.rs | 227 ++++++++++++++++++++++++++++++++ crates/render/src/lib.rs | 1 + crates/render/src/tests.rs | 35 +++++ docs/gaps.md | 6 +- 7 files changed, 356 insertions(+), 6 deletions(-) create mode 100644 crates/render/src/chartex.rs diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index aebee497..a6c9fa24 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -186,6 +186,10 @@ pub struct Chart { pub y2_hidden: bool, /// The chart's language (`c:lang`, e.g. `ru-RU`): digit grouping and decimal mark. pub lang: Option, + /// A waterfall's total points (`cx:subtotals`): bars that stand on the axis and restart + /// the running total, instead of floating. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub subtotals: Vec, /// Original chart XML part kept for round-trip of unmodelled options. #[serde(skip_serializing_if = "Option::is_none")] pub raw: Option, @@ -225,6 +229,7 @@ impl Default for Chart { y2_format: None, y2_hidden: false, lang: None, + subtotals: vec![], raw: None, } } @@ -304,6 +309,28 @@ impl Chart { /// A value axis' scale — (min, max, major unit) — with at most `max_intervals` intervals: /// the bounds and unit the chart fixes, else PowerPoint's automatic ones (the data plus 5 % /// headroom, rounded out to a 1-2-5 unit). `None` when no series uses the axis. + /// A waterfall's bars as (from, to) on the value axis: each point floats from the running + /// total, a total point stands on zero and sets it. + pub fn waterfall_bars(&self) -> Vec<(f64, f64)> { + let Some(s) = self.series.first() else { return vec![] }; + let mut run = 0.0; + s.values + .iter() + .enumerate() + .map(|(i, v)| { + let v = v.unwrap_or(0.0); + if self.subtotals.contains(&i) { + run = v; + (0.0, v) + } else { + let from = run; + run += v; + (from, run) + } + }) + .collect() + } + pub fn axis_scale(&self, secondary: bool, max_intervals: usize) -> Option<(f64, f64, f64)> { let (lo, hi) = self.axis_range(secondary)?; let (fmin, fmax, major) = if secondary { (self.y2_min, self.y2_max, self.y2_major) } else { (self.y_min, self.y_max, self.y_major) }; @@ -328,6 +355,13 @@ impl Chart { pub fn value_range(&self) -> (f64, f64) { let mut lo: f64 = 0.0; let mut hi: f64 = 0.0; + if self.kind == ChartType::Waterfall { + for (a, b) in self.waterfall_bars() { + lo = lo.min(a.min(b)); + hi = hi.max(a.max(b)); + } + return (lo, hi); + } let n = self.categories.len().max(self.series.iter().map(|s| s.values.len()).max().unwrap_or(0)); if self.kind.stacked() { for i in 0..n { diff --git a/crates/pptx/src/read/chartex.rs b/crates/pptx/src/read/chartex.rs index ac14c97b..1207df02 100644 --- a/crates/pptx/src/read/chartex.rs +++ b/crates/pptx/src/read/chartex.rs @@ -110,6 +110,9 @@ pub(crate) fn chartex_from(root: &El) -> Option { if c.categories.is_empty() { c.categories = d.categories.clone(); } + if let Some(st) = s.child("layoutPr").and_then(|l| l.child("subtotals")) { + c.subtotals = st.children_named("idx").filter_map(|i| i.u32("val")).map(|i| i as usize).take(MAX_POINTS).collect(); + } let name = s.child("tx").and_then(|t| t.find("v")).map(|v| v.text()).unwrap_or_default(); c.series.push(Series { name, diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index eb2b0a96..5df5dd7e 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -59,7 +59,18 @@ fn split_label(name: &str) -> String { } } -fn label(ctx: &mut RenderContext, rctx: &Ctx, m: Affine, text: &str, r: Rect, size: f64, align: Align, anchor: Anchor, col: Rgba, bold: bool) { +pub(crate) fn label( + ctx: &mut RenderContext, + rctx: &Ctx, + m: Affine, + text: &str, + r: Rect, + size: f64, + align: Align, + anchor: Anchor, + col: Rgba, + bold: bool, +) { let mut body = TextBody::from_text(text); body.body.inset_l = Some(0.0); body.body.inset_r = Some(0.0); @@ -150,7 +161,14 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } let pie = matches!(c.kind, ChartType::Pie | ChartType::Doughnut); // Legend placed by `c:legendPos`. - let legend_items: Vec<(String, Rgba)> = if pie { + let waterfall = c.kind == ChartType::Waterfall; + let legend_items: Vec<(String, Rgba)> = if waterfall { + ["Increase", "Decrease", "Total"].iter().enumerate().map(|(i, n)| (n.to_string(), series_color(rctx, c, i))).collect() + } else if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) { + c.categories.iter().enumerate().map(|(i, n)| (n.clone(), series_color(rctx, c, i))).collect() + } else if c.kind == ChartType::Funnel { + vec![] + } else if pie { c.categories.iter().enumerate().map(|(i, n)| (n.clone(), series_color(rctx, c, i))).collect() } else { c.series @@ -242,6 +260,10 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } return; } + if matches!(c.kind, ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst) { + crate::chartex::draw(ctx, rctx, c, m, area, base, text_col); + return; + } let combo = c.kind == ChartType::Combo; // Bars are drawn first and lines over them; a combo chart has both. let bar_kind = c.series.iter().map(|s| c.series_kind(s)).find(|k| k.is_bar_like()); @@ -399,7 +421,9 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h let sec = c.on_secondary(s); let v = s.values.get(i).copied().flatten().unwrap_or(0.0) / total; let start = band * i as f64 + (band - group) / 2.0 + if stacked { 0.0 } else { bar * bi as f64 }; - let (a, b) = if stacked { + let (a, b) = if waterfall { + c.waterfall_bars().get(i).copied().unwrap_or_default() + } else if stacked { if v >= 0.0 { let r = (pos_acc, pos_acc + v); pos_acc += v; @@ -416,10 +440,34 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h Rect::new(to_v(a), plot.y0 + start, to_v(b), plot.y0 + start + bar) } else { Rect::new(plot.x0 + start, to_axis(sec, b), plot.x0 + start + bar, to_axis(sec, a)) + } + .abs(); + // A waterfall colours increases, decreases and totals, and joins each bar's end + // to the next bar's start. + let fill = if waterfall { + let slot = if c.subtotals.contains(&i) { + 2 + } else if b < a { + 1 + } else { + 0 + }; + series_color(rctx, c, slot) + } else { + series_fill(rctx, c, *si, Some(s)) }; ctx.set_transform(m); - ctx.set_paint(color(series_fill(rctx, c, *si, Some(s)), 1.0)); + ctx.set_paint(color(fill, 1.0)); ctx.fill_rect(&r.abs()); + if waterfall && i + 1 < ncat && !horiz { + let y = to_v(b); + let mut j = BezPath::new(); + j.move_to((r.x1, y)); + j.line_to((r.x1 + (band - bar), y)); + ctx.set_paint(color(Rgba::rgb(0xA6, 0xA6, 0xA6), 1.0)); + ctx.set_stroke(kurbo::Stroke::new(0.75)); + ctx.stroke_path(&j); + } if c.data_labels { let t = c.data_label(s, s.values.get(i).copied().flatten().unwrap_or(0.0)); let lr = if horiz { diff --git a/crates/render/src/chartex.rs b/crates/render/src/chartex.rs new file mode 100644 index 00000000..faf8ec2e --- /dev/null +++ b/crates/render/src/chartex.rs @@ -0,0 +1,227 @@ +//! Office 2016 chart layouts the bar renderer can't draw: funnel, treemap and sunburst +//! (waterfall bars are drawn by [`crate::chart`]). + +use deckcraft_color::Rgba; +use deckcraft_model::chart::{Chart, ChartType}; +use deckcraft_model::resolve::Ctx; +use deckcraft_model::text::{Align, Anchor}; +use kurbo::{Affine, BezPath, Point, Rect, Shape as _}; +use vello_cpu::RenderContext; + +use crate::chart::{label, series_color}; +use crate::color; + +/// Draw `c` inside `area` (the space left by the title and legend). +pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, text_col: Rgba) { + let Some(s) = c.series.first() else { return }; + let vals: Vec = s.values.iter().map(|v| v.unwrap_or(0.0).max(0.0)).collect(); + if vals.iter().all(|v| *v <= 0.0) { + return; + } + let value_label = |v: f64| c.data_label(s, v); + let labels = c.data_labels || c.kind == ChartType::Funnel; + match c.kind { + ChartType::Funnel => funnel(ctx, rctx, c, m, area, base, text_col, &vals, &value_label), + ChartType::Treemap => treemap(ctx, rctx, c, m, area, base, &vals, labels.then_some(&value_label)), + _ => sunburst(ctx, rctx, c, m, area, base, &vals), + } +} + +/// Bars centred on one axis, as wide as their value, widest first as given: category labels on +/// the left, values inside. +#[allow(clippy::too_many_arguments)] +fn funnel( + ctx: &mut RenderContext, + rctx: &Ctx, + c: &Chart, + m: Affine, + area: Rect, + base: f64, + text_col: Rgba, + vals: &[f64], + value_label: &dyn Fn(f64) -> String, +) { + let n = vals.len().max(1); + let gutter = c.categories.iter().map(|n| n.chars().count()).max().unwrap_or(0) as f64 * base * 0.55 + base; + let plot = Rect::new(area.x0 + gutter.min(area.width() * 0.4), area.y0, area.x1, area.y1); + let max = vals.iter().copied().fold(0.0, f64::max).max(1e-9); + let band = plot.height() / n as f64; + let cx = plot.center().x; + for (i, v) in vals.iter().enumerate() { + let w = v / max * plot.width(); + let y = plot.y0 + band * i as f64; + let r = Rect::new(cx - w / 2.0, y + band * 0.05, cx + w / 2.0, y + band * 0.95); + ctx.set_transform(m); + ctx.set_paint(color(series_color(rctx, c, 0), 1.0)); + ctx.fill_rect(&r); + if let Some(name) = c.categories.get(i) { + label(ctx, rctx, m, name, Rect::new(area.x0, y, plot.x0 - base * 0.4, y + band), base, Align::Right, Anchor::Middle, text_col, false); + } + label(ctx, rctx, m, &value_label(*v), Rect::new(cx - 60.0, y, cx + 60.0, y + band), base, Align::Center, Anchor::Middle, Rgba::WHITE, false); + } +} + +/// Squarified treemap (Bruls, Huizing, van Wijk): rows of tiles laid along the short side, +/// each row kept while it improves the worst aspect ratio. +pub fn squarify(vals: &[f64], r: Rect) -> Vec { + let total: f64 = vals.iter().sum(); + let mut out = vec![Rect::ZERO; vals.len()]; + if total <= 0.0 || r.area() <= 0.0 { + return out; + } + let scale = r.area() / total; + let mut order: Vec = (0..vals.len()).filter(|&i| vals.get(i).is_some_and(|v| *v > 0.0)).collect(); + order.sort_by(|a, b| vals[*b].total_cmp(&vals[*a])); + let mut free = r; + let mut i = 0; + while i < order.len() { + let short = free.width().min(free.height()); + let worst = |row: &[usize]| { + let areas: Vec = row.iter().map(|&k| vals[k] * scale).collect(); + let sum: f64 = areas.iter().sum(); + let (mx, mn) = (areas.iter().copied().fold(0.0, f64::max), areas.iter().copied().fold(f64::MAX, f64::min)); + ((short * short * mx) / (sum * sum)).max((sum * sum) / (short * short * mn)) + }; + let mut end = i + 1; + while end < order.len() && worst(&order[i..=end]) <= worst(&order[i..end]) { + end += 1; + } + let row = &order[i..end]; + let sum: f64 = row.iter().map(|&k| vals[k] * scale).sum(); + let thick = sum / short; + let mut at = 0.0; + for &k in row { + let len = vals[k] * scale / thick; + out[k] = if free.width() >= free.height() { + Rect::new(free.x0, free.y0 + at, free.x0 + thick, free.y0 + at + len) + } else { + Rect::new(free.x0 + at, free.y0, free.x0 + at + len, free.y0 + thick) + }; + at += len; + } + free = if free.width() >= free.height() { + Rect::new(free.x0 + thick, free.y0, free.x1, free.y1) + } else { + Rect::new(free.x0, free.y0 + thick, free.x1, free.y1) + }; + i = end; + } + out +} + +#[allow(clippy::too_many_arguments)] +fn treemap( + ctx: &mut RenderContext, + rctx: &Ctx, + c: &Chart, + m: Affine, + area: Rect, + base: f64, + vals: &[f64], + value_label: Option<&dyn Fn(f64) -> String>, +) { + for (i, t) in squarify(vals, area).into_iter().enumerate() { + if t.area() <= 0.0 { + continue; + } + ctx.set_transform(m); + ctx.set_paint(color(series_color(rctx, c, i), 1.0)); + ctx.fill_rect(&t); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.5)); + ctx.stroke_path(&t.to_path(0.1)); + let mut text = c.categories.get(i).cloned().unwrap_or_default(); + if let (Some(f), Some(v)) = (value_label, vals.get(i)) { + text = format!("{text}\n{}", f(*v)); + } + let inner = t.inset(-base * 0.3); + if inner.width() > base && inner.height() > base { + label(ctx, rctx, m, &text, inner, base, Align::Left, Anchor::Top, Rgba::WHITE, false); + } + } +} + +/// One ring of the innermost level, with the centre left open. +fn sunburst(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, vals: &[f64]) { + let total: f64 = vals.iter().sum(); + let r = area.width().min(area.height()) / 2.0 * 0.95; + let hole = r * 0.35; + let cen = area.center(); + let mut a0 = -std::f64::consts::FRAC_PI_2; + for (i, v) in vals.iter().enumerate() { + let sweep = v / total * std::f64::consts::TAU; + if sweep <= 0.0 { + continue; + } + let mut p = BezPath::new(); + p.move_to(Point::new(cen.x + hole * a0.cos(), cen.y + hole * a0.sin())); + p.line_to(Point::new(cen.x + r * a0.cos(), cen.y + r * a0.sin())); + p.extend(kurbo::Arc::new(cen, (r, r), a0, sweep, 0.0).append_iter(0.1)); + let a1 = a0 + sweep; + p.line_to(Point::new(cen.x + hole * a1.cos(), cen.y + hole * a1.sin())); + p.extend(kurbo::Arc::new(cen, (hole, hole), a1, -sweep, 0.0).append_iter(0.1)); + p.close_path(); + ctx.set_transform(m); + ctx.set_paint(color(series_color(rctx, c, i), 1.0)); + ctx.fill_path(&p); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.5)); + ctx.stroke_path(&p); + if let Some(name) = c.categories.get(i).filter(|_| sweep > 0.25) { + let mid = a0 + sweep / 2.0; + let rr = (r + hole) / 2.0; + let lp = Point::new(cen.x + rr * mid.cos(), cen.y + rr * mid.sin()); + label( + ctx, + rctx, + m, + name, + Rect::new(lp.x - 50.0, lp.y - base, lp.x + 50.0, lp.y + base), + base, + Align::Center, + Anchor::Middle, + Rgba::WHITE, + false, + ); + } + a0 = a1; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn squarify_tiles_the_area_in_proportion() { + let vals = [6.0, 6.0, 4.0, 3.0, 2.0, 2.0, 1.0]; + let r = Rect::new(0.0, 0.0, 600.0, 400.0); + let tiles = squarify(&vals, r); + let total: f64 = vals.iter().sum(); + for (t, v) in tiles.iter().zip(vals) { + assert!((t.area() - v / total * r.area()).abs() < 1e-6, "{t:?} for {v}"); + assert!(t.x0 >= -1e-9 && t.y0 >= -1e-9 && t.x1 <= 600.0 + 1e-6 && t.y1 <= 400.0 + 1e-6, "{t:?}"); + // Squarified: no sliver tiles. + let aspect = t.width().max(t.height()) / t.width().min(t.height()); + assert!(aspect < 4.0, "{t:?}"); + } + for (i, a) in tiles.iter().enumerate() { + for b in &tiles[i + 1..] { + assert!(a.intersect(*b).area() < 1e-6, "{a:?} overlaps {b:?}"); + } + } + assert!(squarify(&[0.0, 0.0], r).iter().all(|t| t.area() == 0.0)); + } + + #[test] + fn waterfall_bars_float_and_totals_stand() { + let c = Chart { + kind: ChartType::Waterfall, + subtotals: vec![3], + series: vec![deckcraft_model::chart::Series { values: vec![Some(100.0), Some(40.0), Some(-25.0), Some(115.0)], ..Default::default() }], + ..Default::default() + }; + assert_eq!(c.waterfall_bars(), [(0.0, 100.0), (100.0, 140.0), (140.0, 115.0), (0.0, 115.0)]); + assert_eq!(c.value_range(), (0.0, 140.0)); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eb139555..e6570c5c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -10,6 +10,7 @@ mod bevel; mod camera; mod chart; +mod chartex; mod colr; mod compound; diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 0de3d695..3b9e1a3a 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1711,3 +1711,38 @@ fn east_asian_vertical_text_stands_ideographs_upright() { let (w, h) = ink(deckcraft_model::text::TextDir::Vertical); assert!(h > w * 3, "vert: stroke on its side {w}x{h}"); } + +#[test] +fn office_2016_charts_draw_their_own_layouts() { + let chart = |kind: ChartType, vals: &[f64], subtotals: Vec| { + let c = Chart { + kind, + title: None, + legend: None, + gridlines: false, + subtotals, + categories: (0..vals.len()).map(|i| format!("C{i}")).collect(), + series: vec![deckcraft_model::chart::Series { values: vals.iter().map(|v| Some(*v)).collect(), ..Default::default() }], + ..Default::default() + }; + let sh = Shape { id: ShapeId(621), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }; + render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }) + }; + let ink = |img: &crate::Image, x: u32, y: u32| { + let p = img.pixel(x, y); + p[0] < 235 || p[1] < 235 || p[2] < 235 + }; + // Waterfall: the decrease floats (nothing under it near the axis), the total stands on it. + let img = chart(ChartType::Waterfall, &[100.0, 40.0, -25.0, 115.0], vec![3]); + let plot_bottom = (300..530).rev().find(|&y| ink(&img, 120, y)).unwrap_or(500); + let band = |i: f64| (80.0 + (900.0 - 80.0) * (i + 0.5) / 4.0) as u32; + assert!(!ink(&img, band(2.0), plot_bottom - 20), "the decrease bar reaches the axis"); + assert!(ink(&img, band(3.0), plot_bottom - 20), "the total bar doesn't stand on the axis"); + assert!(ink(&img, band(1.0), 100), "the increase bar is missing"); + // Funnel, treemap and sunburst fill the middle of the chart. + for kind in [ChartType::Funnel, ChartType::Treemap, ChartType::Sunburst] { + let img = chart(kind, &[50.0, 30.0, 20.0], vec![]); + let covered = (0..540).step_by(10).flat_map(|y| (0..960).step_by(10).map(move |x| (x, y))).filter(|&(x, y)| ink(&img, x, y)).count(); + assert!(covered > 1000, "{kind:?}: {covered} samples inked"); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index a9cdda81..10e2fa41 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -146,8 +146,10 @@ Detail in [file-format-parity.md](file-format-parity.md). ### Charts: data grid, elements and the newer chart types - **Evidence:** checklist rows "Edit chart data" (P0, partial), "Chart elements" (partial), "Other chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, Pareto, - box & whisker) are read for their data and written back unchanged, but they're drawn with the - bar renderer and an edited one is saved as a plain column chart; region maps aren't read. + box & whisker) are read for their data and written back unchanged. Waterfalls (floating bars, + totals, connectors), funnels, treemaps (squarified) and sunbursts (innermost ring) are drawn; + histograms and box & whisker still use the column renderer, an edited one is saved as a plain + column chart, and region maps aren't read. - **Impact:** most business decks have charts; editing their data is routine. - **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h). From 07d12ed8d994acef64ff131ffd9b2ec208ac20c2 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 00:43:00 +0300 Subject: [PATCH 03/27] Metafile: EMF+ images, text, gradient brushes and clipping Builds on the EMF+ only reader: bitmap images (raw pixel formats and compressed PNG/JPEG payloads) through DrawImage and DrawImagePoints, DrawString and character-code DrawDriverString, hatch, texture, linear and path gradient brushes, clip rectangles, paths and regions, and objects split across several records. Co-authored-by: Cursor --- Cargo.lock | 1 + crates/metafile/Cargo.toml | 1 + crates/metafile/src/canvas.rs | 74 +++ crates/metafile/src/clip.rs | 251 +++++++++ crates/metafile/src/emfplus.rs | 942 ++++++++++++++++++++++++++++----- crates/metafile/src/lib.rs | 1 + crates/metafile/src/tests.rs | 160 ++++++ 7 files changed, 1299 insertions(+), 131 deletions(-) create mode 100644 crates/metafile/src/clip.rs diff --git a/Cargo.lock b/Cargo.lock index 4044fce6..434fd547 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1050,6 +1050,7 @@ dependencies = [ name = "deckcraft-metafile" version = "0.6.1" dependencies = [ + "image", "kurbo", "thiserror 2.0.21", ] diff --git a/crates/metafile/Cargo.toml b/crates/metafile/Cargo.toml index 63f7ee77..1b564539 100644 --- a/crates/metafile/Cargo.toml +++ b/crates/metafile/Cargo.toml @@ -11,3 +11,4 @@ workspace = true [dependencies] kurbo = { workspace = true } thiserror = { workspace = true } +image = { workspace = true } diff --git a/crates/metafile/src/canvas.rs b/crates/metafile/src/canvas.rs index 99f42071..ddb4b299 100644 --- a/crates/metafile/src/canvas.rs +++ b/crates/metafile/src/canvas.rs @@ -17,6 +17,8 @@ pub(crate) const PS_NULL: u32 = 5; /// Pen style bits; the rest of the style word holds end-cap, join and type flags. pub(crate) const PS_STYLE_MASK: u32 = 0x0F; pub(crate) const BS_SOLID: u32 = 0; +/// Cells on the longer side of a computed (gradient or texture) fill tile. +const SHADE_RES: usize = 128; pub(crate) const BS_NULL: u32 = 1; pub(crate) const BS_HATCHED: u32 = 2; /// BS_PATTERN, BS_DIBPATTERN and BS_DIBPATTERNPT: the brush paints a bitmap tile. @@ -304,6 +306,8 @@ pub(crate) struct Canvas { in_path: bool, path: Vec, full: bool, + /// Device clip rectangle `[l, t, r, b]` applied to everything drawn (EMF+ sets it). + clip: Option<[f64; 4]>, } impl Canvas { @@ -327,6 +331,7 @@ impl Canvas { in_path: false, path: Vec::new(), full: false, + clip: None, } } @@ -336,6 +341,17 @@ impl Canvas { } fn push(&mut self, raw: Raw) { + match self.clip { + None => self.push_raw(raw), + Some(c) => { + for r in crate::clip::clip_raw(raw, c) { + self.push_raw(r); + } + } + } + } + + fn push_raw(&mut self, raw: Raw) { let cost = match &raw { Raw::Path { ops, .. } => ops.len() * std::mem::size_of::(), // A tile shared with its brush is counted once (by the brush); one made for this fill @@ -456,6 +472,64 @@ impl Canvas { self.brush = brush; } + /// Sets the device clip rectangle `[l, t, r, b]` (None: unclipped). + pub fn set_clip(&mut self, clip: Option<[f64; 4]>) { + self.clip = clip; + } + + /// Logical to device as one affine transform (the mapping is affine). + pub fn device_affine(&self) -> Xform { + let o = self.map.device((0.0, 0.0)); + let x = self.map.device((1.0, 0.0)); + let y = self.map.device((0.0, 1.0)); + [x.0 - o.0, x.1 - o.1, y.0 - o.0, y.1 - o.1, o.0, o.1] + } + + /// Text colour, font and alignment set directly (EMF+ strings name their own font and brush); + /// the background is transparent. + pub fn set_text_style(&mut self, color: [u8; 4], font: LogFont, align: u32) { + self.text.color = color; + self.text.bk_opaque = false; + self.text.align = align; + self.text.font = Some(Arc::new(font)); + } + + /// Fills logical `ops` with a ready-made tile, laid from the device origin. + pub fn tile_shape(&mut self, ops: &[Op], tile: Arc) { + let dev: Vec = ops.iter().map(|o| self.map_op(*o)).collect(); + if dev.len() >= 2 { + let even_odd = self.even_odd; + self.push(Raw::Pattern { ops: dev, even_odd, tile }); + } + } + + /// Fills logical `ops` with a colour computed per device point (gradients, textures): a tile + /// of at most [`SHADE_RES`] cells on the longer side, laid out so that its one repeat covers + /// the shape. + pub fn shade_shape(&mut self, ops: &[Op], shade: &dyn Fn(P) -> [u8; 4]) { + let dev: Vec = ops.iter().map(|o| self.map_op(*o)).collect(); + let Some([l, t, r, b]) = crate::clip::ops_bbox(&dev) else { return }; + let side = (r - l).max(b - t); + if dev.len() < 2 || !(side.is_finite() && side > 0.0) { + return; + } + let cell = side / SHADE_RES as f64; + let w = (((r - l) / cell).ceil() as usize + 1).clamp(1, SHADE_RES + 1); + let h = (((b - t) / cell).ceil() as usize + 1).clamp(1, SHADE_RES + 1); + let (pw, ph) = (w as f64 * cell, h as f64 * cell); + let mut px = Vec::with_capacity(w * h * 4); + for j in 0..h { + // The device position this tile row stands for inside the shape's box. + let y = t + (j as f64 * cell - t).rem_euclid(ph) + cell / 2.0; + for i in 0..w { + let x = l + (i as f64 * cell - l).rem_euclid(pw) + cell / 2.0; + px.extend_from_slice(&shade((x, y))); + } + } + let even_odd = self.even_odd; + self.push(Raw::Pattern { ops: dev, even_odd, tile: Arc::new(RawTile { w, h, px, cell }) }); + } + /// Pushes the drawing state. Past [`MAX_STACK`] levels the parse stops, since later restores /// could no longer match the file. pub fn save_dc(&mut self) { diff --git a/crates/metafile/src/clip.rs b/crates/metafile/src/clip.rs new file mode 100644 index 00000000..b0db77c3 --- /dev/null +++ b/crates/metafile/src/clip.rs @@ -0,0 +1,251 @@ +//! Clipping drawn output to a device rectangle (EMF+ clip rectangles, paths and regions are +//! reduced to their bounding rectangle). Shapes wholly inside pass unchanged; shapes crossing the +//! edge are flattened and cut: fills with Sutherland–Hodgman per subpath, outlines with +//! Liang–Barsky per segment. Bitmaps are cropped and text gets a clip rectangle. + +use crate::canvas::Raw; +use crate::dib::Rgba; +use crate::geom::{Op, P}; + +/// Line segments per Bézier when a crossing shape is flattened. +const CURVE_STEPS: usize = 16; + +/// The bounding box `[l, t, r, b]` of the points in `ops` (control points included). +pub(crate) fn ops_bbox(ops: &[Op]) -> Option<[f64; 4]> { + let mut b: Option<[f64; 4]> = None; + let mut add = |p: P| { + let v = b.get_or_insert([p.0, p.1, p.0, p.1]); + *v = [v[0].min(p.0), v[1].min(p.1), v[2].max(p.0), v[3].max(p.1)]; + }; + for op in ops { + match *op { + Op::M(p) | Op::L(p) => add(p), + Op::C(a, c, d) => { + add(a); + add(c); + add(d); + } + Op::Z => {} + } + } + b +} + +pub(crate) fn intersect(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { + let r = [a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]; + // An empty intersection stays empty (zero size) so it clips everything away. + if r[2] < r[0] || r[3] < r[1] { [r[0], r[1], r[0], r[1]] } else { r } +} + +fn inside(b: [f64; 4], c: [f64; 4]) -> bool { + b[0] >= c[0] && b[1] >= c[1] && b[2] <= c[2] && b[3] <= c[3] +} + +fn disjoint(b: [f64; 4], c: [f64; 4]) -> bool { + b[2] < c[0] || b[0] > c[2] || b[3] < c[1] || b[1] > c[3] || c[2] <= c[0] || c[3] <= c[1] +} + +/// Clips one drawn item; a shape that is both filled and stroked comes back as two items. +pub(crate) fn clip_raw(raw: Raw, c: [f64; 4]) -> Vec { + match raw { + Raw::Path { ops, fill, stroke, even_odd } => { + let Some(b) = ops_bbox(&ops) else { return Vec::new() }; + if inside(b, c) { + return vec![Raw::Path { ops, fill, stroke, even_odd }]; + } + if disjoint(b, c) { + return Vec::new(); + } + let subs = flatten(&ops); + let mut out = Vec::new(); + if fill.is_some() { + let f = clip_fill(&subs, c); + if !f.is_empty() { + out.push(Raw::Path { ops: f, fill, stroke: None, even_odd }); + } + } + if stroke.is_some() { + let s = clip_stroke(&subs, c); + if !s.is_empty() { + out.push(Raw::Path { ops: s, fill: None, stroke, even_odd }); + } + } + out + } + Raw::Pattern { ops, even_odd, tile } => { + let Some(b) = ops_bbox(&ops) else { return Vec::new() }; + if inside(b, c) { + return vec![Raw::Pattern { ops, even_odd, tile }]; + } + if disjoint(b, c) { + return Vec::new(); + } + let f = clip_fill(&flatten(&ops), c); + if f.is_empty() { Vec::new() } else { vec![Raw::Pattern { ops: f, even_odd, tile }] } + } + Raw::Bitmap { rect, img } => crop(rect, img, c).map(|(rect, img)| vec![Raw::Bitmap { rect, img }]).unwrap_or_default(), + Raw::Text(mut t) => { + let cur = t.clip.map_or(c, |[a, b, d, e]| intersect(c, [f64::from(a), f64::from(b), f64::from(d), f64::from(e)])); + t.clip = Some(cur.map(|v| v as f32)); + vec![Raw::Text(t)] + } + } +} + +/// Subpaths as point lists with their closed flag. +fn flatten(ops: &[Op]) -> Vec<(Vec

, bool)> { + let mut subs: Vec<(Vec

, bool)> = Vec::new(); + for op in ops { + match *op { + Op::M(p) => subs.push((vec![p], false)), + Op::L(p) => match subs.last_mut() { + Some(s) => s.0.push(p), + None => subs.push((vec![p], false)), + }, + Op::C(a, b, d) => { + let Some(s) = subs.last_mut() else { continue }; + let Some(&p0) = s.0.last() else { continue }; + for i in 1..=CURVE_STEPS { + let t = i as f64 / CURVE_STEPS as f64; + let u = 1.0 - t; + let k = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t]; + s.0.push((k[0] * p0.0 + k[1] * a.0 + k[2] * b.0 + k[3] * d.0, k[0] * p0.1 + k[1] * a.1 + k[2] * b.1 + k[3] * d.1)); + } + } + Op::Z => { + if let Some(s) = subs.last_mut() { + s.1 = true; + // Drawing after a close starts from the subpath's first point. + let first = s.0.first().copied(); + if let Some(f) = first { + subs.push((vec![f], false)); + } + } + } + } + } + subs.retain(|s| s.0.len() > 1); + subs +} + +fn clip_fill(subs: &[(Vec

, bool)], c: [f64; 4]) -> Vec { + let mut ops = Vec::new(); + for (pts, _) in subs { + let mut poly = pts.clone(); + // Left, top, right, bottom edges in turn. + for edge in 0..4 { + let keep = |p: P| match edge { + 0 => p.0 >= c[0], + 1 => p.1 >= c[1], + 2 => p.0 <= c[2], + _ => p.1 <= c[3], + }; + let cut = |a: P, b: P| { + let t = match edge { + 0 => (c[0] - a.0) / (b.0 - a.0), + 1 => (c[1] - a.1) / (b.1 - a.1), + 2 => (c[2] - a.0) / (b.0 - a.0), + _ => (c[3] - a.1) / (b.1 - a.1), + }; + (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t) + }; + let mut next = Vec::with_capacity(poly.len() + 4); + for i in 0..poly.len() { + let (a, b) = (poly[(i + poly.len() - 1) % poly.len()], poly[i]); + match (keep(a), keep(b)) { + (true, true) => next.push(b), + (true, false) => next.push(cut(a, b)), + (false, true) => { + next.push(cut(a, b)); + next.push(b); + } + (false, false) => {} + } + } + poly = next; + if poly.is_empty() { + break; + } + } + if poly.len() >= 3 { + ops.push(Op::M(poly[0])); + ops.extend(poly[1..].iter().map(|p| Op::L(*p))); + ops.push(Op::Z); + } + } + ops +} + +fn clip_stroke(subs: &[(Vec

, bool)], c: [f64; 4]) -> Vec { + let mut ops = Vec::new(); + for (pts, closed) in subs { + let mut pts = pts.clone(); + if *closed && let Some(&f) = pts.first() { + pts.push(f); + } + let mut last: Option

= None; + for w in pts.windows(2) { + let Some((a, b)) = segment(w[0], w[1], c) else { + last = None; + continue; + }; + if last != Some(a) { + ops.push(Op::M(a)); + } + ops.push(Op::L(b)); + last = Some(b); + } + } + ops +} + +/// Liang–Barsky: the part of segment `a`–`b` inside `c`. +fn segment(a: P, b: P, c: [f64; 4]) -> Option<(P, P)> { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let (mut t0, mut t1) = (0.0f64, 1.0f64); + for (p, q) in [(-dx, a.0 - c[0]), (dx, c[2] - a.0), (-dy, a.1 - c[1]), (dy, c[3] - a.1)] { + if p == 0.0 { + if q < 0.0 { + return None; + } + } else { + let r = q / p; + if p < 0.0 { + t0 = t0.max(r); + } else { + t1 = t1.min(r); + } + } + } + (t0 <= t1).then_some(((a.0 + dx * t0, a.1 + dy * t0), (a.0 + dx * t1, a.1 + dy * t1))) +} + +/// Crops a bitmap drawn into `rect` = `[x, y, w, h]` (device, positive extents) to `c`. +fn crop(rect: [f64; 4], img: Rgba, c: [f64; 4]) -> Option<([f64; 4], Rgba)> { + let [x, y, w, h] = rect; + let b = [x, y, x + w, y + h]; + if inside(b, c) { + return Some((rect, img)); + } + if disjoint(b, c) || w <= 0.0 || h <= 0.0 || img.w == 0 || img.h == 0 { + return None; + } + let i = intersect(b, c); + let (kx, ky) = (img.w as f64 / w, img.h as f64 / h); + let x0 = (((i[0] - x) * kx).floor().max(0.0) as usize).min(img.w); + let x1 = (((i[2] - x) * kx).ceil().max(0.0) as usize).min(img.w); + let y0 = (((i[1] - y) * ky).floor().max(0.0) as usize).min(img.h); + let y1 = (((i[3] - y) * ky).ceil().max(0.0) as usize).min(img.h); + if x1 <= x0 || y1 <= y0 { + return None; + } + let mut px = Vec::with_capacity((x1 - x0) * (y1 - y0) * 4); + for row in y0..y1 { + // Rows are top-down unless the image says otherwise. + let r = if img.bottom_up { img.h - 1 - row } else { row }; + let s = (r * img.w + x0) * 4; + px.extend_from_slice(img.px.get(s..s + (x1 - x0) * 4)?); + } + let out = [x + x0 as f64 / kx, y + y0 as f64 / ky, (x1 - x0) as f64 / kx, (y1 - y0) as f64 / ky]; + Some((out, Rgba { w: x1 - x0, h: y1 - y0, bottom_up: false, px })) +} diff --git a/crates/metafile/src/emfplus.rs b/crates/metafile/src/emfplus.rs index 3aef2842..465f2a1f 100644 --- a/crates/metafile/src/emfplus.rs +++ b/crates/metafile/src/emfplus.rs @@ -2,14 +2,26 @@ //! //! Most EMF+ files are *dual*: the same picture is also drawn with plain EMF records, which //! [`crate::emf`] already plays, so this reader ignores their EMF+ part. An *EMF+ only* file has -//! nothing else to draw, so it used to show as an empty picture; for those this reader plays the -//! vector records: solid brushes and pens, paths, rectangles, polygons, lines, ellipses and -//! Béziers, under the world and page transforms, save/restore and containers. Images, text, -//! gradient brushes, clipping and arcs are not drawn yet. +//! nothing else to draw; for those this reader plays the records: +//! - brushes (solid, hatch, texture, linear and path gradients), pens, paths, images, fonts, +//! string formats and regions, including objects split across several records; +//! - rectangles, polygons, lines, ellipses, paths and Béziers; +//! - bitmaps (raw pixel formats, and PNG/JPEG/GIF/TIFF/BMP payloads) and strings (DrawString, +//! DrawDriverString with character codes); +//! - world and page transforms, save/restore and containers; +//! - clipping to rectangles, and to the bounding rectangle of clip paths and regions. +//! +//! Not drawn yet: nested metafile images, glyph-index driver strings, arcs and pies, image +//! attributes, and region combinations other than intersect and union. + +use std::sync::Arc; use crate::bytes::Bytes; -use crate::canvas::{BS_NULL, BS_SOLID, Brush, Canvas, PS_NULL, Pen, Xform, mul}; +use crate::canvas::{BS_NULL, BS_SOLID, Brush, Canvas, LogFont, PS_NULL, Pen, RawTile, Xform, hatch_tile, mul}; +use crate::clip::{intersect, ops_bbox}; +use crate::dib::Rgba; use crate::geom::{Op, P}; +use crate::text::{ETO_CLIPPED, Font}; /// "EMF+" as the comment identifier of an EMR_COMMENT holding EMF+ records. pub(crate) const EMF_PLUS: u32 = 0x2B46_4D45; @@ -19,12 +31,158 @@ const MAX_OBJECTS: usize = 64; const MAX_STACK: usize = 64; /// Points per path or point list read, as the plain EMF reader caps paths. const MAX_POINTS: usize = 100_000; +/// Gradient stops read from a brush. +const MAX_STOPS: usize = 256; +/// Characters per string. +const MAX_CHARS: usize = 32_768; +/// Bytes gathered for one object split across records. +const MAX_OBJECT_BYTES: usize = 64 * 1024 * 1024; +/// Nesting depth of region nodes. +const MAX_REGION_DEPTH: usize = 32; -#[derive(Clone, Debug)] +const TA_RIGHT: u32 = 2; +const TA_CENTER: u32 = 6; +const TA_BOTTOM: u32 = 8; +const TA_BASELINE: u32 = 24; + +/// What a brush paints. Coordinates are in brush space, which `xf` maps to world space. +#[derive(Clone)] +enum Fill { + Solid([u8; 4]), + Hatch { style: u32, fg: [u8; 4], bg: [u8; 4] }, + Linear { rect: [f64; 4], stops: Vec<(f64, [u8; 4])>, wrap: u32, xf: Xform }, + Radial { center: P, radius: P, stops: Vec<(f64, [u8; 4])>, xf: Xform }, + Texture { img: Arc, wrap: u32, xf: Xform }, +} + +impl Fill { + /// One colour standing for the brush (pens and text draw solid). + fn color(&self) -> [u8; 4] { + match self { + Fill::Solid(c) | Fill::Hatch { fg: c, .. } => *c, + Fill::Linear { stops, .. } | Fill::Radial { stops, .. } => ramp(stops, 0.5), + Fill::Texture { img, .. } => img.px.get(..4).and_then(|c| c.try_into().ok()).unwrap_or([0, 0, 0, 255]), + } + } + + /// The colour at a brush-space point. + fn sample(&self, p: P) -> [u8; 4] { + match self { + Fill::Solid(c) | Fill::Hatch { fg: c, .. } => *c, + Fill::Linear { rect, stops, wrap, .. } => { + let t = if rect[2] == 0.0 { 0.0 } else { (p.0 - rect[0]) / rect[2] }; + ramp(stops, wrap_t(t, *wrap)) + } + Fill::Radial { center, radius, stops, .. } => { + let (dx, dy) = ((p.0 - center.0) / radius.0.max(1e-9), (p.1 - center.1) / radius.1.max(1e-9)); + ramp(stops, dx.hypot(dy).min(1.0)) + } + Fill::Texture { img, wrap, .. } => { + let (w, h) = (img.w as f64, img.h as f64); + if w == 0.0 || h == 0.0 { + return [0, 0, 0, 0]; + } + if *wrap == 4 && !(0.0..w).contains(&p.0) || *wrap == 4 && !(0.0..h).contains(&p.1) { + return [0, 0, 0, 0]; + } + let x = (p.0.rem_euclid(w) as usize).min(img.w - 1); + let y = (p.1.rem_euclid(h) as usize).min(img.h - 1); + let i = (y * img.w + x) * 4; + img.px.get(i..i + 4).and_then(|c| c.try_into().ok()).unwrap_or([0, 0, 0, 0]) + } + } + } + + fn xf(&self) -> Xform { + match self { + Fill::Linear { xf, .. } | Fill::Radial { xf, .. } | Fill::Texture { xf, .. } => *xf, + _ => IDENTITY, + } + } +} + +/// Where `t` lands under a wrap mode: tiled, mirrored (flip modes) or clamped. +fn wrap_t(t: f64, wrap: u32) -> f64 { + if !t.is_finite() { + return 0.0; + } + match wrap { + 1 | 3 => { + let m = t.rem_euclid(2.0); + if m > 1.0 { 2.0 - m } else { m } + } + 4 => t.clamp(0.0, 1.0), + _ => t.rem_euclid(1.0), + } +} + +/// The colour at `t` along sorted stops. +fn ramp(stops: &[(f64, [u8; 4])], t: f64) -> [u8; 4] { + let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return [0, 0, 0, 0] }; + if t <= first.0 { + return first.1; + } + for w in stops.windows(2) { + let ((a, ca), (b, cb)) = (w[0], w[1]); + if t <= b { + let k = if b > a { (t - a) / (b - a) } else { 0.0 }; + return lerp(ca, cb, k); + } + } + last.1 +} + +fn lerp(a: [u8; 4], b: [u8; 4], k: f64) -> [u8; 4] { + let mut c = [0u8; 4]; + for i in 0..4 { + c[i] = (f64::from(a[i]) + (f64::from(b[i]) - f64::from(a[i])) * k).round().clamp(0.0, 255.0) as u8; + } + c +} + +#[derive(Clone)] +struct FontObj { + em: f64, + unit: u32, + style: u32, + family: String, +} + +#[derive(Clone, Copy, Debug, Default)] +struct Format { + flags: u32, + align: u32, + line_align: u32, +} + +#[derive(Clone)] enum Obj { - Brush([u8; 4]), - Pen { color: [u8; 4], width: f64 }, + Brush(Fill), + Pen { + color: [u8; 4], + width: f64, + }, Path(Vec), + Image(Arc), + Font(FontObj), + Format(Format), + /// A region's bounding rectangle `[l, t, r, b]` in world units; None is infinite. + Region(Option<[f64; 4]>), +} + +/// An object arriving in several records: its id, type, total size and the bytes so far. +struct Partial { + id: usize, + kind: u16, + total: usize, + data: Vec, +} + +struct Saved { + id: u32, + world: Xform, + page: f64, + clip: Option<[f64; 4]>, } pub(crate) struct Plus { @@ -33,13 +191,16 @@ pub(crate) struct Plus { dpi: f64, world: Xform, page: f64, + /// Device clip rectangle `[l, t, r, b]`; None is unclipped. + clip: Option<[f64; 4]>, objects: Vec>, - stack: Vec<(u32, Xform, f64)>, + stack: Vec, + partial: Option, } impl Plus { pub fn new() -> Self { - Plus { active: false, dpi: 96.0, world: IDENTITY, page: 1.0, objects: vec![None; MAX_OBJECTS], stack: Vec::new() } + Plus { active: false, dpi: 96.0, world: IDENTITY, page: 1.0, clip: None, objects: vec![None; MAX_OBJECTS], stack: Vec::new(), partial: None } } /// Plays the EMF+ records of one EMR_COMMENT payload (after the identifier). @@ -60,6 +221,15 @@ impl Plus { } } + /// World units to device pixels before the canvas's own mapping. + fn xf(&self) -> Xform { + mul(self.world, [self.page, 0.0, 0.0, self.page, 0.0, 0.0]) + } + + fn painter(&self) -> Painter { + Painter { xf: self.xf(), clip: self.clip } + } + fn record(&mut self, cv: &mut Canvas, typ: u16, flags: u16, r: Bytes) -> Option<()> { if typ == 0x4001 { // Header: dual files also draw with EMF records, which win. @@ -76,38 +246,83 @@ impl Plus { let id = usize::from(flags & 0xFF); match typ { 0x4008 => self.object(flags, r), - 0x400A => self.fill_rects(cv, flags, r), - 0x400B => self.with_pen(id, |p| p.draw_rects(cv, compressed, r)), - 0x400C => self.fill_polygon(cv, flags, r), - 0x400D => self.with_pen(id, |p| { - let pts = points(r, 4, r.u32(0)? as usize, compressed)?; - p.shape(cv, poly_ops(&pts, flags & 0x2000 != 0), false, true); + 0x400A => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let p = self.painter(); + for rc in rects(r, 8, r.u32(4)? as usize, compressed)? { + p.fill(cv, &rect_ops(rc), &fill); + } Some(()) - }), - 0x400E => self.fill_ellipse(cv, flags, r), - 0x400F => self.with_pen(id, |p| { - let rect = rects(r, 0, 1, compressed)?; - p.shape(cv, ellipse_ops(*rect.first()?), false, true); - Some(()) - }), - 0x4014 => self.fill_path(cv, flags, r), - 0x4015 => { - let path = self.path(id); - r.u32(0).and_then(|pen| { - self.with_pen(pen as usize, |p| { - p.shape(cv, path?, false, true); - Some(()) - }) - }) } - 0x4019 => self.with_pen(id, |p| { + 0x400B => { + let pen = self.pen(id)?; + let p = self.painter(); + for rc in rects(r, 4, r.u32(0)? as usize, compressed)? { + p.stroke(cv, &rect_ops(rc), pen); + } + Some(()) + } + 0x400C => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let pts = points(r, 8, r.u32(4)? as usize, compressed)?; + self.painter().fill(cv, &poly_ops(&pts, true), &fill); + Some(()) + } + 0x400D => { + let pen = self.pen(id)?; + let pts = points(r, 4, r.u32(0)? as usize, compressed)?; + self.painter().stroke(cv, &poly_ops(&pts, flags & 0x2000 != 0), pen); + Some(()) + } + 0x400E => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let rect = *rects(r, 4, 1, compressed)?.first()?; + self.painter().fill(cv, &ellipse_ops(rect), &fill); + Some(()) + } + 0x400F => { + let pen = self.pen(id)?; + let rect = *rects(r, 0, 1, compressed)?.first()?; + self.painter().stroke(cv, &ellipse_ops(rect), pen); + Some(()) + } + 0x4014 => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let ops = self.path(id)?; + self.painter().fill(cv, &ops, &fill); + Some(()) + } + 0x4015 => { + let ops = self.path(id)?; + let pen = self.pen(r.u32(0)? as usize)?; + self.painter().stroke(cv, &ops, pen); + Some(()) + } + 0x4019 => { + let pen = self.pen(id)?; let pts = points(r, 4, r.u32(0)? as usize, compressed)?; let (first, rest) = pts.split_first()?; let mut ops = vec![Op::M(*first)]; ops.extend(rest.as_chunks::<3>().0.iter().map(|c| Op::C(c[0], c[1], c[2]))); - p.shape(cv, ops, false, true); + self.painter().stroke(cv, &ops, pen); Some(()) - }), + } + 0x401A => { + let img = self.image(id)?; + let dst = *rects(r, 24, 1, compressed)?.first()?; + self.draw_image(cv, &img, r, [dst[0], dst[1], dst[2] - dst[0], dst[3] - dst[1]]) + } + 0x401B => { + let img = self.image(id)?; + let pts = points(r, 28, 3, compressed)?; + let (p0, p1, p2) = (*pts.first()?, *pts.get(1)?, *pts.get(2)?); + // The destination parallelogram's top-left, top-right and bottom-left corners; a + // sheared or rotated one is drawn into its bounding rectangle's axes. + let dst = [p0.0, p0.1, p1.0 - p0.0 + (p2.0 - p0.0), p2.1 - p0.1 + (p1.1 - p0.1)]; + self.draw_image(cv, &img, r, dst) + } + 0x401C => self.draw_string(cv, flags, r), + 0x4036 => self.draw_driver_string(cv, flags, r), 0x4025 | 0x4028 => r.u32(0).map(|i| self.push(i)), 0x4027 => r.u32(24).map(|i| self.push(i)), 0x4026 | 0x4029 => r.u32(0).map(|i| self.pop(i)), @@ -124,20 +339,40 @@ impl Plus { self.apply([a.cos(), a.sin(), -a.sin(), a.cos(), 0.0, 0.0], flags) } 0x4030 => { - let scale = f64::from(r.f32(0)?); - let unit = match flags & 0xFF { - 3 => self.dpi / 72.0, - 4 => self.dpi, - 5 => self.dpi / 300.0, - 6 => self.dpi / 25.4, - _ => 1.0, - }; - let page = scale * unit; + let page = f64::from(r.f32(0)?) * unit_px(u32::from(flags & 0xFF), self.dpi); if page.is_finite() && page > 0.0 && page < 1.0e6 { self.page = page; } Some(()) } + 0x4031 => { + self.clip = None; + Some(()) + } + 0x4032 => { + let [x, y, w, h] = floats::<4>(r, 0)?; + let dev = self.device_rect(cv, [x, y, x + w, y + h]); + self.combine(flags, Some(dev)) + } + 0x4033 => { + let b = ops_bbox(&self.path(id)?)?; + let dev = self.device_rect(cv, b); + self.combine(flags, Some(dev)) + } + 0x4034 => { + let Some(Some(Obj::Region(reg))) = self.objects.get(id).cloned() else { return None }; + let dev = reg.map(|b| self.device_rect(cv, b)); + self.combine(flags, dev) + } + 0x4035 => { + let [dx, dy] = floats::<2>(r, 0)?; + let a = mul(self.xf(), cv.device_affine()); + let (ox, oy) = (dx * a[0] + dy * a[2], dx * a[1] + dy * a[3]); + if let Some(c) = self.clip.as_mut() { + *c = [c[0] + ox, c[1] + oy, c[2] + ox, c[3] + oy]; + } + Some(()) + } _ => None, } } @@ -150,35 +385,94 @@ impl Plus { fn push(&mut self, id: u32) { if self.stack.len() < MAX_STACK { - self.stack.push((id, self.world, self.page)); + self.stack.push(Saved { id, world: self.world, page: self.page, clip: self.clip }); } } fn pop(&mut self, id: u32) { - if let Some(i) = self.stack.iter().rposition(|s| s.0 == id) { - let (_, w, p) = self.stack[i]; + if let Some(i) = self.stack.iter().rposition(|s| s.id == id) { + let s = &self.stack[i]; + (self.world, self.page, self.clip) = (s.world, s.page, s.clip); self.stack.truncate(i); - self.world = w; - self.page = p; } } - fn object(&mut self, flags: u16, r: Bytes) -> Option<()> { - if flags & 0x8000 != 0 { - // Continued (split) objects are images and large paths; not read yet. + /// A world rectangle `[l, t, r, b]` as the device rectangle around it. + fn device_rect(&self, cv: &Canvas, [l, t, r, b]: [f64; 4]) -> [f64; 4] { + let a = mul(self.xf(), cv.device_affine()); + let pts = [(l, t), (r, t), (l, b), (r, b)].map(|p| apply(a, p)); + let xs = pts.map(|p| p.0); + let ys = pts.map(|p| p.1); + let min = |v: [f64; 4]| v.iter().copied().fold(f64::INFINITY, f64::min); + let max = |v: [f64; 4]| v.iter().copied().fold(f64::NEG_INFINITY, f64::max); + [min(xs), min(ys), max(xs), max(ys)] + } + + /// Combines a new clip area (None: infinite) with the current one under the record's combine + /// mode: replace, intersect and union (as the rectangle around both). XOR, exclude and + /// complement leave the clip as it was. + fn combine(&mut self, flags: u16, new: Option<[f64; 4]>) -> Option<()> { + if new.is_some_and(|c| !c.iter().all(|v| v.is_finite())) { return None; } - let id = usize::from(flags & 0xFF); - let obj = match (flags >> 8) & 0x7F { - 1 => brush(r, 0).map(|(c, _)| Obj::Brush(c)), - 2 => pen(r), - 3 => path(r).map(Obj::Path), - _ => None, + self.clip = match (flags >> 8) & 0xF { + 0 => new, + 1 => match (self.clip, new) { + (None, n) => n, + (c, None) => c, + (Some(a), Some(b)) => Some(intersect(a, b)), + }, + 2 => match (self.clip, new) { + (Some(a), Some(b)) => Some([a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]), + _ => None, + }, + _ => self.clip, }; + Some(()) + } + + fn object(&mut self, flags: u16, r: Bytes) -> Option<()> { + let id = usize::from(flags & 0xFF); + let kind = (flags >> 8) & 0x7F; + if flags & 0x8000 != 0 { + // A continued object: the total size, then this record's share of the bytes. + let total = (r.u32(0)? as usize).min(MAX_OBJECT_BYTES); + let chunk = r.tail(4)?.0; + let p = match self.partial.take() { + Some(p) if p.id == id && p.kind == kind && p.total == total => p, + _ => Partial { id, kind, total, data: Vec::new() }, + }; + let mut p = p; + let room = p.total.saturating_sub(p.data.len()); + p.data.extend_from_slice(&chunk[..chunk.len().min(room)]); + if p.data.len() >= p.total { + let obj = parse_object(p.kind, Bytes(&p.data)); + self.store(p.id, obj); + } else { + self.partial = Some(p); + } + return Some(()); + } + // The last piece of a continued object comes without the continue flag. + if let Some(mut p) = self.partial.take() + && p.id == id + && p.kind == kind + { + let room = p.total.saturating_sub(p.data.len()); + p.data.extend_from_slice(&r.0[..r.0.len().min(room)]); + let obj = parse_object(kind, Bytes(&p.data)); + self.store(id, obj); + return Some(()); + } + let obj = parse_object(kind, r); + self.store(id, obj); + Some(()) + } + + fn store(&mut self, id: usize, obj: Option) { if let Some(slot) = self.objects.get_mut(id) { *slot = obj; } - Some(()) } fn path(&self, id: usize) -> Option> { @@ -188,88 +482,238 @@ impl Plus { } } - /// The brush colour of a fill record: an ARGB value with the `S` flag, else a brush object. - fn brush_of(&self, flags: u16, v: u32) -> Option<[u8; 4]> { - if flags & 0x8000 != 0 { - return Some(argb(v)); - } - match self.objects.get(v as usize)? { - Some(Obj::Brush(c)) => Some(*c), + fn pen(&self, id: usize) -> Option<([u8; 4], f64)> { + match self.objects.get(id)? { + Some(Obj::Pen { color, width }) => Some((*color, *width)), _ => None, } } - fn with_pen(&mut self, id: usize, f: impl FnOnce(&mut Painter) -> Option) -> Option { - let Some(Some(Obj::Pen { color, width })) = self.objects.get(id).cloned() else { return None }; - let mut p = Painter { xf: mul(self.world, [self.page, 0.0, 0.0, self.page, 0.0, 0.0]), pen: Some((color, width)), fill: None }; - f(&mut p) + fn image(&self, id: usize) -> Option> { + match self.objects.get(id)? { + Some(Obj::Image(img)) => Some(img.clone()), + _ => None, + } } - fn filler(&self, color: [u8; 4]) -> Painter { - Painter { xf: mul(self.world, [self.page, 0.0, 0.0, self.page, 0.0, 0.0]), pen: None, fill: Some(color) } + /// The brush of a fill record: an ARGB value with the `S` flag, else a brush object. + fn brush_of(&self, flags: u16, v: u32) -> Option { + if flags & 0x8000 != 0 { + return Some(Fill::Solid(argb(v))); + } + match self.objects.get(v as usize)? { + Some(Obj::Brush(f)) => Some(f.clone()), + _ => None, + } } - fn fill_rects(&mut self, cv: &mut Canvas, flags: u16, r: Bytes) -> Option<()> { - let c = self.brush_of(flags, r.u32(0)?)?; - let rs = rects(r, 8, r.u32(4)? as usize, flags & 0x4000 != 0)?; - let p = self.filler(c); - for rc in rs { - p.shape(cv, rect_ops(rc), true, false); + /// DrawImage and DrawImagePoints: the source rectangle (pixels) cut from the image, drawn + /// into world rectangle `dst` = `[x, y, w, h]`. + fn draw_image(&self, cv: &mut Canvas, img: &Rgba, r: Bytes, dst: [f64; 4]) -> Option<()> { + let [sx, sy, sw, sh] = floats::<4>(r, 8)?; + let src = if r.u32(4)? == 2 { [sx, sy, sw, sh].map(|v| v.round().clamp(-1.0e9, 1.0e9) as i64) } else { [0; 4] }; + let (part, dst) = crate::dib::clip_blit(img, src, dst)?; + self.painter().image(cv, dst, part); + Some(()) + } + + fn font(&self, id: usize) -> Option { + match self.objects.get(id)? { + Some(Obj::Font(f)) => Some(f.clone()), + _ => None, + } + } + + /// The font as a LOGFONT whose height is in world units. + fn log_font(&self, f: &FontObj) -> LogFont { + let em = if f.unit == 0 { f.em } else { f.em * unit_px(f.unit, self.dpi) / self.page }; + let font = Font { + family: f.family.clone(), + height: -(em.clamp(0.0, 1.0e6) as f32), + weight: if f.style & 1 != 0 { 700 } else { 400 }, + italic: f.style & 2 != 0, + underline: f.style & 4 != 0, + strike: f.style & 8 != 0, + ..Font::default() + }; + LogFont { font, escapement: 0.0 } + } + + /// DrawString: lines of text laid out in a rectangle by the string format's alignments. + fn draw_string(&self, cv: &mut Canvas, flags: u16, r: Bytes) -> Option<()> { + let font = self.font(usize::from(flags & 0xFF))?; + let color = self.brush_of(flags, r.u32(0)?)?.color(); + let fmt = match self.objects.get(r.u32(4)? as usize) { + Some(Some(Obj::Format(f))) => *f, + _ => Format::default(), + }; + let len = (r.u32(8)? as usize).min(MAX_CHARS); + let [x, y, w, h] = floats::<4>(r, 12)?; + let units: Vec = (0..len).map(|i| r.u16(28 + i * 2)).collect::>()?; + let lf = self.log_font(&font); + let em = f64::from(-lf.font.height); + let lines: Vec<&[u16]> = units.split(|u| *u == 0x0A).collect(); + let line_h = em * 1.2; + let (ax, ta_h) = match fmt.align { + 1 => (x + w / 2.0, TA_CENTER), + 2 => (x + w, TA_RIGHT), + _ => (x, 0), + }; + let block = line_h * lines.len() as f64; + let top = match fmt.line_align { + 1 => y + (h - block) / 2.0, + 2 => y + h - block, + _ => y, + }; + let clip = (w > 0.0 && h > 0.0 && fmt.flags & 0x4000 == 0).then_some([x, y, x + w, y + h]); + let p = self.painter(); + for (i, line) in lines.iter().enumerate() { + let line: Vec = line.iter().copied().filter(|u| *u != 0x0D).collect(); + if line.is_empty() { + continue; + } + let (text, dx) = crate::text::decode_utf16(&line, &[]); + let at = (ax, top + line_h * i as f64); + let (at, ta) = if fmt.line_align == 2 && lines.len() == 1 { ((ax, y + h), ta_h | TA_BOTTOM) } else { (at, ta_h) }; + p.text(cv, at, text, dx, TextStyle { color, font: lf.clone(), align: ta, clip }, IDENTITY); } Some(()) } - fn fill_polygon(&mut self, cv: &mut Canvas, flags: u16, r: Bytes) -> Option<()> { - let c = self.brush_of(flags, r.u32(0)?)?; - let pts = points(r, 8, r.u32(4)? as usize, flags & 0x4000 != 0)?; - self.filler(c).shape(cv, poly_ops(&pts, true), true, false); - Some(()) - } - - fn fill_ellipse(&mut self, cv: &mut Canvas, flags: u16, r: Bytes) -> Option<()> { - let c = self.brush_of(flags, r.u32(0)?)?; - let rect = *rects(r, 4, 1, flags & 0x4000 != 0)?.first()?; - self.filler(c).shape(cv, ellipse_ops(rect), true, false); - Some(()) - } - - fn fill_path(&mut self, cv: &mut Canvas, flags: u16, r: Bytes) -> Option<()> { - let c = self.brush_of(flags, r.u32(0)?)?; - let ops = self.path(usize::from(flags & 0xFF))?; - self.filler(c).shape(cv, ops, true, false); + /// DrawDriverString with character codes (CmapLookup): positioned glyphs on a baseline. + fn draw_driver_string(&self, cv: &mut Canvas, flags: u16, r: Bytes) -> Option<()> { + let font = self.font(usize::from(flags & 0xFF))?; + let color = self.brush_of(flags, r.u32(0)?)?.color(); + let opts = r.u32(4)?; + if opts & 1 == 0 { + // Glyph indices into the font: the metafile crate knows no fonts to map them back. + return None; + } + let n = (r.u32(12)? as usize).min(MAX_CHARS); + let units: Vec = (0..n).map(|i| r.u16(16 + i * 2)).collect::>()?; + let pos = points(r, 16 + n * 2, n, false)?; + let m = if r.u32(8)? != 0 { matrix(r, 16 + n * 10)? } else { IDENTITY }; + let lf = self.log_font(&font); + let first = *pos.first()?; + let dx: Vec = if opts & 4 != 0 { + Vec::new() + } else { + let mut d: Vec = pos.windows(2).map(|w| (w[1].0 - w[0].0) as f32).collect(); + d.push(d.last().copied().unwrap_or(-lf.font.height * 0.5)); + d + }; + let (text, dx) = crate::text::decode_utf16(&units, &dx); + self.painter().text(cv, first, text, dx, TextStyle { color, font: lf, align: TA_BASELINE, clip: None }, m); Some(()) } } -/// Draws through the canvas with EMF+'s transform and its own pen or brush, then puts the +/// Page unit to device pixels: world, display and pixel are 1; point, inch, document, mm. +fn unit_px(unit: u32, dpi: f64) -> f64 { + match unit { + 3 => dpi / 72.0, + 4 => dpi, + 5 => dpi / 300.0, + 6 => dpi / 25.4, + _ => 1.0, + } +} + +struct TextStyle { + color: [u8; 4], + font: LogFont, + align: u32, + /// World clip rectangle `[l, t, r, b]`. + clip: Option<[f64; 4]>, +} + +/// Draws through the canvas with EMF+'s transform, clip and its own pen or brush, then puts the /// canvas's GDI state back. struct Painter { xf: Xform, - pen: Option<([u8; 4], f64)>, - fill: Option<[u8; 4]>, + clip: Option<[f64; 4]>, } impl Painter { - fn shape(&self, cv: &mut Canvas, ops: Vec, fill: bool, stroke: bool) { + fn begin(&self, cv: &mut Canvas, pre: Xform) { let world = cv.world(); cv.save_dc(); - cv.modify_world(mul(self.xf, world), 4); - let (pc, pw) = self.pen.unwrap_or(([0, 0, 0, 0], 0.0)); - cv.set_pen(Pen { style: if self.pen.is_some() { 0 } else { PS_NULL }, width: pw, color: pc }); - cv.set_brush(match self.fill { - Some(c) => Brush::new(BS_SOLID, c), - None => Brush::new(BS_NULL, [0, 0, 0, 0]), - }); - cv.shape(&ops, fill, stroke); - cv.restore_dc(-1); + cv.modify_world(mul(mul(pre, self.xf), world), 4); + cv.set_clip(self.clip); } - fn draw_rects(&self, cv: &mut Canvas, compressed: bool, r: Bytes) -> Option<()> { - for rc in rects(r, 4, r.u32(0)? as usize, compressed)? { - self.shape(cv, rect_ops(rc), false, true); - } - Some(()) + fn end(&self, cv: &mut Canvas) { + cv.restore_dc(-1); + cv.set_clip(None); } + + fn no_pen(cv: &mut Canvas) { + cv.set_pen(Pen { style: PS_NULL, width: 0.0, color: [0, 0, 0, 0] }); + } + + fn fill(&self, cv: &mut Canvas, ops: &[Op], fill: &Fill) { + self.begin(cv, IDENTITY); + Self::no_pen(cv); + match fill { + Fill::Solid(c) => { + cv.set_brush(Brush::new(BS_SOLID, *c)); + cv.shape(ops, true, false); + } + Fill::Hatch { style, fg, bg } => match hatch_tile(*style, *fg, Some(*bg)) { + Some(px) => cv.tile_shape(ops, Arc::new(RawTile { w: 8, h: 8, px, cell: cv.pattern_px })), + // GDI+ has many more hatch styles than GDI: those fill with the blend of both colours. + None => { + cv.set_brush(Brush::new(BS_SOLID, lerp(*fg, *bg, 0.5))); + cv.shape(ops, true, false); + } + }, + _ => { + // Device point to brush space: invert brush space → world → device. + if let Some(inv) = invert(mul(fill.xf(), cv.device_affine())) { + cv.shade_shape(ops, &|p| fill.sample(apply(inv, p))); + } + } + } + self.end(cv); + } + + fn stroke(&self, cv: &mut Canvas, ops: &[Op], (color, width): ([u8; 4], f64)) { + self.begin(cv, IDENTITY); + cv.set_pen(Pen { style: 0, width, color }); + cv.set_brush(Brush::new(BS_NULL, [0, 0, 0, 0])); + cv.shape(ops, false, true); + self.end(cv); + } + + fn image(&self, cv: &mut Canvas, dst: [f64; 4], img: Rgba) { + self.begin(cv, IDENTITY); + cv.bitmap(dst, img); + self.end(cv); + } + + fn text(&self, cv: &mut Canvas, at: P, text: String, dx: Vec, style: TextStyle, pre: Xform) { + self.begin(cv, pre); + cv.set_text_style(style.color, style.font, style.align); + let (opts, rect) = match style.clip { + Some(c) => (ETO_CLIPPED, Some(c)), + None => (0, None), + }; + cv.text(at, text, dx, opts, rect); + self.end(cv); + } +} + +fn apply(m: Xform, p: P) -> P { + (p.0 * m[0] + p.1 * m[2] + m[4], p.0 * m[1] + p.1 * m[3] + m[5]) +} + +fn invert(m: Xform) -> Option { + let det = m[0] * m[3] - m[1] * m[2]; + if !det.is_finite() || det.abs() < 1e-12 { + return None; + } + let (a, b, c, d) = (m[3] / det, -m[1] / det, -m[2] / det, m[0] / det); + Some([a, b, c, d, -(m[4] * a + m[5] * c), -(m[4] * b + m[5] * d)]) } /// ARGB (0xAARRGGBB) to RGBA. @@ -277,21 +721,102 @@ fn argb(v: u32) -> [u8; 4] { [(v >> 16) as u8, (v >> 8) as u8, v as u8, (v >> 24) as u8] } -fn matrix(r: Bytes, off: usize) -> Option { - let mut m = [0.0; 6]; - for (i, v) in m.iter_mut().enumerate() { - *v = f64::from(r.f32(off + i * 4)?); +fn floats(r: Bytes, off: usize) -> Option<[f64; N]> { + let mut v = [0.0; N]; + for (i, x) in v.iter_mut().enumerate() { + *x = f64::from(r.f32(off + i * 4)?); } - m.iter().all(|v| v.is_finite()).then_some(m) + v.iter().all(|x| x.is_finite()).then_some(v) } -/// A brush object at `off`: its colour and byte length. Only solid brushes are read; other kinds -/// (hatch, texture, gradients) are given their first colour so shapes still show. -fn brush(r: Bytes, off: usize) -> Option<([u8; 4], usize)> { - let kind = r.u32(off + 4)?; - let c = argb(r.u32(off + 8)?); - let c = if kind == 1 { argb(r.u32(off + 12)?) } else { c }; - Some((c, 12)) +fn matrix(r: Bytes, off: usize) -> Option { + floats::<6>(r, off) +} + +fn parse_object(kind: u16, r: Bytes) -> Option { + match kind { + 1 => brush(r, 0).map(Obj::Brush), + 2 => pen(r), + 3 => path(r).map(Obj::Path), + 4 => region(r), + 5 => image(r).map(|i| Obj::Image(Arc::new(i))), + 6 => font(r), + 7 => Some(Obj::Format(Format { flags: r.u32(4)?, align: r.u32(12)?, line_align: r.u32(16)? })), + _ => None, + } +} + +/// A brush object at `off`: version, type, then the type's data. +fn brush(r: Bytes, off: usize) -> Option { + let d = r.tail(off + 8)?; + match r.u32(off + 4)? { + 0 => Some(Fill::Solid(argb(d.u32(0)?))), + 1 => Some(Fill::Hatch { style: d.u32(0)?, fg: argb(d.u32(4)?), bg: argb(d.u32(8)?) }), + 2 => { + let bflags = d.u32(0)?; + let wrap = d.u32(4)?; + let (xf, off) = if bflags & 0x2 != 0 { (matrix(d, 8)?, 32) } else { (IDENTITY, 8) }; + Some(Fill::Texture { img: Arc::new(image(d.tail(off)?)?), wrap, xf }) + } + 3 => path_gradient(d), + 4 => linear_gradient(d), + _ => None, + } +} + +fn linear_gradient(d: Bytes) -> Option { + let bflags = d.u32(0)?; + let wrap = d.u32(4)?; + let rect = floats::<4>(d, 8)?; + let (c0, c1) = (argb(d.u32(24)?), argb(d.u32(28)?)); + let mut off = 40; + let mut xf = IDENTITY; + if bflags & 0x2 != 0 { + xf = matrix(d, off)?; + off += 24; + } + let mut stops = vec![(0.0, c0), (1.0, c1)]; + if bflags & 0x4 != 0 { + // Preset colours: positions, then one colour each. + let n = (d.u32(off)? as usize).min(MAX_STOPS); + let pos: Vec = (0..n).map(|i| d.f32(off + 4 + i * 4).map(f64::from)).collect::>()?; + let cols: Vec<[u8; 4]> = (0..n).map(|i| d.u32(off + 4 + n * 4 + i * 4).map(argb)).collect::>()?; + stops = pos.into_iter().zip(cols).collect(); + } else if bflags & 0x8 != 0 { + // Blend factors: positions, then how far from the start colour to the end colour. + let n = (d.u32(off)? as usize).min(MAX_STOPS); + let pos: Vec = (0..n).map(|i| d.f32(off + 4 + i * 4).map(f64::from)).collect::>()?; + let fac: Vec = (0..n).map(|i| d.f32(off + 4 + n * 4 + i * 4).map(f64::from)).collect::>()?; + stops = pos.into_iter().zip(fac).map(|(p, k)| (p, lerp(c0, c1, k.clamp(0.0, 1.0)))).collect(); + } + stops.retain(|s| s.0.is_finite()); + stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + (!stops.is_empty()).then_some(Fill::Linear { rect, stops, wrap, xf }) +} + +/// A path gradient: from the centre colour to the (first) surrounding colour at the boundary, +/// taken as the ellipse in the boundary's bounding rectangle. +fn path_gradient(d: Bytes) -> Option { + let bflags = d.u32(0)?; + let center_c = argb(d.u32(8)?); + let [cx, cy] = floats::<2>(d, 12)?; + let nc = (d.u32(20)? as usize).min(MAX_STOPS); + let surround = if nc > 0 { argb(d.u32(24)?) } else { center_c }; + let mut off = 24 + nc * 4; + let bbox = if bflags & 0x1 != 0 { + let size = d.u32(off)? as usize; + let b = ops_bbox(&path(d.tail(off + 4)?)?)?; + off = off.checked_add(4 + size)?; + b + } else { + let n = d.u32(off)? as usize; + let pts = points(d, off + 4, n, false)?; + off = off.checked_add(4 + 8 * n.min(MAX_POINTS))?; + ops_bbox(&poly_ops(&pts, true))? + }; + let xf = if bflags & 0x2 != 0 { matrix(d, off)? } else { IDENTITY }; + let radius = ((bbox[2] - bbox[0]) / 2.0, (bbox[3] - bbox[1]) / 2.0); + Some(Fill::Radial { center: (cx, cy), radius, stops: vec![(0.0, center_c), (1.0, surround)], xf }) } /// A pen object: version, type, then pen data (flags, unit, width, optional fields), then a brush. @@ -314,7 +839,7 @@ fn pen(r: Bytes) -> Option { off += 4 + (r.u32(off)? as usize).min(1 << 20); } } - let (color, _) = brush(r, off)?; + let color = brush(r, off)?.color(); Some(Obj::Pen { color, width: if width.is_finite() { width.clamp(0.0, 1.0e5) } else { 0.0 } }) } @@ -338,12 +863,10 @@ fn path(r: Bytes) -> Option> { 0 => ops.push(Op::M(p)), 3 if i + 2 < n => { ops.push(Op::C(p, pts[i + 1], pts[i + 2])); - let last = types[i + 2]; - i += 2; - if last & 0x80 != 0 { + if types[i + 2] & 0x80 != 0 { ops.push(Op::Z); } - i += 1; + i += 3; continue; } _ => ops.push(Op::L(p)), @@ -356,6 +879,163 @@ fn path(r: Bytes) -> Option> { Some(ops) } +/// A region object reduced to its bounding rectangle (None: infinite). +fn region(r: Bytes) -> Option { + region_node(r, 8, 0).map(|(b, _)| Obj::Region(b)) +} + +/// One region node at `off`: its bounds (None: infinite; an empty node is a zero rectangle) and +/// its length in bytes. +fn region_node(r: Bytes, off: usize, depth: usize) -> Option<(Option<[f64; 4]>, usize)> { + if depth > MAX_REGION_DEPTH { + return None; + } + match r.u32(off)? { + 0x1000_0000 => { + let [x, y, w, h] = floats::<4>(r, off + 4)?; + Some((Some([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)]), 20)) + } + 0x1000_0001 => { + let size = r.u32(off + 4)? as usize; + let b = ops_bbox(&path(r.tail(off + 8)?)?).unwrap_or([0.0; 4]); + Some((Some(b), 8usize.checked_add(size)?)) + } + 0x1000_0002 => Some((Some([0.0; 4]), 4)), + 0x1000_0003 => Some((None, 4)), + mode @ 1..=5 => { + let (a, la) = region_node(r, off + 4, depth + 1)?; + let (b, lb) = region_node(r, off.checked_add(4 + la)?, depth + 1)?; + let out = match mode { + 1 => match (a, b) { + (None, x) | (x, None) => x, + (Some(a), Some(b)) => Some(intersect(a, b)), + }, + 2 => match (a, b) { + (Some(a), Some(b)) => Some([a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]), + _ => None, + }, + // XOR, exclude, complement: the left area bounds the result closely enough. + _ => a, + }; + Some((out, (4 + la).checked_add(lb)?)) + } + // Replace: the right-hand area. + 0 => { + let (_, la) = region_node(r, off + 4, depth + 1)?; + let (b, lb) = region_node(r, off.checked_add(4 + la)?, depth + 1)?; + Some((b, (4 + la).checked_add(lb)?)) + } + _ => None, + } +} + +/// A font object: em size, unit, style flags, then the family name in UTF-16. +fn font(r: Bytes) -> Option { + let em = f64::from(r.f32(4)?); + let n = (r.u32(20)? as usize).min(64); + let units: Vec = (0..n).map(|i| r.u16(24 + i * 2)).collect::>()?; + let family = String::from_utf16_lossy(&units).trim_end_matches('\0').to_string(); + em.is_finite().then_some(Obj::Font(FontObj { em, unit: r.u32(8)?, style: r.u32(12)?, family })) +} + +/// An image object: a bitmap of raw pixels or a compressed (PNG, JPEG, GIF…) file. Metafile +/// images are not drawn yet. +fn image(r: Bytes) -> Option { + if r.u32(4)? != 1 { + return None; + } + let (w, h, stride) = (r.u32(8)? as i32, r.u32(12)? as i32, r.u32(16)? as i32); + let format = r.u32(20)?; + let data = r.tail(28)?; + if r.u32(24)? == 1 { + return decode_compressed(data.0); + } + let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?); + if w == 0 || h == 0 || w.checked_mul(h)? > crate::MAX_PIXELS { + return None; + } + let bpp = ((format >> 8) & 0xFF) as usize; + let (pal, pixels) = if format & 0x0001_0000 != 0 { + // Indexed formats carry a palette first: flags, count, ARGB entries. + let n = (data.u32(4)? as usize).min(256); + let pal: Vec<[u8; 4]> = (0..n).map(|i| data.u32(8 + i * 4).map(argb)).collect::>()?; + (pal, data.tail(8 + n * 4)?) + } else { + (Vec::new(), data) + }; + let row = (w.checked_mul(bpp)?).div_ceil(8); + let stride_abs = (stride.unsigned_abs() as usize).max(row); + let mut px = Vec::with_capacity(w * h * 4); + for y in 0..h { + let line = pixels.slice(y.checked_mul(stride_abs)?, row)?; + for x in 0..w { + let c = match (format, bpp) { + (_, 1 | 4 | 8) if !pal.is_empty() => { + let bit = x * bpp; + let byte = *line.get(bit / 8)?; + let i = (byte >> (8 - bpp - bit % 8)) & ((1u16 << bpp) - 1) as u8; + pal.get(usize::from(i)).copied().unwrap_or([0, 0, 0, 255]) + } + (_, 24) => { + let p = line.get(x * 3..x * 3 + 3)?; + [p[2], p[1], p[0], 255] + } + (0x0002_2009, 32) => { + let p = line.get(x * 4..x * 4 + 4)?; + [p[2], p[1], p[0], 255] + } + (0x000E_200B, 32) => { + let p = line.get(x * 4..x * 4 + 4)?; + let a = u32::from(p[3]); + let un = |v: u8| (u32::from(v) * 255 + a / 2).checked_div(a).map_or(v, |c| c.min(255) as u8); + [un(p[2]), un(p[1]), un(p[0]), p[3]] + } + (_, 32) => { + let p = line.get(x * 4..x * 4 + 4)?; + [p[2], p[1], p[0], p[3]] + } + (0x0002_1006, 16) => { + let v = u16::from_le_bytes([*line.get(x * 2)?, *line.get(x * 2 + 1)?]); + let s = |v: u16, bits: u32| ((u32::from(v) * 255) / ((1 << bits) - 1)) as u8; + [s(v >> 11, 5), s((v >> 5) & 0x3F, 6), s(v & 0x1F, 5), 255] + } + (_, 16) => { + let v = u16::from_le_bytes([*line.get(x * 2)?, *line.get(x * 2 + 1)?]); + let s = |v: u16| ((u32::from(v & 0x1F) * 255) / 31) as u8; + [s(v >> 10), s(v >> 5), s(v), 255] + } + _ => return None, + }; + px.extend_from_slice(&c); + } + } + // A negative stride stores the rows bottom-up. + Some(top_down(Rgba { w, h, bottom_up: stride < 0, px })) +} + +fn top_down(mut img: Rgba) -> Rgba { + if img.bottom_up { + crate::dib::flip(&mut img, false, true); + img.bottom_up = false; + } + img +} + +fn decode_compressed(bytes: &[u8]) -> Option { + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(1 << 15); + limits.max_image_height = Some(1 << 15); + limits.max_alloc = Some((crate::MAX_PIXELS * 4) as u64); + reader.limits(limits); + let img = reader.decode().ok()?.to_rgba8(); + let (w, h) = (img.width() as usize, img.height() as usize); + if w.checked_mul(h)? > crate::MAX_PIXELS { + return None; + } + Some(Rgba { w, h, bottom_up: false, px: img.into_raw() }) +} + /// `n` points at `off`: 16-bit integers when compressed, else 32-bit floats. fn points(r: Bytes, off: usize, n: usize, compressed: bool) -> Option> { let n = n.min(MAX_POINTS); diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs index cdf18154..ccdeba9d 100644 --- a/crates/metafile/src/lib.rs +++ b/crates/metafile/src/lib.rs @@ -15,6 +15,7 @@ mod bytes; mod canvas; +mod clip; mod dib; mod emf; mod emfplus; diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index e3c4c923..6a23ad6c 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -1624,3 +1624,163 @@ fn emfplus_garbage_records_never_panic() { let _ = parse(&emf(&[plus_comment(&recs)], [0; 4], [0; 4])); } } + +fn plus_pic(recs: Vec>) -> Picture { + let mut all = vec![plus_header(false)]; + all.extend(recs); + parse(&emf(&[plus_comment(&all)], [0; 4], [0; 4])).unwrap() +} + +fn u16s(s: &str) -> Vec { + s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect() +} + +/// A 2 x 2 32bpp ARGB bitmap image object: red, green / blue, white. +fn bitmap_2x2() -> Vec { + let px = [0xFFFF_0000u32, 0xFF00_FF00, 0xFF00_00FF, 0xFFFF_FFFF]; + cat(vec![h32(0xDBC0_1002), h32(1), h32(2), h32(2), h32(8), h32(0x0026_200A), h32(0), px.iter().flat_map(|v| v.to_le_bytes()).collect()]) +} + +fn draw_image(id: u16, dst: [f32; 4]) -> Vec { + prec(0x401A, id, &cat(vec![h32(0), h32(2), f32s(&[0.0, 0.0, 2.0, 2.0]), f32s(&dst)])) +} + +#[test] +fn emfplus_draw_image_places_the_bitmap() { + let p = plus_pic(vec![prec(0x4008, 0x0500 | 4, &bitmap_2x2()), draw_image(4, [10.0, 10.0, 20.0, 40.0])]); + match &p.items[0] { + Item::Bitmap { rect, width, height, rgba } => { + assert_eq!((*width, *height), (2, 2)); + assert_eq!(rect, &[0.0, 0.0, 20.0, 40.0]); + assert_eq!(&rgba[..8], &[255, 0, 0, 255, 0, 255, 0, 255]); + assert_eq!(&rgba[8..12], &[0, 0, 255, 255]); + } + other => panic!("expected a bitmap: {other:?}"), + } +} + +#[test] +fn emfplus_object_split_across_records_is_joined() { + let obj = bitmap_2x2(); + let (a, b) = obj.split_at(20); + let total = h32(obj.len() as u32); + let first = prec(0x4008, 0x8000 | 0x0500 | 1, &cat(vec![total.clone(), a.to_vec()])); + let second = prec(0x4008, 0x0500 | 1, b); + let p = plus_pic(vec![first, second, draw_image(1, [0.0, 0.0, 4.0, 4.0])]); + assert!(matches!(&p.items[0], Item::Bitmap { width: 2, height: 2, .. })); +} + +#[test] +fn emfplus_compressed_png_image_decodes() { + let mut png = Vec::new(); + let img = image::RgbaImage::from_pixel(3, 1, image::Rgba([1, 2, 3, 255])); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let obj = cat(vec![h32(0xDBC0_1002), h32(1), h32(0), h32(0), h32(0), h32(0), h32(1), png]); + let draw = prec(0x401A, 2, &cat(vec![h32(0), h32(2), f32s(&[0.0, 0.0, 3.0, 1.0]), f32s(&[0.0, 0.0, 3.0, 1.0])])); + let p = plus_pic(vec![prec(0x4008, 0x0500 | 2, &obj), draw]); + assert!(matches!(&p.items[0], Item::Bitmap { width: 3, height: 1, rgba, .. } if rgba[..4] == [1, 2, 3, 255])); +} + +fn font_obj(family: &str, em: f32, unit: u32, style: u32) -> Vec { + cat(vec![h32(0xDBC0_1002), f32s(&[em]), h32(unit), h32(style), h32(0), h32(family.encode_utf16().count() as u32), u16s(family)]) +} + +#[test] +fn emfplus_draw_string_with_font_and_centred_format() { + let font = prec(0x4008, 0x0600 | 3, &font_obj("Calibri", 12.0, 2, 1)); + // String format: centre alignment, no clip. + let fmt = prec(0x4008, 0x0700 | 4, &cat(vec![h32(0xDBC0_1002), h32(0x4000), h32(0), h32(1), h32(0)])); + let s = prec(0x401C, 0x8000 | 3, &cat(vec![h32(0xFF11_2233), h32(4), h32(2), f32s(&[0.0, 0.0, 100.0, 20.0]), u16s("Hi")])); + let p = plus_pic(vec![font, fmt, prec(0x400A, 0x8000, &cat(vec![h32(0xFFFF_FFFF), h32(1), f32s(&[0.0, 0.0, 100.0, 20.0])])), s]); + let t = p.items.iter().find_map(|i| if let Item::Text(t) = i { Some(t) } else { None }).expect("a text item"); + assert_eq!(t.text, "Hi"); + assert_eq!(t.font.family, "Calibri"); + assert_eq!(t.font.weight, 700); + assert!(close(t.font.height, -12.0)); + assert_eq!(t.color, [0x11, 0x22, 0x33, 0xFF]); + assert_eq!(t.h_align, HAlign::Center); + assert!(close(t.transform[4] as f32, 50.0)); + assert_eq!(t.background, None); + assert_eq!(t.clip, None); +} + +#[test] +fn emfplus_driver_string_uses_character_codes_and_positions() { + let font = prec(0x4008, 0x0600, &font_obj("Arial", 10.0, 0, 0)); + let pos = f32s(&[5.0, 30.0, 11.0, 30.0]); + let s = prec(0x4036, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), h32(0), h32(2), u16s("Ab"), pos])); + let p = plus_pic(vec![prec(0x400A, 0x8000, &cat(vec![h32(0xFFFF_FFFF), h32(1), f32s(&[0.0, 0.0, 50.0, 50.0])])), font, s]); + let Some(Item::Text(t)) = p.items.get(1) else { panic!("expected text: {:?}", p.items) }; + assert_eq!(t.text, "Ab"); + assert_eq!(t.dx, vec![6.0, 6.0]); + assert_eq!(t.v_align, VAlign::Baseline); + assert!(close(t.transform[4] as f32, 5.0) && close(t.transform[5] as f32, 30.0)); + // Glyph indices (no CmapLookup) are skipped. + let gi = prec(0x4036, 0x8000, &cat(vec![h32(0xFF00_0000), h32(0), h32(0), h32(1), h16(36), f32s(&[0.0, 0.0])])); + let font = prec(0x4008, 0x0600, &font_obj("Arial", 10.0, 0, 0)); + assert!(plus_pic(vec![font, gi]).items.is_empty()); +} + +#[test] +fn emfplus_linear_gradient_fills_with_a_shaded_tile() { + // Red to blue across x = 0..100. + let brush = + cat(vec![h32(0xDBC0_1002), h32(4), h32(0), h32(0), f32s(&[0.0, 0.0, 100.0, 10.0]), h32(0xFFFF_0000), h32(0xFF00_00FF), h32(0), h32(0)]); + let fill = prec(0x400A, 0, &cat(vec![h32(5), h32(1), f32s(&[0.0, 0.0, 100.0, 10.0])])); + let p = plus_pic(vec![prec(0x4008, 0x0100 | 5, &brush), fill]); + let Item::Pattern { tile, .. } = &p.items[0] else { panic!("expected a pattern: {:?}", p.items) }; + let px = |x: usize| &tile.rgba[x * 4..x * 4 + 4]; + assert!(px(0)[0] > 240 && px(0)[2] < 15, "left is red: {:?}", px(0)); + let right = (tile.width as usize - 2).min(127); + assert!(px(right)[2] > 230 && px(right)[0] < 25, "right is blue: {:?}", px(right)); +} + +#[test] +fn emfplus_hatch_brush_fills_with_an_8x8_tile() { + let brush = cat(vec![h32(0xDBC0_1002), h32(1), h32(4), h32(0xFF00_0000), h32(0xFFFF_FFFF)]); + let fill = prec(0x400A, 0, &cat(vec![h32(0), h32(1), f32s(&[0.0, 0.0, 16.0, 16.0])])); + let p = plus_pic(vec![prec(0x4008, 0x0100, &brush), fill]); + assert!(matches!(&p.items[0], Item::Pattern { tile, .. } if tile.width == 8 && tile.height == 8)); +} + +#[test] +fn emfplus_clip_rect_cuts_fills_strokes_and_images() { + let clip = prec(0x4032, 0, &f32s(&[0.0, 0.0, 50.0, 50.0])); + let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[25.0, 25.0, 50.0, 50.0])])); + let outside = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[60.0, 60.0, 5.0, 5.0])])); + let pen = prec(0x4008, 0x0200 | 1, &solid_pen(0xFF00_FF00, 1.0)); + let line = prec(0x400D, 1, &cat(vec![h32(2), f32s(&[0.0, 10.0, 100.0, 10.0])])); + let img = prec(0x4008, 0x0500 | 2, &bitmap_2x2()); + let p = plus_pic(vec![clip, fill, outside, pen, line, img, draw_image(2, [40.0, 0.0, 20.0, 20.0])]); + assert_eq!(p.items.len(), 3, "{:?}", p.items); + let (segs, ..) = path_segs(&p.items[0]); + let xs: Vec = segs.iter().filter_map(|s| if let Seg::Move(x, _) | Seg::Line(x, _) = s { Some(*x) } else { None }).collect(); + // Picture coordinates start at the drawing's left edge (0): the square is cut at x = 50. + assert!(xs.iter().all(|x| *x <= 50.0 + 1e-3), "{segs:?}"); + let (segs, ..) = path_segs(&p.items[1]); + assert!(matches!(segs[1], Seg::Line(x, _) if close(x, 50.0)), "{segs:?}"); + let Item::Bitmap { rect, width, .. } = &p.items[2] else { panic!() }; + assert_eq!(*width, 1); + assert!(close(rect[2], 10.0)); +} + +#[test] +fn emfplus_reset_clip_and_restore_bring_drawing_back() { + let clip = prec(0x4032, 0, &f32s(&[0.0, 0.0, 1.0, 1.0])); + let fill = || prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[10.0, 10.0, 5.0, 5.0])])); + let p = plus_pic(vec![prec(0x4025, 0, &h32(1)), clip, fill(), prec(0x4026, 0, &h32(1)), fill()]); + assert_eq!(p.items.len(), 1); + let clip = prec(0x4032, 0, &f32s(&[0.0, 0.0, 1.0, 1.0])); + let p = plus_pic(vec![clip, fill(), prec(0x4031, 0, &[]), fill()]); + assert_eq!(p.items.len(), 1); +} + +#[test] +fn emfplus_region_clip_intersects_rectangles() { + // Region: intersect(rect 0,0,30,30 ; rect 10,10,100,100) = 10..30. + let rect = |r: [f32; 4]| cat(vec![h32(0x1000_0000), f32s(&r)]); + let region = cat(vec![h32(0xDBC0_1002), h32(2), h32(1), rect([0.0, 0.0, 30.0, 30.0]), rect([10.0, 10.0, 90.0, 90.0])]); + let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[0.0, 0.0, 100.0, 100.0])])); + let p = plus_pic(vec![prec(0x4008, 0x0400 | 6, ®ion), prec(0x4034, 6, &[]), fill]); + assert_eq!((p.width, p.height), (20.0, 20.0)); +} From 8f935a86970890f8f0d193e62b16847e3d49e9ed Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 00:52:06 +0300 Subject: [PATCH 04/27] Metafile: EMF+ arcs, convex clip paths, wrapped strings, nested metafiles Arcs and pies; clip paths and regions clip to their convex outline instead of the bounding rectangle; DrawString wraps to the layout width; glyph-index driver strings in the Windows core fonts map back to text; metafile images draw their nested picture. Co-authored-by: Cursor --- crates/metafile/src/canvas.rs | 18 +- crates/metafile/src/clip.rs | 220 +++++++++++------ crates/metafile/src/emfplus.rs | 423 ++++++++++++++++++++++++++------- crates/metafile/src/tests.rs | 73 +++++- 4 files changed, 565 insertions(+), 169 deletions(-) diff --git a/crates/metafile/src/canvas.rs b/crates/metafile/src/canvas.rs index ddb4b299..482501b4 100644 --- a/crates/metafile/src/canvas.rs +++ b/crates/metafile/src/canvas.rs @@ -306,8 +306,8 @@ pub(crate) struct Canvas { in_path: bool, path: Vec, full: bool, - /// Device clip rectangle `[l, t, r, b]` applied to everything drawn (EMF+ sets it). - clip: Option<[f64; 4]>, + /// Convex device clip polygon applied to everything drawn (EMF+ sets it). + clip: Option>, } impl Canvas { @@ -341,12 +341,13 @@ impl Canvas { } fn push(&mut self, raw: Raw) { - match self.clip { + match self.clip.take() { None => self.push_raw(raw), Some(c) => { - for r in crate::clip::clip_raw(raw, c) { + for r in crate::clip::clip_raw(raw, &c) { self.push_raw(r); } + self.clip = Some(c); } } } @@ -472,8 +473,8 @@ impl Canvas { self.brush = brush; } - /// Sets the device clip rectangle `[l, t, r, b]` (None: unclipped). - pub fn set_clip(&mut self, clip: Option<[f64; 4]>) { + /// Sets the convex device clip polygon (None: unclipped). + pub fn set_clip(&mut self, clip: Option>) { self.clip = clip; } @@ -494,6 +495,11 @@ impl Canvas { self.text.font = Some(Arc::new(font)); } + /// Pushes an item already in device units (a nested metafile's drawing). + pub fn push_device(&mut self, raw: Raw) { + self.push(raw); + } + /// Fills logical `ops` with a ready-made tile, laid from the device origin. pub fn tile_shape(&mut self, ops: &[Op], tile: Arc) { let dev: Vec = ops.iter().map(|o| self.map_op(*o)).collect(); diff --git a/crates/metafile/src/clip.rs b/crates/metafile/src/clip.rs index b0db77c3..8290dd37 100644 --- a/crates/metafile/src/clip.rs +++ b/crates/metafile/src/clip.rs @@ -1,7 +1,8 @@ -//! Clipping drawn output to a device rectangle (EMF+ clip rectangles, paths and regions are -//! reduced to their bounding rectangle). Shapes wholly inside pass unchanged; shapes crossing the -//! edge are flattened and cut: fills with Sutherland–Hodgman per subpath, outlines with -//! Liang–Barsky per segment. Bitmaps are cropped and text gets a clip rectangle. +//! Clipping drawn output to a convex device polygon (EMF+ clip rectangles, and clip paths and +//! regions that are convex; others are reduced to their bounding rectangle). Shapes wholly inside +//! pass unchanged; shapes crossing the edge are flattened and cut: fills with Sutherland–Hodgman +//! per subpath, outlines with Cyrus–Beck per segment. Bitmaps are cropped (and masked outside a +//! non-rectangular polygon) and text gets the polygon's bounding rectangle. use crate::canvas::Raw; use crate::dib::Rgba; @@ -37,35 +38,129 @@ pub(crate) fn intersect(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { if r[2] < r[0] || r[3] < r[1] { [r[0], r[1], r[0], r[1]] } else { r } } -fn inside(b: [f64; 4], c: [f64; 4]) -> bool { - b[0] >= c[0] && b[1] >= c[1] && b[2] <= c[2] && b[3] <= c[3] +/// A rectangle `[l, t, r, b]` as a polygon. +pub(crate) fn rect_poly([l, t, r, b]: [f64; 4]) -> Vec

{ + vec![(l, t), (r, t), (r, b), (l, b)] +} + +/// The bounding box of a point list. +pub(crate) fn pts_bbox(pts: &[P]) -> Option<[f64; 4]> { + let (&f, rest) = pts.split_first()?; + Some(rest.iter().fold([f.0, f.1, f.0, f.1], |v, p| [v[0].min(p.0), v[1].min(p.1), v[2].max(p.0), v[3].max(p.1)])) +} + +fn cross(a: P, b: P, p: P) -> f64 { + (b.0 - a.0) * (p.1 - a.1) - (b.1 - a.1) * (p.0 - a.0) +} + +/// A convex clip polygon with positive orientation, or None when `pts` is not convex (or +/// degenerate); duplicate and collinear points are dropped. +pub(crate) fn convex(pts: &[P]) -> Option> { + let mut v: Vec

= Vec::with_capacity(pts.len()); + for &p in pts { + if !(p.0.is_finite() && p.1.is_finite()) { + return None; + } + if v.last().is_none_or(|q: &P| (q.0 - p.0).abs() > 1e-9 || (q.1 - p.1).abs() > 1e-9) { + v.push(p); + } + } + while v.len() > 1 && v.first() == v.last() { + v.pop(); + } + let area: f64 = (0..v.len()).map(|i| cross((0.0, 0.0), v[i], v[(i + 1) % v.len()])).sum(); + if v.len() < 3 || area.abs() < 1e-12 { + return None; + } + if area < 0.0 { + v.reverse(); + } + let n = v.len(); + let mut out = Vec::with_capacity(n); + for i in 0..n { + let c = cross(v[(i + n - 1) % n], v[i], v[(i + 1) % n]); + if c < -1e-9 * (1.0 + area.abs()) { + return None; + } + if c.abs() > 1e-12 { + out.push(v[i]); + } + } + (out.len() >= 3).then_some(out) +} + +fn inside_poly(clip: &[P], p: P) -> bool { + (0..clip.len()).all(|i| cross(clip[i], clip[(i + 1) % clip.len()], p) >= -1e-9) +} + +/// Whether the clip polygon is an axis-aligned rectangle. +fn is_rect(clip: &[P]) -> bool { + pts_bbox(clip).is_some_and(|b| clip.len() == 4 && clip.iter().all(|p| (p.0 == b[0] || p.0 == b[2]) && (p.1 == b[1] || p.1 == b[3]))) +} + +/// Sutherland–Hodgman: `poly` cut by convex `clip`. +pub(crate) fn clip_polygon(poly: &[P], clip: &[P]) -> Vec

{ + let mut poly = poly.to_vec(); + for i in 0..clip.len() { + let (a, b) = (clip[i], clip[(i + 1) % clip.len()]); + let keep = |p: P| cross(a, b, p) >= 0.0; + let cut = |p: P, q: P| { + let (cp, cq) = (cross(a, b, p), cross(a, b, q)); + let t = cp / (cp - cq); + (p.0 + (q.0 - p.0) * t, p.1 + (q.1 - p.1) * t) + }; + let mut next = Vec::with_capacity(poly.len() + 4); + for j in 0..poly.len() { + let (p, q) = (poly[(j + poly.len() - 1) % poly.len()], poly[j]); + match (keep(p), keep(q)) { + (true, true) => next.push(q), + (true, false) => next.push(cut(p, q)), + (false, true) => { + next.push(cut(p, q)); + next.push(q); + } + (false, false) => {} + } + } + poly = next; + if poly.is_empty() { + break; + } + } + poly } fn disjoint(b: [f64; 4], c: [f64; 4]) -> bool { b[2] < c[0] || b[0] > c[2] || b[3] < c[1] || b[1] > c[3] || c[2] <= c[0] || c[3] <= c[1] } -/// Clips one drawn item; a shape that is both filled and stroked comes back as two items. -pub(crate) fn clip_raw(raw: Raw, c: [f64; 4]) -> Vec { +fn bbox_inside(b: [f64; 4], clip: &[P]) -> bool { + rect_poly(b).into_iter().all(|p| inside_poly(clip, p)) +} + +/// Clips one drawn item to a convex device polygon; a shape that is both filled and stroked comes +/// back as two items. An empty polygon clips everything away. +pub(crate) fn clip_raw(raw: Raw, clip: &[P]) -> Vec { + let Some(cb) = pts_bbox(clip).filter(|_| clip.len() >= 3) else { return Vec::new() }; match raw { Raw::Path { ops, fill, stroke, even_odd } => { let Some(b) = ops_bbox(&ops) else { return Vec::new() }; - if inside(b, c) { + if bbox_inside(b, clip) { return vec![Raw::Path { ops, fill, stroke, even_odd }]; } - if disjoint(b, c) { + if disjoint(b, cb) { return Vec::new(); } let subs = flatten(&ops); let mut out = Vec::new(); if fill.is_some() { - let f = clip_fill(&subs, c); + let f = clip_fill(&subs, clip); if !f.is_empty() { out.push(Raw::Path { ops: f, fill, stroke: None, even_odd }); } } if stroke.is_some() { - let s = clip_stroke(&subs, c); + let s = clip_stroke(&subs, clip); if !s.is_empty() { out.push(Raw::Path { ops: s, fill: None, stroke, even_odd }); } @@ -74,18 +169,19 @@ pub(crate) fn clip_raw(raw: Raw, c: [f64; 4]) -> Vec { } Raw::Pattern { ops, even_odd, tile } => { let Some(b) = ops_bbox(&ops) else { return Vec::new() }; - if inside(b, c) { + if bbox_inside(b, clip) { return vec![Raw::Pattern { ops, even_odd, tile }]; } - if disjoint(b, c) { + if disjoint(b, cb) { return Vec::new(); } - let f = clip_fill(&flatten(&ops), c); + let f = clip_fill(&flatten(&ops), clip); if f.is_empty() { Vec::new() } else { vec![Raw::Pattern { ops: f, even_odd, tile }] } } - Raw::Bitmap { rect, img } => crop(rect, img, c).map(|(rect, img)| vec![Raw::Bitmap { rect, img }]).unwrap_or_default(), + Raw::Bitmap { rect, img } => crop(rect, img, clip).map(|(rect, img)| vec![Raw::Bitmap { rect, img }]).unwrap_or_default(), Raw::Text(mut t) => { - let cur = t.clip.map_or(c, |[a, b, d, e]| intersect(c, [f64::from(a), f64::from(b), f64::from(d), f64::from(e)])); + // Text clips to a rectangle: the one around the polygon. + let cur = t.clip.map_or(cb, |[a, b, d, e]| intersect(cb, [f64::from(a), f64::from(b), f64::from(d), f64::from(e)])); t.clip = Some(cur.map(|v| v as f32)); vec![Raw::Text(t)] } @@ -93,7 +189,7 @@ pub(crate) fn clip_raw(raw: Raw, c: [f64; 4]) -> Vec { } /// Subpaths as point lists with their closed flag. -fn flatten(ops: &[Op]) -> Vec<(Vec

, bool)> { +pub(crate) fn flatten(ops: &[Op]) -> Vec<(Vec

, bool)> { let mut subs: Vec<(Vec

, bool)> = Vec::new(); for op in ops { match *op { @@ -128,45 +224,10 @@ fn flatten(ops: &[Op]) -> Vec<(Vec

, bool)> { subs } -fn clip_fill(subs: &[(Vec

, bool)], c: [f64; 4]) -> Vec { +fn clip_fill(subs: &[(Vec

, bool)], clip: &[P]) -> Vec { let mut ops = Vec::new(); for (pts, _) in subs { - let mut poly = pts.clone(); - // Left, top, right, bottom edges in turn. - for edge in 0..4 { - let keep = |p: P| match edge { - 0 => p.0 >= c[0], - 1 => p.1 >= c[1], - 2 => p.0 <= c[2], - _ => p.1 <= c[3], - }; - let cut = |a: P, b: P| { - let t = match edge { - 0 => (c[0] - a.0) / (b.0 - a.0), - 1 => (c[1] - a.1) / (b.1 - a.1), - 2 => (c[2] - a.0) / (b.0 - a.0), - _ => (c[3] - a.1) / (b.1 - a.1), - }; - (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t) - }; - let mut next = Vec::with_capacity(poly.len() + 4); - for i in 0..poly.len() { - let (a, b) = (poly[(i + poly.len() - 1) % poly.len()], poly[i]); - match (keep(a), keep(b)) { - (true, true) => next.push(b), - (true, false) => next.push(cut(a, b)), - (false, true) => { - next.push(cut(a, b)); - next.push(b); - } - (false, false) => {} - } - } - poly = next; - if poly.is_empty() { - break; - } - } + let poly = clip_polygon(pts, clip); if poly.len() >= 3 { ops.push(Op::M(poly[0])); ops.extend(poly[1..].iter().map(|p| Op::L(*p))); @@ -176,7 +237,7 @@ fn clip_fill(subs: &[(Vec

, bool)], c: [f64; 4]) -> Vec { ops } -fn clip_stroke(subs: &[(Vec

, bool)], c: [f64; 4]) -> Vec { +fn clip_stroke(subs: &[(Vec

, bool)], clip: &[P]) -> Vec { let mut ops = Vec::new(); for (pts, closed) in subs { let mut pts = pts.clone(); @@ -185,7 +246,7 @@ fn clip_stroke(subs: &[(Vec

, bool)], c: [f64; 4]) -> Vec { } let mut last: Option

= None; for w in pts.windows(2) { - let Some((a, b)) = segment(w[0], w[1], c) else { + let Some((a, b)) = segment(w[0], w[1], clip) else { last = None; continue; }; @@ -199,34 +260,40 @@ fn clip_stroke(subs: &[(Vec

, bool)], c: [f64; 4]) -> Vec { ops } -/// Liang–Barsky: the part of segment `a`–`b` inside `c`. -fn segment(a: P, b: P, c: [f64; 4]) -> Option<(P, P)> { - let (dx, dy) = (b.0 - a.0, b.1 - a.1); +/// Cyrus–Beck: the part of segment `a`–`b` inside convex `clip`. +fn segment(a: P, b: P, clip: &[P]) -> Option<(P, P)> { + let d = (b.0 - a.0, b.1 - a.1); let (mut t0, mut t1) = (0.0f64, 1.0f64); - for (p, q) in [(-dx, a.0 - c[0]), (dx, c[2] - a.0), (-dy, a.1 - c[1]), (dy, c[3] - a.1)] { - if p == 0.0 { - if q < 0.0 { + for i in 0..clip.len() { + let (e0, e1) = (clip[i], clip[(i + 1) % clip.len()]); + // Inside is cross(e0, e1, p) >= 0, linear in t along the segment. + let f0 = cross(e0, e1, a); + let df = (e1.0 - e0.0) * d.1 - (e1.1 - e0.1) * d.0; + if df == 0.0 { + if f0 < 0.0 { return None; } } else { - let r = q / p; - if p < 0.0 { - t0 = t0.max(r); + let t = -f0 / df; + if df > 0.0 { + t0 = t0.max(t); } else { - t1 = t1.min(r); + t1 = t1.min(t); } } } - (t0 <= t1).then_some(((a.0 + dx * t0, a.1 + dy * t0), (a.0 + dx * t1, a.1 + dy * t1))) + (t0 <= t1).then_some(((a.0 + d.0 * t0, a.1 + d.1 * t0), (a.0 + d.0 * t1, a.1 + d.1 * t1))) } -/// Crops a bitmap drawn into `rect` = `[x, y, w, h]` (device, positive extents) to `c`. -fn crop(rect: [f64; 4], img: Rgba, c: [f64; 4]) -> Option<([f64; 4], Rgba)> { +/// Crops a bitmap drawn into `rect` = `[x, y, w, h]` (device, positive extents) to the clip +/// polygon's box; pixels outside a non-rectangular polygon turn transparent. +fn crop(rect: [f64; 4], img: Rgba, clip: &[P]) -> Option<([f64; 4], Rgba)> { let [x, y, w, h] = rect; let b = [x, y, x + w, y + h]; - if inside(b, c) { + if bbox_inside(b, clip) { return Some((rect, img)); } + let c = pts_bbox(clip)?; if disjoint(b, c) || w <= 0.0 || h <= 0.0 || img.w == 0 || img.h == 0 { return None; } @@ -239,12 +306,23 @@ fn crop(rect: [f64; 4], img: Rgba, c: [f64; 4]) -> Option<([f64; 4], Rgba)> { if x1 <= x0 || y1 <= y0 { return None; } + let mask = !is_rect(clip); let mut px = Vec::with_capacity((x1 - x0) * (y1 - y0) * 4); for row in y0..y1 { // Rows are top-down unless the image says otherwise. let r = if img.bottom_up { img.h - 1 - row } else { row }; let s = (r * img.w + x0) * 4; + let start = px.len(); px.extend_from_slice(img.px.get(s..s + (x1 - x0) * 4)?); + if mask { + let cy = y + (row as f64 + 0.5) / ky; + for (k, p) in px[start..].as_chunks_mut::<4>().0.iter_mut().enumerate() { + let cx = x + ((x0 + k) as f64 + 0.5) / kx; + if !inside_poly(clip, (cx, cy)) { + p[3] = 0; + } + } + } } let out = [x + x0 as f64 / kx, y + y0 as f64 / ky, (x1 - x0) as f64 / kx, (y1 - y0) as f64 / ky]; Some((out, Rgba { w: x1 - x0, h: y1 - y0, bottom_up: false, px })) diff --git a/crates/metafile/src/emfplus.rs b/crates/metafile/src/emfplus.rs index 465f2a1f..48d1af89 100644 --- a/crates/metafile/src/emfplus.rs +++ b/crates/metafile/src/emfplus.rs @@ -9,19 +9,23 @@ //! - bitmaps (raw pixel formats, and PNG/JPEG/GIF/TIFF/BMP payloads) and strings (DrawString, //! DrawDriverString with character codes); //! - world and page transforms, save/restore and containers; -//! - clipping to rectangles, and to the bounding rectangle of clip paths and regions. +//! - arcs and pies; +//! - clipping to rectangles and convex clip paths and regions (others: their bounding rectangle); +//! - nested metafile images, and glyph-index driver strings in the Windows core fonts. //! -//! Not drawn yet: nested metafile images, glyph-index driver strings, arcs and pies, image -//! attributes, and region combinations other than intersect and union. +//! Not drawn yet: image attributes, glyph indices of other fonts, and region combinations other +//! than intersect and union (XOR, exclude and complement keep the left area). use std::sync::Arc; use crate::bytes::Bytes; +use crate::canvas::Raw; use crate::canvas::{BS_NULL, BS_SOLID, Brush, Canvas, LogFont, PS_NULL, Pen, RawTile, Xform, hatch_tile, mul}; -use crate::clip::{intersect, ops_bbox}; +use crate::clip::{clip_polygon, convex, flatten, ops_bbox, pts_bbox, rect_poly}; use crate::dib::Rgba; use crate::geom::{Op, P}; use crate::text::{ETO_CLIPPED, Font}; +use crate::{Item, Picture, Seg}; /// "EMF+" as the comment identifier of an EMR_COMMENT holding EMF+ records. pub(crate) const EMF_PLUS: u32 = 0x2B46_4D45; @@ -155,6 +159,23 @@ struct Format { line_align: u32, } +/// A clip area: a convex polygon (empty clips everything), or None for infinite. +type Area = Option>; + +enum ImageData { + Bitmap(Rgba), + /// A nested WMF or EMF (EMF+) picture. + Meta(Picture), +} + +thread_local! { + /// Nesting depth of metafile images being parsed, so a file of nested metafiles cannot run + /// the stack out. + static NESTING: std::cell::Cell = const { std::cell::Cell::new(0) }; +} +/// Metafile images inside metafile images, at most. +const MAX_NESTING: usize = 3; + #[derive(Clone)] enum Obj { Brush(Fill), @@ -163,11 +184,11 @@ enum Obj { width: f64, }, Path(Vec), - Image(Arc), + Image(Arc), Font(FontObj), Format(Format), /// A region's bounding rectangle `[l, t, r, b]` in world units; None is infinite. - Region(Option<[f64; 4]>), + Region(Area), } /// An object arriving in several records: its id, type, total size and the bytes so far. @@ -182,7 +203,7 @@ struct Saved { id: u32, world: Xform, page: f64, - clip: Option<[f64; 4]>, + clip: Area, } pub(crate) struct Plus { @@ -191,8 +212,8 @@ pub(crate) struct Plus { dpi: f64, world: Xform, page: f64, - /// Device clip rectangle `[l, t, r, b]`; None is unclipped. - clip: Option<[f64; 4]>, + /// Convex device clip polygon; None is unclipped. + clip: Area, objects: Vec>, stack: Vec, partial: Option, @@ -227,7 +248,7 @@ impl Plus { } fn painter(&self) -> Painter { - Painter { xf: self.xf(), clip: self.clip } + Painter { xf: self.xf(), clip: self.clip.clone() } } fn record(&mut self, cv: &mut Canvas, typ: u16, flags: u16, r: Bytes) -> Option<()> { @@ -351,28 +372,43 @@ impl Plus { } 0x4032 => { let [x, y, w, h] = floats::<4>(r, 0)?; - let dev = self.device_rect(cv, [x, y, x + w, y + h]); - self.combine(flags, Some(dev)) + let area = map_area(Some(rect_poly([x, y, x + w, y + h])), mul(self.xf(), cv.device_affine())); + self.combine(flags, area) } 0x4033 => { - let b = ops_bbox(&self.path(id)?)?; - let dev = self.device_rect(cv, b); - self.combine(flags, Some(dev)) + let area = map_area(Some(area_of_ops(&self.path(id)?)), mul(self.xf(), cv.device_affine())); + self.combine(flags, area) } 0x4034 => { let Some(Some(Obj::Region(reg))) = self.objects.get(id).cloned() else { return None }; - let dev = reg.map(|b| self.device_rect(cv, b)); - self.combine(flags, dev) + let area = map_area(reg, mul(self.xf(), cv.device_affine())); + self.combine(flags, area) } 0x4035 => { let [dx, dy] = floats::<2>(r, 0)?; let a = mul(self.xf(), cv.device_affine()); let (ox, oy) = (dx * a[0] + dy * a[2], dx * a[1] + dy * a[3]); if let Some(c) = self.clip.as_mut() { - *c = [c[0] + ox, c[1] + oy, c[2] + ox, c[3] + oy]; + for p in c.iter_mut() { + *p = (p.0 + ox, p.1 + oy); + } } Some(()) } + 0x4010 => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let [start, sweep] = floats::<2>(r, 4)?; + let rect = *rects(r, 12, 1, compressed)?.first()?; + self.painter().fill(cv, &arc_ops(rect, start, sweep, true), &fill); + Some(()) + } + 0x4011 | 0x4012 => { + let pen = self.pen(id)?; + let [start, sweep] = floats::<2>(r, 0)?; + let rect = *rects(r, 8, 1, compressed)?.first()?; + self.painter().stroke(cv, &arc_ops(rect, start, sweep, typ == 0x4011), pen); + Some(()) + } _ => None, } } @@ -385,48 +421,27 @@ impl Plus { fn push(&mut self, id: u32) { if self.stack.len() < MAX_STACK { - self.stack.push(Saved { id, world: self.world, page: self.page, clip: self.clip }); + self.stack.push(Saved { id, world: self.world, page: self.page, clip: self.clip.clone() }); } } fn pop(&mut self, id: u32) { if let Some(i) = self.stack.iter().rposition(|s| s.id == id) { - let s = &self.stack[i]; + let s = self.stack.swap_remove(i); (self.world, self.page, self.clip) = (s.world, s.page, s.clip); self.stack.truncate(i); } } - /// A world rectangle `[l, t, r, b]` as the device rectangle around it. - fn device_rect(&self, cv: &Canvas, [l, t, r, b]: [f64; 4]) -> [f64; 4] { - let a = mul(self.xf(), cv.device_affine()); - let pts = [(l, t), (r, t), (l, b), (r, b)].map(|p| apply(a, p)); - let xs = pts.map(|p| p.0); - let ys = pts.map(|p| p.1); - let min = |v: [f64; 4]| v.iter().copied().fold(f64::INFINITY, f64::min); - let max = |v: [f64; 4]| v.iter().copied().fold(f64::NEG_INFINITY, f64::max); - [min(xs), min(ys), max(xs), max(ys)] - } - - /// Combines a new clip area (None: infinite) with the current one under the record's combine - /// mode: replace, intersect and union (as the rectangle around both). XOR, exclude and - /// complement leave the clip as it was. - fn combine(&mut self, flags: u16, new: Option<[f64; 4]>) -> Option<()> { - if new.is_some_and(|c| !c.iter().all(|v| v.is_finite())) { - return None; - } + /// Combines a new clip area with the current one under the record's combine mode: replace, + /// intersect and union (as the rectangle around both). XOR, exclude and complement leave the + /// clip as it was. + fn combine(&mut self, flags: u16, new: Area) -> Option<()> { self.clip = match (flags >> 8) & 0xF { 0 => new, - 1 => match (self.clip, new) { - (None, n) => n, - (c, None) => c, - (Some(a), Some(b)) => Some(intersect(a, b)), - }, - 2 => match (self.clip, new) { - (Some(a), Some(b)) => Some([a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]), - _ => None, - }, - _ => self.clip, + 1 => area_intersect(self.clip.take(), new), + 2 => area_union(self.clip.take(), new), + _ => self.clip.take(), }; Some(()) } @@ -489,7 +504,7 @@ impl Plus { } } - fn image(&self, id: usize) -> Option> { + fn image(&self, id: usize) -> Option> { match self.objects.get(id)? { Some(Obj::Image(img)) => Some(img.clone()), _ => None, @@ -509,7 +524,14 @@ impl Plus { /// DrawImage and DrawImagePoints: the source rectangle (pixels) cut from the image, drawn /// into world rectangle `dst` = `[x, y, w, h]`. - fn draw_image(&self, cv: &mut Canvas, img: &Rgba, r: Bytes, dst: [f64; 4]) -> Option<()> { + fn draw_image(&self, cv: &mut Canvas, img: &ImageData, r: Bytes, dst: [f64; 4]) -> Option<()> { + let img = match img { + ImageData::Bitmap(b) => b, + ImageData::Meta(pic) => { + self.painter().picture(cv, dst, pic); + return Some(()); + } + }; let [sx, sy, sw, sh] = floats::<4>(r, 8)?; let src = if r.u32(4)? == 2 { [sx, sy, sw, sh].map(|v| v.round().clamp(-1.0e9, 1.0e9) as i64) } else { [0; 4] }; let (part, dst) = crate::dib::clip_blit(img, src, dst)?; @@ -552,7 +574,14 @@ impl Plus { let units: Vec = (0..len).map(|i| r.u16(28 + i * 2)).collect::>()?; let lf = self.log_font(&font); let em = f64::from(-lf.font.height); - let lines: Vec<&[u16]> = units.split(|u| *u == 0x0A).collect(); + let wrap = w > 0.0 && fmt.flags & 0x1000 == 0; + let lines: Vec> = units + .split(|u| *u == 0x0A) + .flat_map(|l| { + let l: Vec = l.iter().copied().filter(|u| *u != 0x0D).collect(); + if wrap { wrap_line(&l, w, em) } else { vec![l] } + }) + .collect(); let line_h = em * 1.2; let (ax, ta_h) = match fmt.align { 1 => (x + w / 2.0, TA_CENTER), @@ -568,11 +597,10 @@ impl Plus { let clip = (w > 0.0 && h > 0.0 && fmt.flags & 0x4000 == 0).then_some([x, y, x + w, y + h]); let p = self.painter(); for (i, line) in lines.iter().enumerate() { - let line: Vec = line.iter().copied().filter(|u| *u != 0x0D).collect(); if line.is_empty() { continue; } - let (text, dx) = crate::text::decode_utf16(&line, &[]); + let (text, dx) = crate::text::decode_utf16(line, &[]); let at = (ax, top + line_h * i as f64); let (at, ta) = if fmt.line_align == 2 && lines.len() == 1 { ((ax, y + h), ta_h | TA_BOTTOM) } else { (at, ta_h) }; p.text(cv, at, text, dx, TextStyle { color, font: lf.clone(), align: ta, clip }, IDENTITY); @@ -585,12 +613,13 @@ impl Plus { let font = self.font(usize::from(flags & 0xFF))?; let color = self.brush_of(flags, r.u32(0)?)?.color(); let opts = r.u32(4)?; - if opts & 1 == 0 { - // Glyph indices into the font: the metafile crate knows no fonts to map them back. - return None; - } let n = (r.u32(12)? as usize).min(MAX_CHARS); - let units: Vec = (0..n).map(|i| r.u16(16 + i * 2)).collect::>()?; + let mut units: Vec = (0..n).map(|i| r.u16(16 + i * 2)).collect::>()?; + if opts & 1 == 0 { + // Glyph indices into the font. The crate knows no fonts, but the classic Windows core + // fonts keep the standard Macintosh glyph order for ASCII, so those map back. + units = glyphs_to_chars(&font.family, &units)?; + } let pos = points(r, 16 + n * 2, n, false)?; let m = if r.u32(8)? != 0 { matrix(r, 16 + n * 10)? } else { IDENTITY }; let lf = self.log_font(&font); @@ -631,7 +660,7 @@ struct TextStyle { /// canvas's GDI state back. struct Painter { xf: Xform, - clip: Option<[f64; 4]>, + clip: Area, } impl Painter { @@ -639,7 +668,7 @@ impl Painter { let world = cv.world(); cv.save_dc(); cv.modify_world(mul(mul(pre, self.xf), world), 4); - cv.set_clip(self.clip); + cv.set_clip(self.clip.clone()); } fn end(&self, cv: &mut Canvas) { @@ -691,6 +720,62 @@ impl Painter { self.end(cv); } + /// A nested metafile's picture stretched into world rectangle `dst` = `[x, y, w, h]`. + fn picture(&self, cv: &mut Canvas, [x, y, w, h]: [f64; 4], pic: &Picture) { + self.begin(cv, IDENTITY); + let (sx, sy) = (w / f64::from(pic.width), h / f64::from(pic.height)); + let m = mul([sx, 0.0, 0.0, sy, x, y], cv.device_affine()); + let scale = (m[0] * m[3] - m[1] * m[2]).abs().sqrt(); + let pt = |a: f32, b: f32| apply(m, (f64::from(a), f64::from(b))); + let ops = |segs: &[Seg]| -> Vec { + segs.iter() + .map(|s| match *s { + Seg::Move(a, b) => Op::M(pt(a, b)), + Seg::Line(a, b) => Op::L(pt(a, b)), + Seg::Cubic(a, b, c, d, e, f) => Op::C(pt(a, b), pt(c, d), pt(e, f)), + Seg::Close => Op::Z, + }) + .collect() + }; + for item in &pic.items { + if cv.full() { + break; + } + let raw = match item { + Item::Path { segs, fill, stroke, even_odd } => { + Raw::Path { ops: ops(segs), fill: *fill, stroke: stroke.map(|(c, w)| (c, f64::from(w) * scale)), even_odd: *even_odd } + } + Item::Pattern { segs, even_odd, tile } => Raw::Pattern { + ops: ops(segs), + even_odd: *even_odd, + tile: Arc::new(RawTile { + w: tile.width as usize, + h: tile.height as usize, + px: tile.rgba.clone(), + cell: f64::from(tile.cell[0]) * scale, + }), + }, + Item::Bitmap { rect, width, height, rgba } => { + let (a, b) = (pt(rect[0], rect[1]), pt(rect[0] + rect[2], rect[1] + rect[3])); + let mut img = Rgba { w: *width as usize, h: *height as usize, bottom_up: false, px: rgba.clone() }; + crate::dib::flip(&mut img, a.0 > b.0, a.1 > b.1); + Raw::Bitmap { rect: [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()], img } + } + Item::Text(t) => { + let mut t = t.clone(); + t.transform = mul(t.transform, m); + if let Some([x0, y0, x1, y1]) = t.clip { + let (p, q) = (pt(x0, y0), pt(x1, y1)); + t.clip = Some([p.0.min(q.0) as f32, p.1.min(q.1) as f32, p.0.max(q.0) as f32, p.1.max(q.1) as f32]); + } + Raw::Text(t) + } + }; + cv.push_device(raw); + } + self.end(cv); + } + fn text(&self, cv: &mut Canvas, at: P, text: String, dx: Vec, style: TextStyle, pre: Xform) { self.begin(cv, pre); cv.set_text_style(style.color, style.font, style.align); @@ -756,7 +841,8 @@ fn brush(r: Bytes, off: usize) -> Option { let bflags = d.u32(0)?; let wrap = d.u32(4)?; let (xf, off) = if bflags & 0x2 != 0 { (matrix(d, 8)?, 32) } else { (IDENTITY, 8) }; - Some(Fill::Texture { img: Arc::new(image(d.tail(off)?)?), wrap, xf }) + let ImageData::Bitmap(img) = image(d.tail(off)?)? else { return None }; + Some(Fill::Texture { img: Arc::new(img), wrap, xf }) } 3 => path_gradient(d), 4 => linear_gradient(d), @@ -879,56 +965,133 @@ fn path(r: Bytes) -> Option> { Some(ops) } -/// A region object reduced to its bounding rectangle (None: infinite). +/// A region object as a convex area in world units (non-convex parts become their bounding +/// rectangle). fn region(r: Bytes) -> Option { - region_node(r, 8, 0).map(|(b, _)| Obj::Region(b)) + region_node(r, 8, 0).map(|(a, _)| Obj::Region(a)) } -/// One region node at `off`: its bounds (None: infinite; an empty node is a zero rectangle) and -/// its length in bytes. -fn region_node(r: Bytes, off: usize, depth: usize) -> Option<(Option<[f64; 4]>, usize)> { +/// One region node at `off`: its area and its length in bytes. +fn region_node(r: Bytes, off: usize, depth: usize) -> Option<(Area, usize)> { if depth > MAX_REGION_DEPTH { return None; } match r.u32(off)? { 0x1000_0000 => { let [x, y, w, h] = floats::<4>(r, off + 4)?; - Some((Some([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)]), 20)) + Some((Some(rect_poly([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)])), 20)) } 0x1000_0001 => { let size = r.u32(off + 4)? as usize; - let b = ops_bbox(&path(r.tail(off + 8)?)?).unwrap_or([0.0; 4]); - Some((Some(b), 8usize.checked_add(size)?)) + let area = path(r.tail(off + 8)?).map(|ops| area_of_ops(&ops)).unwrap_or_default(); + Some((Some(area), 8usize.checked_add(size)?)) } - 0x1000_0002 => Some((Some([0.0; 4]), 4)), + 0x1000_0002 => Some((Some(Vec::new()), 4)), 0x1000_0003 => Some((None, 4)), - mode @ 1..=5 => { + mode @ 0..=5 => { let (a, la) = region_node(r, off + 4, depth + 1)?; let (b, lb) = region_node(r, off.checked_add(4 + la)?, depth + 1)?; let out = match mode { - 1 => match (a, b) { - (None, x) | (x, None) => x, - (Some(a), Some(b)) => Some(intersect(a, b)), - }, - 2 => match (a, b) { - (Some(a), Some(b)) => Some([a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]), - _ => None, - }, + 0 => b, + 1 => area_intersect(a, b), + 2 => area_union(a, b), // XOR, exclude, complement: the left area bounds the result closely enough. _ => a, }; Some((out, (4 + la).checked_add(lb)?)) } - // Replace: the right-hand area. - 0 => { - let (_, la) = region_node(r, off + 4, depth + 1)?; - let (b, lb) = region_node(r, off.checked_add(4 + la)?, depth + 1)?; - Some((b, (4 + la).checked_add(lb)?)) + _ => None, + } +} + +/// The area a path encloses: its outline when that is one convex figure, else the rectangle +/// around it. +fn area_of_ops(ops: &[Op]) -> Vec

{ + let subs = flatten(ops); + if let [(pts, _)] = subs.as_slice() + && let Some(c) = convex(pts) + { + return c; + } + ops_bbox(ops).map(rect_poly).unwrap_or_default() +} + +fn area_intersect(a: Area, b: Area) -> Area { + match (a, b) { + (None, x) | (x, None) => x, + (Some(a), Some(b)) if a.len() >= 3 && b.len() >= 3 => Some(convex(&clip_polygon(&a, &b)).unwrap_or_default()), + _ => Some(Vec::new()), + } +} + +fn area_union(a: Area, b: Area) -> Area { + match (a, b) { + (Some(a), Some(b)) => { + if a.len() < 3 { + return Some(b); + } + if b.len() < 3 { + return Some(a); + } + let all: Vec

= a.into_iter().chain(b).collect(); + Some(pts_bbox(&all).map(rect_poly).unwrap_or_default()) } _ => None, } } +/// An area through an affine map (which keeps it convex; the orientation is put right again). +fn map_area(a: Area, m: Xform) -> Area { + a.map(|pts| { + let mapped: Vec

= pts.iter().map(|p| apply(m, *p)).collect(); + if mapped.len() < 3 { Vec::new() } else { convex(&mapped).unwrap_or_default() } + }) +} + +/// An elliptical arc in `rect` from `start` sweeping `sweep` degrees (clockwise on screen), as +/// cubics; a pie adds the lines to and from the centre. +fn arc_ops([l, t, r, b]: [f64; 4], start: f64, sweep: f64, pie: bool) -> Vec { + let (cx, cy, rx, ry) = ((l + r) / 2.0, (t + b) / 2.0, (r - l) / 2.0, (b - t) / 2.0); + let sweep = sweep.clamp(-360.0, 360.0); + // GDI+ angles are along rays from the centre; turn them into the ellipse's parameter. + let param = |deg: f64| { + let a = deg.to_radians(); + (rx * a.sin()).atan2(ry * a.cos()) + }; + let t0 = param(start); + let mut dt = if sweep.abs() >= 360.0 { sweep.signum() * std::f64::consts::TAU } else { param(start + sweep) - t0 }; + if sweep.abs() < 360.0 { + while sweep > 0.0 && dt < 0.0 { + dt += std::f64::consts::TAU; + } + while sweep < 0.0 && dt > 0.0 { + dt -= std::f64::consts::TAU; + } + } + let at = |t: f64| (cx + rx * t.cos(), cy + ry * t.sin()); + let mut ops = Vec::with_capacity(8); + if pie { + ops.push(Op::M((cx, cy))); + ops.push(Op::L(at(t0))); + } else { + ops.push(Op::M(at(t0))); + } + let n = ((dt.abs() / std::f64::consts::FRAC_PI_2).ceil() as usize).clamp(1, 4); + let step = dt / n as f64; + let k = 4.0 / 3.0 * (step / 4.0).tan(); + for i in 0..n { + let (a, e) = (t0 + step * i as f64, t0 + step * (i + 1) as f64); + let (p0, p3) = (at(a), at(e)); + let c1 = (p0.0 - k * rx * a.sin(), p0.1 + k * ry * a.cos()); + let c2 = (p3.0 + k * rx * e.sin(), p3.1 - k * ry * e.cos()); + ops.push(Op::C(c1, c2, p3)); + } + if pie { + ops.push(Op::Z); + } + ops +} + /// A font object: em size, unit, style flags, then the family name in UTF-16. fn font(r: Bytes) -> Option { let em = f64::from(r.f32(4)?); @@ -938,12 +1101,31 @@ fn font(r: Bytes) -> Option { em.is_finite().then_some(Obj::Font(FontObj { em, unit: r.u32(8)?, style: r.u32(12)?, family })) } -/// An image object: a bitmap of raw pixels or a compressed (PNG, JPEG, GIF…) file. Metafile -/// images are not drawn yet. -fn image(r: Bytes) -> Option { - if r.u32(4)? != 1 { +/// An image object: a bitmap of raw pixels or a compressed (PNG, JPEG, GIF…) file, or a nested +/// metafile. +fn image(r: Bytes) -> Option { + match r.u32(4)? { + 1 => bitmap(r).map(ImageData::Bitmap), + 2 => metafile(r).map(ImageData::Meta), + _ => None, + } +} + +/// A metafile image: type, size, then a WMF (placeable or not) or EMF file, parsed as a picture. +fn metafile(r: Bytes) -> Option { + let size = r.u32(12)? as usize; + let data = r.slice(16, size).or_else(|| r.tail(16).map(|t| t.0))?; + let depth = NESTING.with(|n| n.get()); + if depth >= MAX_NESTING { return None; } + NESTING.with(|n| n.set(depth + 1)); + let pic = crate::parse(data).ok(); + NESTING.with(|n| n.set(depth)); + pic.filter(|p| p.width > 0.0 && p.height > 0.0) +} + +fn bitmap(r: Bytes) -> Option { let (w, h, stride) = (r.u32(8)? as i32, r.u32(12)? as i32, r.u32(16)? as i32); let format = r.u32(20)?; let data = r.tail(28)?; @@ -1086,3 +1268,68 @@ fn ellipse_ops([l, t, r, b]: [f64; 4]) -> Vec { Op::Z, ] } + +/// Core fonts whose first glyphs follow the standard Macintosh order (glyph 3 is the space, +/// glyphs 3..=97 are U+0020..=U+007E). +const MAC_ORDER_FONTS: &[&str] = + &["arial", "arial black", "times new roman", "courier new", "verdana", "georgia", "tahoma", "trebuchet ms", "comic sans ms", "impact"]; + +/// Glyph indices of a known font back to ASCII; None when the font is unknown or a glyph is +/// outside the ASCII range. +fn glyphs_to_chars(family: &str, glyphs: &[u16]) -> Option> { + let family = family.trim().to_ascii_lowercase(); + if !MAC_ORDER_FONTS.contains(&family.as_str()) { + return None; + } + glyphs.iter().map(|g| (3..=97).contains(g).then_some(g + 29)).collect() +} + +/// A rough advance for a character in ems: the metafile crate has no fonts, so line breaks for +/// wrapped strings use typical proportional widths. +fn char_em(u: u16) -> f64 { + match char::from_u32(u32::from(u)).unwrap_or('x') { + ' ' | 'i' | 'l' | 'j' | '.' | ',' | ':' | ';' | '\'' | '!' | '|' | 'I' | 'f' | 't' | 'r' => 0.3, + 'm' | 'w' | 'M' | 'W' => 0.85, + c if c.is_ascii_uppercase() || c.is_ascii_digit() => 0.62, + c if (c as u32) >= 0x2E80 => 1.0, + _ => 0.52, + } +} + +/// Greedy word wrap of one line to `width` world units at em size `em`; a word longer than the +/// line breaks between characters. +fn wrap_line(line: &[u16], width: f64, em: f64) -> Vec> { + if em <= 0.0 || !width.is_finite() { + return vec![line.to_vec()]; + } + let fits = |l: &[u16]| l.iter().map(|u| char_em(*u) * em).sum::() <= width; + let mut out: Vec> = Vec::new(); + let mut cur: Vec = Vec::new(); + for word in line.split_inclusive(|u| *u == 0x20) { + let mut cand = cur.clone(); + cand.extend_from_slice(word); + let trimmed = |v: &[u16]| v.iter().rposition(|u| *u != 0x20).map_or(0, |i| i + 1); + if fits(&cand[..trimmed(&cand)]) || cur.is_empty() && fits(&word[..trimmed(word)]) { + cur = cand; + continue; + } + if !cur.is_empty() { + let n = trimmed(&cur); + cur.truncate(n); + out.push(std::mem::take(&mut cur)); + } + // A word wider than the line on its own: break it between characters. + for &u in word { + cur.push(u); + if !fits(&cur[..trimmed(&cur)]) && cur.len() > 1 { + cur.pop(); + out.push(std::mem::take(&mut cur)); + cur.push(u); + } + } + } + let n = cur.iter().rposition(|u| *u != 0x20).map_or(0, |i| i + 1); + cur.truncate(n); + out.push(cur); + out +} diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 6a23ad6c..7ad5071b 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -1715,10 +1715,16 @@ fn emfplus_driver_string_uses_character_codes_and_positions() { assert_eq!(t.dx, vec![6.0, 6.0]); assert_eq!(t.v_align, VAlign::Baseline); assert!(close(t.transform[4] as f32, 5.0) && close(t.transform[5] as f32, 30.0)); - // Glyph indices (no CmapLookup) are skipped. - let gi = prec(0x4036, 0x8000, &cat(vec![h32(0xFF00_0000), h32(0), h32(0), h32(1), h16(36), f32s(&[0.0, 0.0])])); - let font = prec(0x4008, 0x0600, &font_obj("Arial", 10.0, 0, 0)); - assert!(plus_pic(vec![font, gi]).items.is_empty()); +} + +#[test] +fn emfplus_glyph_index_driver_strings_map_back_in_core_fonts_only() { + // Glyph indices (no CmapLookup): 36, 69 are "Ab" in Arial's standard glyph order. + let gi = || prec(0x4036, 0x8000, &cat(vec![h32(0xFF00_0000), h32(0), h32(0), h32(2), h16(36), h16(69), f32s(&[0.0, 0.0, 6.0, 0.0])])); + let p = plus_pic(vec![prec(0x4008, 0x0600, &font_obj("Arial", 10.0, 0, 0)), gi()]); + assert!(matches!(&p.items[0], Item::Text(t) if t.text == "Ab"), "{:?}", p.items); + // An unknown font's glyph indices cannot be mapped: nothing is drawn. + assert!(plus_pic(vec![prec(0x4008, 0x0600, &font_obj("Calibri", 10.0, 0, 0)), gi()]).items.is_empty()); } #[test] @@ -1784,3 +1790,62 @@ fn emfplus_region_clip_intersects_rectangles() { let p = plus_pic(vec![prec(0x4008, 0x0400 | 6, ®ion), prec(0x4034, 6, &[]), fill]); assert_eq!((p.width, p.height), (20.0, 20.0)); } + +#[test] +fn emfplus_pie_and_arc() { + // A quarter pie from 0 to 90 degrees in a 0..100 circle: centre, then (100, 50) round to (50, 100). + let pie = prec(0x4010, 0x8000, &cat(vec![h32(0xFF00_0000), f32s(&[0.0, 90.0]), f32s(&[0.0, 0.0, 100.0, 100.0])])); + let p = plus_pic(vec![pie]); + let (segs, fill, ..) = path_segs(&p.items[0]); + assert_eq!(fill, Some([0, 0, 0, 255])); + assert_eq!(segs[0], Seg::Move(0.0, 0.0)); + assert_eq!(segs[1], Seg::Line(50.0, 0.0)); + assert!(matches!(segs[2], Seg::Cubic(.., x, y) if close(x, 0.0) && close(y, 50.0)), "{segs:?}"); + assert_eq!(segs[3], Seg::Close); + // A half-circle arc stroke is two cubics. + let pen = prec(0x4008, 0x0200, &solid_pen(0xFF00_0000, 1.0)); + let arc = prec(0x4012, 0, &cat(vec![f32s(&[180.0, -180.0]), f32s(&[0.0, 0.0, 100.0, 100.0])])); + let p = plus_pic(vec![pen, arc]); + let (segs, ..) = path_segs(&p.items[0]); + assert_eq!(segs.len(), 3, "{segs:?}"); +} + +#[test] +fn emfplus_convex_clip_path_cuts_to_the_shape_not_its_box() { + // A diamond clip path (0,50) (50,0) (100,50) (50,100): the corner of a full square fill is cut away. + let diamond = cat(vec![h32(0xDBC0_1002), h32(4), h32(0), f32s(&[0.0, 50.0, 50.0, 0.0, 100.0, 50.0, 50.0, 100.0]), vec![0, 1, 1, 0x81]]); + let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[0.0, 0.0, 100.0, 100.0])])); + let p = plus_pic(vec![prec(0x4008, 0x0300 | 9, &diamond), prec(0x4033, 9, &[]), fill]); + let (segs, ..) = path_segs(&p.items[0]); + let pts: Vec<(f32, f32)> = segs.iter().filter_map(|s| if let Seg::Move(x, y) | Seg::Line(x, y) = s { Some((*x, *y)) } else { None }).collect(); + assert!(!pts.iter().any(|(x, y)| close(*x, 0.0) && close(*y, 0.0)), "corner kept: {pts:?}"); + assert!(pts.iter().all(|(x, y)| (x - 50.0).abs() + (y - 50.0).abs() <= 50.0 + 1e-3), "{pts:?}"); +} + +#[test] +fn emfplus_draw_string_wraps_to_the_layout_width() { + let font = prec(0x4008, 0x0600, &font_obj("Arial", 10.0, 0, 0)); + let text = "alpha beta gamma delta"; + let s = prec( + 0x401C, + 0x8000, + &cat(vec![h32(0xFF00_0000), h32(9), h32(text.encode_utf16().count() as u32), f32s(&[0.0, 0.0, 60.0, 100.0]), u16s(text)]), + ); + let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFFFF_FFFF), h32(1), f32s(&[0.0, 0.0, 60.0, 100.0])])); + let p = plus_pic(vec![fill, font, s]); + let lines: Vec<&str> = p.items.iter().filter_map(|i| if let Item::Text(t) = i { Some(t.text.as_str()) } else { None }).collect(); + assert_eq!(lines, vec!["alpha beta", "gamma delta"]); +} + +#[test] +fn emfplus_nested_metafile_image_draws_its_items_into_the_destination() { + // The nested picture: a plain EMF with a 10 x 10 rectangle (white brush, black pen). + let inner = emf(&[erec(43, &cat(vec![e32(0), e32(0), e32(10), e32(10)]))], [0; 4], [0; 4]); + let obj = cat(vec![h32(0xDBC0_1002), h32(2), h32(3), h32(inner.len() as u32), inner]); + let p = plus_pic(vec![prec(0x4008, 0x0500 | 3, &obj), draw_image(3, [0.0, 0.0, 40.0, 20.0])]); + assert_eq!(p.items.len(), 1); + assert_eq!((p.width, p.height), (40.0, 20.0)); + let (segs, fill, ..) = path_segs(&p.items[0]); + assert_eq!(fill, Some([255, 255, 255, 255])); + assert!(segs.contains(&Seg::Line(40.0, 20.0)), "{segs:?}"); +} From 15daed6ca4b0d0c4de2329c3b509951112141cc9 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 01:08:13 +0300 Subject: [PATCH 05/27] Charts: histograms, Pareto and box and whisker; Office 2016 charts save as themselves Histograms and Paretos bin their observations (bin count, bin width or Scott's rule) and draw as touching columns, a Pareto with its cumulative line on a percentage axis. Box and whisker charts draw exclusive-quartile boxes, 1.5 IQR whiskers, means and outliers per category group. Edited and new Office 2016 charts are written as chartEx parts from the model (with the plain-chart fallback), so a waterfall stays a waterfall. The chart menus list all seven types, and chart.options sets bins and waterfall totals. Co-authored-by: Cursor --- crates/engine/src/cmd/insert.rs | 40 ++++- crates/model/src/chart.rs | 305 ++++++++++++++++++++++++++++++++ crates/pptx/src/read/chartex.rs | 4 + crates/pptx/src/write/chart.rs | 151 +++++++++++++++- crates/pptx/tests/roundtrip.rs | 58 +++++- crates/render/src/chart.rs | 17 ++ crates/render/src/chartex.rs | 103 ++++++++++- crates/render/src/tests.rs | 31 ++++ crates/ui-egui/src/ribbon.rs | 25 ++- docs/file-format-parity.md | 2 +- docs/gaps.md | 10 +- 11 files changed, 728 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 2643ab11..810a96e2 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -73,7 +73,7 @@ pub fn specs() -> Vec { "Chart Elements", ["Chart Design", "Chart Layouts"], None, - "{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, id?}", + "{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], id?}", has_selection, chart_options ), @@ -644,7 +644,12 @@ fn chart_type_cmd(s: &mut Session, p: &Value) -> Result { let k = chart_type(str_param(p, "type").unwrap_or("column")); with_chart(s, p, "chart.type", |c| { c.kind = k; - c.vary_colors = matches!(k, ChartType::Pie | ChartType::Doughnut) || c.series.len() == 1 && c.vary_colors; + c.vary_colors = + matches!(k, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Treemap | ChartType::Sunburst | ChartType::Waterfall) + || c.series.len() == 1 && c.vary_colors; + if matches!(k, ChartType::Waterfall | ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) { + c.gridlines = true; + } c.raw = None; Ok(()) }) @@ -688,7 +693,38 @@ fn chart_data(s: &mut Session, p: &Value) -> Result { } fn chart_options(s: &mut Session, p: &Value) -> Result { + // Histogram bins: a count or a width (setting one clears the other; null for automatic). + let bin_count = p.get("binCount").map(|v| v.as_u64().map(|n| n.clamp(1, deckcraft_model::chart::MAX_BINS as u64) as u32)); + let bin_size = p.get("binSize").map(|v| v.as_f64().filter(|w| w.is_finite() && *w > 0.0)); + if p.get("binSize").is_some_and(|v| !v.is_null()) && bin_size.flatten().is_none() { + return Err(bad("chart.options", "binSize must be a positive number")); + } + // Waterfall total points. + let subtotals: Option> = + p.get("subtotals").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect()); with_chart(s, p, "chart.options", |c| { + if let Some(n) = bin_count { + c.bin_count = n; + if n.is_some() { + c.bin_size = None; + } + } + if let Some(w) = bin_size { + c.bin_size = w; + if w.is_some() { + c.bin_count = None; + } + } + if let Some(t) = &subtotals { + let n = c.series.first().map_or(0, |s| s.values.len()); + let mut t: Vec = t.iter().copied().filter(|i| *i < n).collect(); + t.sort_unstable(); + t.dedup(); + c.subtotals = t; + } + if bin_count.is_some() || bin_size.is_some() || subtotals.is_some() { + c.raw = None; + } if let Some(t) = p.get("title") { c.title = t.as_str().map(String::from); } diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index a6c9fa24..583a447b 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -38,6 +38,22 @@ pub enum ChartType { } impl ChartType { + /// The Office 2016 (`chartEx`) types, offered after [`ChartType::MAIN`] in chart menus. + pub const OFFICE_2016: [ChartType; 7] = [ + ChartType::Waterfall, + ChartType::Funnel, + ChartType::Histogram, + ChartType::Pareto, + ChartType::BoxWhisker, + ChartType::Treemap, + ChartType::Sunburst, + ]; + + /// Whether the type is saved as a `chartEx` part (with a plain chart as the fallback). + pub fn is_chartex(self) -> bool { + Self::OFFICE_2016.contains(&self) + } + pub const MAIN: [ChartType; 12] = [ ChartType::Column, ChartType::Bar, @@ -190,6 +206,13 @@ pub struct Chart { /// the running total, instead of floating. #[serde(skip_serializing_if = "Vec::is_empty")] pub subtotals: Vec, + /// A histogram's bin count (`cx:binCount`); with neither this nor `bin_size` the bins are + /// automatic (Scott's rule, as Excel does). + #[serde(skip_serializing_if = "Option::is_none")] + pub bin_count: Option, + /// A histogram's bin width (`cx:binSize`). + #[serde(skip_serializing_if = "Option::is_none")] + pub bin_size: Option, /// Original chart XML part kept for round-trip of unmodelled options. #[serde(skip_serializing_if = "Option::is_none")] pub raw: Option, @@ -230,6 +253,8 @@ impl Default for Chart { y2_hidden: false, lang: None, subtotals: vec![], + bin_count: None, + bin_size: None, raw: None, } } @@ -241,6 +266,27 @@ impl Chart { let cats = ["Q1", "Q2", "Q3", "Q4"]; let data = [[4.2, 2.6, 2.1], [2.8, 4.1, 2.4], [3.6, 1.9, 3.3], [4.7, 3.0, 5.1]]; let mut c = Chart { kind, categories: cats.iter().map(|s| s.to_string()).collect(), ..Default::default() }; + if matches!(kind, ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) { + // These plot raw observations: a histogram bins one series, a box chart summarises + // each category's points. + let obs = [12.0, 15.0, 17.0, 18.0, 21.0, 22.0, 22.0, 24.0, 25.0, 27.0, 28.0, 31.0, 33.0, 36.0, 41.0, 48.0]; + let n_series = if kind == ChartType::BoxWhisker { 2 } else { 1 }; + c.categories = if kind == ChartType::BoxWhisker { + (0..obs.len()).map(|i| if i % 2 == 0 { "Group A" } else { "Group B" }.to_string()).collect() + } else { + vec![] + }; + for s in 0..n_series { + c.series.push(Series { + name: format!("Series {}", s + 1), + values: obs.iter().map(|v| Some(v + s as f64 * 6.0)).collect(), + ..Default::default() + }); + } + c.gridlines = true; + c.legend = (kind == ChartType::BoxWhisker).then(|| "b".to_string()); + return c; + } let n_series = if matches!(kind, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Waterfall | ChartType::Treemap) { 1 } else { 3 }; for s in 0..n_series { @@ -544,3 +590,262 @@ mod tests { assert_eq!(Chart::sample(ChartType::Pie).series.len(), 1); } } + +/// One histogram bin: `[lo, hi]` (the first bin is closed on both sides, later ones open on the +/// left, as Excel bins) and how many observations fall in it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Bin { + pub lo: f64, + pub hi: f64, + pub count: usize, +} + +/// Bins a histogram has at most (a hostile bin size cannot make millions). +pub const MAX_BINS: usize = 1000; + +/// A box and whisker summary of one group: quartiles by Excel's exclusive method, whiskers at +/// the furthest points within 1.5 IQR of the box, and the points beyond them. +#[derive(Clone, Debug, PartialEq)] +pub struct BoxStats { + pub low: f64, + pub q1: f64, + pub median: f64, + pub q3: f64, + pub high: f64, + pub mean: f64, + pub outliers: Vec, +} + +impl Chart { + /// The first series' finite observations. + fn observations(&self) -> Vec { + self.series.first().map(|s| s.values.iter().flatten().copied().filter(|v| v.is_finite()).collect()).unwrap_or_default() + } + + /// The histogram's bins over the first series: by `bin_size`, else `bin_count`, else + /// Scott's rule (3.49·σ·n^(-1/3)). Empty without observations. + pub fn histogram_bins(&self) -> Vec { + let mut v = self.observations(); + if v.is_empty() { + return vec![]; + } + v.sort_by(f64::total_cmp); + let (lo, hi) = (v[0], v[v.len() - 1]); + let span = hi - lo; + let n = v.len() as f64; + let width = if let Some(w) = self.bin_size.filter(|w| w.is_finite() && *w > 0.0) { + w + } else if let Some(k) = self.bin_count.filter(|k| *k > 0) { + span / f64::from(k) + } else { + let mean = v.iter().sum::() / n; + let sd = (v.iter().map(|x| (x - mean).powi(2)).sum::() / (n - 1.0).max(1.0)).sqrt(); + 3.49 * sd / n.cbrt() + }; + let count = if span <= 0.0 || !width.is_finite() || width <= 0.0 { 1 } else { ((span / width).ceil() as usize).clamp(1, MAX_BINS) }; + let width = if count == 1 && (span <= 0.0 || !width.is_finite() || width <= 0.0) { span.max(1.0) } else { width.max(span / MAX_BINS as f64) }; + let mut bins: Vec = (0..count).map(|i| Bin { lo: lo + width * i as f64, hi: lo + width * (i + 1) as f64, count: 0 }).collect(); + for x in v { + // (lo, hi] bins, the first one also holding the minimum. + let i = (((x - lo) / width).ceil() as usize).saturating_sub(1).min(count - 1); + if let Some(b) = bins.get_mut(i) { + b.count += 1; + } + } + bins + } + + /// A histogram (or Pareto) as the chart that draws it: a column chart of the bin counts with + /// touching columns, labelled by bin range. A Pareto sorts the bins by count and adds the + /// cumulative percentage as a line on a 0–100 % secondary axis. + pub fn histogram_view(&self) -> Chart { + let mut bins = self.histogram_bins(); + let pareto = self.kind == ChartType::Pareto; + if pareto { + bins.sort_by_key(|b| std::cmp::Reverse(b.count)); + } + let fmt = |v: f64| { + let r = (v * 100.0).round() / 100.0; + if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") } + }; + let first_lo = bins.iter().map(|b| b.lo).fold(f64::INFINITY, f64::min); + let mut v = Chart { + kind: if pareto { ChartType::Combo } else { ChartType::Column }, + categories: bins + .iter() + .map(|b| if b.lo <= first_lo { format!("[{}, {}]", fmt(b.lo), fmt(b.hi)) } else { format!("({}, {}]", fmt(b.lo), fmt(b.hi)) }) + .collect(), + series: vec![], + gap_width: 0.0, + raw: None, + subtotals: vec![], + ..self.clone() + }; + let name = self.series.first().map(|s| s.name.clone()).unwrap_or_default(); + let base = self.series.first().cloned().unwrap_or_default(); + v.series.push(Series { + name, + values: bins.iter().map(|b| Some(b.count as f64)).collect(), + kind: pareto.then_some(ChartType::Column), + number_format: None, + ..base + }); + if pareto { + let total = bins.iter().map(|b| b.count).sum::().max(1) as f64; + let mut run = 0.0; + v.series.push(Series { + name: "Cumulative %".into(), + values: bins + .iter() + .map(|b| { + run += b.count as f64; + Some(run / total) + }) + .collect(), + kind: Some(ChartType::Line), + secondary_axis: true, + ..Default::default() + }); + v.y2_min = Some(0.0); + v.y2_max = Some(1.0); + v.y2_major = Some(0.2); + v.y2_format = Some("0%".into()); + } + v.vary_colors = false; + v + } + + /// The box chart's groups (distinct category labels in order of appearance; one unnamed + /// group without categories) and, per series, each group's summary (None when it has no + /// points). + pub fn box_groups(&self) -> (Vec, Vec>>) { + let mut groups: Vec = Vec::new(); + for c in &self.categories { + if !groups.contains(c) { + groups.push(c.clone()); + } + } + if groups.is_empty() { + groups.push(String::new()); + } + let stats = self + .series + .iter() + .map(|s| { + groups + .iter() + .map(|g| { + let pts: Vec = s + .values + .iter() + .enumerate() + .filter(|(i, _)| self.categories.get(*i).map_or(g.is_empty() || self.categories.is_empty(), |c| c == g)) + .filter_map(|(_, v)| *v) + .filter(|v| v.is_finite()) + .collect(); + box_stats(pts) + }) + .collect() + }) + .collect(); + (groups, stats) + } +} + +/// Excel's QUARTILE.EXC position `p·(n+1)` (1-based) on sorted data, clamped to the ends. +fn quantile_exc(v: &[f64], p: f64) -> f64 { + let n = v.len(); + let pos = (p * (n as f64 + 1.0)).clamp(1.0, n as f64); + let i = pos.floor() as usize; + let frac = pos - i as f64; + let a = v[i.saturating_sub(1).min(n - 1)]; + let b = v[i.min(n - 1)]; + a + (b - a) * frac +} + +/// The summary of one group's points. +pub fn box_stats(mut pts: Vec) -> Option { + if pts.is_empty() { + return None; + } + pts.sort_by(f64::total_cmp); + let (q1, median, q3) = (quantile_exc(&pts, 0.25), quantile_exc(&pts, 0.5), quantile_exc(&pts, 0.75)); + let iqr = q3 - q1; + let (lo_fence, hi_fence) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr); + let inside: Vec = pts.iter().copied().filter(|v| *v >= lo_fence && *v <= hi_fence).collect(); + let low = inside.first().copied().unwrap_or(q1).min(q1); + let high = inside.last().copied().unwrap_or(q3).max(q3); + let mean = pts.iter().sum::() / pts.len() as f64; + let outliers = pts.iter().copied().filter(|v| *v < lo_fence || *v > hi_fence).collect(); + Some(BoxStats { low, q1, median, q3, high, mean, outliers }) +} + +#[cfg(test)] +mod chartex_tests { + use super::*; + + fn obs(v: &[f64]) -> Chart { + Chart { + kind: ChartType::Histogram, + series: vec![Series { values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }], + ..Default::default() + } + } + + #[test] + fn histogram_bins_by_count_size_and_scott() { + let mut c = obs(&[1.0, 2.0, 2.0, 3.0, 4.0, 5.0, 9.0]); + c.bin_count = Some(4); + let b = c.histogram_bins(); + assert_eq!(b.len(), 4); + assert_eq!((b[0].lo, b[0].hi), (1.0, 3.0)); + // (lo, hi] bins with the minimum in the first: 1, 2, 2, 3 | 4, 5 | - | 9. + assert_eq!(b.iter().map(|b| b.count).collect::>(), vec![4, 2, 0, 1]); + c.bin_count = None; + c.bin_size = Some(5.0); + assert_eq!(c.histogram_bins().iter().map(|b| b.count).collect::>(), vec![6, 1]); + c.bin_size = None; + let auto = c.histogram_bins(); + assert!(!auto.is_empty() && auto.iter().map(|b| b.count).sum::() == 7); + // A tiny bin size is capped; one value gives one bin. + c.bin_size = Some(1e-12); + assert!(c.histogram_bins().len() <= MAX_BINS); + assert_eq!(obs(&[3.0]).histogram_bins(), vec![Bin { lo: 3.0, hi: 4.0, count: 1 }]); + assert!(obs(&[]).histogram_bins().is_empty()); + } + + #[test] + fn pareto_view_sorts_bins_and_adds_the_cumulative_line() { + let mut c = obs(&[1.0, 5.0, 5.0, 5.0, 9.0, 9.0]); + c.kind = ChartType::Pareto; + c.bin_count = Some(3); + let v = c.histogram_view(); + assert_eq!(v.kind, ChartType::Combo); + assert_eq!(v.series[0].values, vec![Some(3.0), Some(2.0), Some(1.0)]); + let cum = &v.series[1]; + assert!(cum.secondary_axis && cum.kind == Some(ChartType::Line)); + assert_eq!(cum.values.last(), Some(&Some(1.0))); + assert_eq!(v.categories[0], "(3.67, 6.33]"); + } + + #[test] + fn box_stats_use_exclusive_quartiles_and_fence_outliers() { + let b = box_stats(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0]).unwrap(); + // QUARTILE.EXC on 8 points: positions 2.25, 4.5, 6.75. + assert_eq!((b.q1, b.median, b.q3), (2.25, 4.5, 6.75)); + assert_eq!(b.outliers, vec![100.0]); + assert_eq!((b.low, b.high), (1.0, 7.0)); + assert!(box_stats(vec![]).is_none()); + let one = box_stats(vec![5.0]).unwrap(); + assert_eq!((one.low, one.q1, one.median, one.q3, one.high), (5.0, 5.0, 5.0, 5.0, 5.0)); + } + + #[test] + fn box_groups_follow_category_labels() { + let c = Chart::sample(ChartType::BoxWhisker); + let (groups, stats) = c.box_groups(); + assert_eq!(groups, vec!["Group A".to_string(), "Group B".to_string()]); + assert_eq!(stats.len(), 2); + assert!(stats.iter().all(|s| s.len() == 2 && s.iter().all(Option::is_some))); + } +} diff --git a/crates/pptx/src/read/chartex.rs b/crates/pptx/src/read/chartex.rs index 1207df02..5c6e2f77 100644 --- a/crates/pptx/src/read/chartex.rs +++ b/crates/pptx/src/read/chartex.rs @@ -110,6 +110,10 @@ pub(crate) fn chartex_from(root: &El) -> Option { if c.categories.is_empty() { c.categories = d.categories.clone(); } + if let Some(b) = s.child("layoutPr").and_then(|l| l.child("binning")) { + c.bin_count = b.child("binCount").and_then(|n| n.u32("val")).filter(|n| *n > 0); + c.bin_size = b.child("binSize").and_then(|n| n.attr("val")).and_then(|v| v.parse::().ok()).filter(|v| v.is_finite() && *v > 0.0); + } if let Some(st) = s.child("layoutPr").and_then(|l| l.child("subtotals")) { c.subtotals = st.children_named("idx").filter_map(|i| i.u32("val")).map(|i| i as usize).take(MAX_POINTS).collect(); } diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 98ac50cb..172acf49 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -365,9 +365,14 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)]) w.close(tag); } -/// The kept `chartEx` part of an Office 2016 chart (waterfall, funnel…) that is unchanged since -/// it was read, without its workbook link; `None` when it was edited or isn't one. +/// The `chartEx` part of an Office 2016 chart (waterfall, funnel…): the kept part when the chart +/// is unchanged since it was read (without its workbook link), else one written from the model. +/// `None` for other chart types. pub fn chartex_xml(c: &Chart) -> Option> { + kept_chartex(c).or_else(|| c.kind.is_chartex().then(|| build_chartex(c))) +} + +fn kept_chartex(c: &Chart) -> Option> { let raw = c.raw.as_ref()?; let doc = crate::xml::parse(raw.as_bytes()).ok()?; let mut root = doc.root.clone(); @@ -502,3 +507,145 @@ pub fn chart_xml(c: &Chart) -> Vec { w.close("c:chartSpace"); w.finish() } + +/// A `cx:tx` holding plain text. +fn cx_text(w: &mut W, t: &str) { + w.open0("cx:tx"); + w.open0("cx:txData"); + w.open0("cx:v"); + w.text(t); + w.close("cx:v"); + w.close("cx:txData"); + w.close("cx:tx"); +} + +/// A `cx:lvl` of points. +fn cx_level(w: &mut W, pts: &[String], format: Option<&str>) { + let mut a = A::new().a("ptCount", pts.len()); + if let Some(f) = format { + a = a.a("formatCode", f); + } + w.open("cx:lvl", a); + for (i, p) in pts.iter().enumerate() { + w.open("cx:pt", A::new().a("idx", i)); + w.text(p); + w.close("cx:pt"); + } + w.close("cx:lvl"); +} + +/// Writes an Office 2016 chart from the model, as PowerPoint lays the part out: one `cx:data` +/// per series (categories and values), then the plot area with each series' layout, the axes +/// the layout uses, the title and the legend. +pub fn build_chartex(c: &Chart) -> Vec { + let mut w = W::new(); + w.open("cx:chartSpace", A::new().a("xmlns:a", NS_A).a("xmlns:r", NS_R).a("xmlns:cx", NS_CX)); + w.open0("cx:chartData"); + for (i, s) in c.series.iter().enumerate() { + w.open("cx:data", A::new().a("id", i)); + if !c.categories.is_empty() { + w.open("cx:strDim", A::new().a("type", "cat")); + cx_level(&mut w, &c.categories, None); + w.close("cx:strDim"); + } + let ty = if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) { "size" } else { "val" }; + w.open("cx:numDim", A::new().a("type", ty)); + let vals: Vec = s.values.iter().map(|v| v.map(fmt_num).unwrap_or_default()).collect(); + cx_level(&mut w, &vals, Some(s.number_format.as_deref().unwrap_or("General"))); + w.close("cx:numDim"); + w.close("cx:data"); + } + w.close("cx:chartData"); + w.open0("cx:chart"); + if let Some(t) = &c.title { + w.open("cx:title", A::new().a("pos", "t").a("align", "ctr").a("overlay", "0")); + cx_text(&mut w, t); + w.close("cx:title"); + } + w.open0("cx:plotArea"); + w.open0("cx:plotAreaRegion"); + let layout = match c.kind { + ChartType::Waterfall => "waterfall", + ChartType::Funnel => "funnel", + ChartType::Treemap => "treemap", + ChartType::Sunburst => "sunburst", + ChartType::BoxWhisker => "boxWhisker", + _ => "clusteredColumn", + }; + let one = !matches!(c.kind, ChartType::BoxWhisker); + for (i, s) in c.series.iter().enumerate().take(if one { 1 } else { usize::MAX }) { + w.open("cx:series", A::new().a("layoutId", layout).a("uniqueId", format!("{{DC00000{i}-0000-4000-8000-000000000000}}"))); + cx_text(&mut w, &s.name); + if s.data_labels.unwrap_or(c.data_labels) { + w.open("cx:dataLabels", A::new().a("pos", if c.kind == ChartType::Funnel { "ctr" } else { "outEnd" })); + w.empty("cx:visibility", A::new().a("seriesName", "0").a("categoryName", "0").a("value", "1")); + w.close("cx:dataLabels"); + } + w.empty("cx:dataId", A::new().a("val", i)); + match c.kind { + ChartType::Waterfall if !c.subtotals.is_empty() => { + w.open0("cx:layoutPr"); + w.open0("cx:subtotals"); + for t in &c.subtotals { + w.empty("cx:idx", A::new().a("val", t)); + } + w.close("cx:subtotals"); + w.close("cx:layoutPr"); + } + ChartType::Histogram | ChartType::Pareto => { + w.open0("cx:layoutPr"); + w.open("cx:binning", A::new().a("intervalClosed", "r")); + if let Some(size) = c.bin_size { + w.empty("cx:binSize", A::new().a("val", fmt_num(size))); + } else if let Some(n) = c.bin_count { + w.empty("cx:binCount", A::new().a("val", n)); + } + w.close("cx:binning"); + w.close("cx:layoutPr"); + } + ChartType::BoxWhisker => { + w.open0("cx:layoutPr"); + w.empty("cx:visibility", A::new().a("meanLine", "0").a("meanMarker", "1").a("nonoutliers", "0").a("outliers", "1")); + w.empty("cx:statistics", A::new().a("quartileMethod", "exclusive")); + w.close("cx:layoutPr"); + } + _ => {} + } + w.close("cx:series"); + } + if c.kind == ChartType::Pareto { + w.open("cx:series", A::new().a("layoutId", "paretoLine").a("ownerIdx", "0").a("uniqueId", "{DC0000FF-0000-4000-8000-000000000000}")); + w.empty("cx:axisId", A::new().a("val", "2")); + w.close("cx:series"); + } + w.close("cx:plotAreaRegion"); + if !matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) { + w.open("cx:axis", A::new().a("id", "0")); + w.empty("cx:catScaling", A::new().a("gapWidth", if matches!(c.kind, ChartType::Histogram | ChartType::Pareto) { "0" } else { "0.5" })); + w.empty0("cx:tickLabels"); + w.close("cx:axis"); + if c.kind != ChartType::Funnel { + w.open("cx:axis", A::new().a("id", "1")); + w.empty0("cx:valScaling"); + if c.gridlines { + w.empty0("cx:majorGridlines"); + } + w.empty0("cx:tickLabels"); + w.close("cx:axis"); + } + if c.kind == ChartType::Pareto { + w.open("cx:axis", A::new().a("id", "2")); + w.empty("cx:valScaling", A::new().a("max", "1").a("min", "0")); + w.empty0("cx:units"); + w.empty0("cx:tickLabels"); + w.close("cx:axis"); + } + } + w.close("cx:plotArea"); + if let Some(pos) = &c.legend { + w.empty("cx:legend", A::new().a("pos", pos.as_str()).a("align", "ctr").a("overlay", "0")); + } + w.close("cx:chart"); + w.close("cx:chartSpace"); + w.finish() +} diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index 58d79b6f..addcb39f 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -1095,7 +1095,8 @@ fn chartex_waterfall_reads_and_round_trips() { let again = deckcraft_pptx::import(&out).unwrap(); assert_eq!(chart(&again).map(|c| (c.kind, c.categories, c.series.len())), Some((ChartType::Waterfall, c.categories.clone(), 1))); - // Edited: the kept part no longer matches, so only a plain chart is written. + // Edited: the kept part no longer matches, so a chartEx part is written from the model, + // still a waterfall with the new value. let mut edited = doc.clone(); for sl in &mut edited.slides { for sh in &mut Arc::make_mut(sl).shapes { @@ -1105,6 +1106,57 @@ fn chartex_waterfall_reads_and_round_trips() { } } let out = deckcraft_pptx::export(&edited).unwrap(); - assert!(pkg_part(&out, "ppt/charts/chartEx1.xml").is_none()); - assert!(!String::from_utf8(pkg_part(&out, &slide).unwrap()).unwrap().contains("cx1")); + let ex = String::from_utf8(pkg_part(&out, "ppt/charts/chartEx1.xml").expect("a written chartEx part")).unwrap(); + assert!(ex.contains(r#"layoutId="waterfall""#), "{ex}"); + assert!(String::from_utf8(pkg_part(&out, &slide).unwrap()).unwrap().contains("cx1")); + let again = chart(&deckcraft_pptx::import(&out).unwrap()).unwrap(); + assert_eq!(again.kind, ChartType::Waterfall); + assert_eq!(again.series[0].values[1], Some(55.0)); + assert_eq!(again.subtotals, c.subtotals); +} + +/// New Office 2016 charts made in DeckCraft are written as chartEx parts that read back as the +/// same type, data and options. +#[test] +fn chartex_new_charts_save_as_their_own_type() { + use deckcraft_engine::Session; + for (ty, kind) in [ + ("waterfall", ChartType::Waterfall), + ("funnel", ChartType::Funnel), + ("treemap", ChartType::Treemap), + ("sunburst", ChartType::Sunburst), + ("histogram", ChartType::Histogram), + ("pareto", ChartType::Pareto), + ("boxwhisker", ChartType::BoxWhisker), + ] { + let chart = |d: &Presentation| -> Option { + d.slides.iter().flat_map(|s| s.shapes.iter()).find_map(|sh| match &sh.kind { + ShapeKind::Chart(c) => Some((**c).clone()), + _ => None, + }) + }; + let mut s = Session::with_new(); + s.execute("slide.new", &serde_json::json!({"layout": "blank"})).unwrap(); + s.execute("insert.chart", &serde_json::json!({"type": ty})).unwrap(); + let id = s.doc().unwrap().selection.shapes[0].0; + let opts = match kind { + ChartType::Histogram | ChartType::Pareto => serde_json::json!({"id": id, "binCount": 4, "title": "T"}), + ChartType::Waterfall => serde_json::json!({"id": id, "subtotals": [3], "title": "T"}), + _ => serde_json::json!({"id": id, "title": "T"}), + }; + s.execute("chart.options", &opts).unwrap(); + let doc = &s.doc().unwrap().doc; + let before = chart(doc).unwrap(); + let out = deckcraft_pptx::export(doc).unwrap(); + assert!(pkg_part(&out, "ppt/charts/chartEx1.xml").is_some(), "{ty}: no chartEx part"); + assert!(pkg_part(&out, "ppt/charts/chart1.xml").is_some(), "{ty}: no fallback chart"); + let after = chart(&deckcraft_pptx::import(&out).unwrap()).unwrap(); + assert_eq!(after.kind, kind, "{ty}"); + assert_eq!(after.title.as_deref(), Some("T"), "{ty}"); + assert_eq!(after.categories, before.categories, "{ty}"); + let n = if matches!(kind, ChartType::BoxWhisker) { before.series.len() } else { 1 }; + assert_eq!(after.series.len(), n, "{ty}"); + assert_eq!(after.series[0].values, before.series[0].values, "{ty}"); + assert_eq!((after.bin_count, after.subtotals.clone()), (before.bin_count, before.subtotals.clone()), "{ty}"); + } } diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index 5df5dd7e..82d1116d 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -134,6 +134,19 @@ fn legend_layout(pos: &str, size: (f64, f64), area: Rect, base: f64, overlay: bo } pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64) { + match c.kind { + // Histograms and Pareto charts are drawn as the column (and line) chart of their bins. + ChartType::Histogram | ChartType::Pareto => draw_inner(ctx, rctx, &c.histogram_view(), m, w, h, None), + ChartType::BoxWhisker => { + let (view, boxes) = crate::chartex::box_view(c); + draw_inner(ctx, rctx, &view, m, w, h, Some(&boxes)); + } + _ => draw_inner(ctx, rctx, c, m, w, h, None), + } +} + +#[allow(clippy::too_many_arguments)] +fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64, boxes: Option<&crate::chartex::Boxes>) { let text_col = c.text_color.as_ref().map(|t| rctx.color(t, None)).unwrap_or(Rgba::rgb(0x59, 0x59, 0x59)); let grid_col = Rgba::rgb(0xD9, 0xD9, 0xD9); if let Some(f) = &c.chart_fill { @@ -400,6 +413,10 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h ); } } + if let Some(b) = boxes { + crate::chartex::draw_boxes(ctx, rctx, c, b, m, plot, band, horiz, &to_v); + return; + } let zero = to_v(0.0f64.clamp(min, max)); if let Some(bk) = bar_kind { let bars: Vec<(usize, &deckcraft_model::chart::Series)> = diff --git a/crates/render/src/chartex.rs b/crates/render/src/chartex.rs index faf8ec2e..04d0ab1f 100644 --- a/crates/render/src/chartex.rs +++ b/crates/render/src/chartex.rs @@ -1,8 +1,9 @@ -//! Office 2016 chart layouts the bar renderer can't draw: funnel, treemap and sunburst -//! (waterfall bars are drawn by [`crate::chart`]). +//! Office 2016 chart layouts the bar renderer can't draw: funnel, treemap, sunburst and the +//! boxes of a box and whisker chart (waterfall bars are drawn by [`crate::chart`], histograms as +//! the column chart of their bins). use deckcraft_color::Rgba; -use deckcraft_model::chart::{Chart, ChartType}; +use deckcraft_model::chart::{BoxStats, Chart, ChartType}; use deckcraft_model::resolve::Ctx; use deckcraft_model::text::{Align, Anchor}; use kurbo::{Affine, BezPath, Point, Rect, Shape as _}; @@ -188,6 +189,102 @@ fn sunburst(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec } } +/// A box chart's summaries: per series, per group. +pub struct Boxes { + pub stats: Vec>>, +} + +/// A box and whisker chart as the frame the bar renderer draws (value axis, gridlines, one +/// category per group, legend by series), and the boxes to draw in it. The frame's values are +/// each group's extremes, so the value axis covers every whisker and outlier. +pub fn box_view(c: &Chart) -> (Chart, Boxes) { + let (groups, stats) = c.box_groups(); + let mut v = c.clone(); + v.kind = ChartType::Column; + v.categories = groups; + v.raw = None; + let ext = |b: &BoxStats, hi: bool| { + let o = b.outliers.iter().copied(); + if hi { o.fold(b.high, f64::max) } else { o.fold(b.low, f64::min) } + }; + let lowest = stats.iter().flatten().flatten().map(|b| ext(b, false)).fold(f64::INFINITY, f64::min); + for (s, st) in v.series.iter_mut().zip(&stats) { + s.values = st.iter().map(|b| b.as_ref().map(|b| ext(b, true))).collect(); + } + if lowest.is_finite() && lowest < 0.0 && v.y_min.is_none() { + // The frame's values are the highs; below zero the axis must reach the lowest point too. + let mag = 10f64.powf(lowest.abs().log10().floor()); + v.y_min = Some((lowest / mag).floor() * mag); + } + (v, Boxes { stats }) +} + +/// Boxes from the first to the third quartile with the median line and a mean marker, whiskers +/// to the furthest points within 1.5 IQR, and outliers as dots; series side by side in each +/// group's band. +#[allow(clippy::too_many_arguments)] +pub fn draw_boxes( + ctx: &mut RenderContext, + rctx: &Ctx, + c: &Chart, + b: &Boxes, + m: Affine, + plot: Rect, + band: f64, + horiz: bool, + to_v: &dyn Fn(f64) -> f64, +) { + let ns = b.stats.len().max(1); + let group = band / (1.0 + c.gap_width.clamp(0.0, 5.0)); + let w = group / ns as f64; + let line = Rgba::rgb(0x40, 0x40, 0x40); + for (si, per) in b.stats.iter().enumerate() { + let fill = series_color(rctx, c, si); + for (gi, st) in per.iter().enumerate() { + let Some(st) = st else { continue }; + let c0 = if horiz { plot.y0 } else { plot.x0 } + band * gi as f64 + (band - group) / 2.0 + w * si as f64; + let (a, z) = (c0 + w * 0.1, c0 + w * 0.9); + let mid = (a + z) / 2.0; + // Along the category axis `a..z`, along the value axis `to_v`. + let rect = |p: f64, q: f64, u: f64, v: f64| if horiz { Rect::new(u.min(v), p, u.max(v), q) } else { Rect::new(p, u.min(v), q, u.max(v)) }; + let pt = |along: f64, val: f64| if horiz { Point::new(val, along) } else { Point::new(along, val) }; + ctx.set_transform(m); + ctx.set_paint(color(fill, 1.0)); + let bx = rect(a, z, to_v(st.q1), to_v(st.q3)); + ctx.fill_rect(&bx); + let mut p = BezPath::new(); + // Whiskers with caps, and the median. + for (from, to) in [(st.q3, st.high), (st.q1, st.low)] { + p.move_to(pt(mid, to_v(from))); + p.line_to(pt(mid, to_v(to))); + p.move_to(pt(mid - w * 0.2, to_v(to))); + p.line_to(pt(mid + w * 0.2, to_v(to))); + } + p.move_to(pt(a, to_v(st.median))); + p.line_to(pt(z, to_v(st.median))); + ctx.set_paint(color(line, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.25)); + ctx.stroke_path(&p); + ctx.stroke_path(&bx.to_path(0.1)); + // The mean as an x. + let (mx, r) = (pt(mid, to_v(st.mean)), (w * 0.08).clamp(1.5, 5.0)); + let mut x = BezPath::new(); + x.move_to((mx.x - r, mx.y - r)); + x.line_to((mx.x + r, mx.y + r)); + x.move_to((mx.x - r, mx.y + r)); + x.line_to((mx.x + r, mx.y - r)); + ctx.stroke_path(&x); + for o in &st.outliers { + let q = pt(mid, to_v(*o)); + ctx.set_paint(color(fill, 1.0)); + ctx.fill_path(&kurbo::Circle::new(q, r).to_path(0.1)); + ctx.set_paint(color(line, 1.0)); + ctx.stroke_path(&kurbo::Circle::new(q, r).to_path(0.1)); + } + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 3b9e1a3a..7b179485 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1746,3 +1746,34 @@ fn office_2016_charts_draw_their_own_layouts() { assert!(covered > 1000, "{kind:?}: {covered} samples inked"); } } + +/// Histograms draw touching columns of their bins, Pareto charts add the cumulative line, and +/// box and whisker charts draw a box per group and series. +#[test] +fn histogram_pareto_and_box_charts_draw() { + let shot = |c: Chart| { + let sh = Shape { id: ShapeId(622), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }; + render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }) + }; + let inked = |img: &crate::Image| { + (0..540) + .step_by(6) + .flat_map(|y| (0..960).step_by(6).map(move |x| (x, y))) + .filter(|&(x, y)| { + let p = img.pixel(x, y); + p[0] < 200 || p[1] < 200 || p[2] < 200 + }) + .count() + }; + let mut empty = Chart::sample(ChartType::Histogram); + empty.series[0].values.clear(); + let blank = inked(&shot(empty)); + for kind in [ChartType::Histogram, ChartType::Pareto, ChartType::BoxWhisker] { + let img = shot(Chart::sample(kind)); + if let Ok(dir) = std::env::var("DECKCRAFT_CHART_SHOTS") { + let _ = std::fs::write(format!("{dir}/{kind:?}.png"), img.to_png()); + } + let n = inked(&img); + assert!(n > blank + 400, "{kind:?}: {n} inked samples (blank {blank})"); + } +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..386dd9db 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1046,8 +1046,13 @@ fn insert(app: &mut SlideApp, ui: &mut Ui) { big(app, ui, Icon::SmartArt, "SmartArt", "app.dialog", json!({"id": "smartart"})); let r = big_button(ui, Icon::Chart, "Chart", enabled(app, "insert.chart")); egui::Popup::menu(&r).show(|ui| { - for k in deckcraft_model::chart::ChartType::MAIN { - if ui.button(k.label()).clicked() { + for k in chart_menu_types() { + if k.is_none() { + ui.separator(); + } + if let Some(k) = k + && ui.button(k.label()).clicked() + { run(app, "insert.chart", json!({"type": format!("{k:?}")})); } } @@ -2304,7 +2309,11 @@ fn chart_design(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let r = big_button(ui, Icon::ChangeChartType, "Change\nChart Type", true); egui::Popup::menu(&r).show(|ui| { - for k in deckcraft_model::chart::ChartType::MAIN { + for k in chart_menu_types() { + let Some(k) = k else { + ui.separator(); + continue; + }; if ui.button(k.label()).clicked() { // Replace the chart's type keeping its data. if let Some(id) = app.session.active().and_then(|d| d.selection.shapes.first().copied()) { @@ -2420,3 +2429,13 @@ fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) { }; p.add(egui::Shape::line(pts, egui::Stroke::new(1.3, ink))); } + +/// The chart types in the Insert Chart and Change Chart Type menus: the main ones, a separator +/// (`None`), then the Office 2016 types not already listed. +fn chart_menu_types() -> Vec> { + use deckcraft_model::chart::ChartType; + let mut v: Vec> = ChartType::MAIN.iter().copied().map(Some).collect(); + v.push(None); + v.extend(ChartType::OFFICE_2016.iter().copied().filter(|k| !ChartType::MAIN.contains(k)).map(Some)); + v +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1e888c7a..7d0927f2 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -73,7 +73,7 @@ the round trip even though DeckCraft doesn't model it. | Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | | | Tables with styles | yes | yes | | | Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw` | -| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, box) | no | no | | +| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, Pareto, box) | yes | yes | Unchanged parts kept; edited and new ones written from the model with a plain-chart fallback. Only the innermost hierarchy level; no region maps | | SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save | | Equations (OMML, `a14:m`) | no | no | PR #55 open | | Audio / video (`p14:media`, trim, fades, bookmarks) | yes | partly | Playback options lost on save (#74) | diff --git a/docs/gaps.md b/docs/gaps.md index 10e2fa41..253022cd 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -146,10 +146,12 @@ Detail in [file-format-parity.md](file-format-parity.md). ### Charts: data grid, elements and the newer chart types - **Evidence:** checklist rows "Edit chart data" (P0, partial), "Chart elements" (partial), "Other chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, Pareto, - box & whisker) are read for their data and written back unchanged. Waterfalls (floating bars, - totals, connectors), funnels, treemaps (squarified) and sunbursts (innermost ring) are drawn; - histograms and box & whisker still use the column renderer, an edited one is saved as a plain - column chart, and region maps aren't read. + box & whisker) are read, drawn, inserted, edited and saved as `chartEx` (kept unchanged, else + written from the model, with a plain chart fallback). Waterfalls float and total, funnels, + treemaps (squarified) and sunbursts (innermost ring only) have their own layouts, histograms and + Paretos bin their data (count, width or Scott's rule), and box & whisker charts use exclusive + quartiles with 1.5 IQR whiskers. Multi-level treemap and sunburst hierarchies, inclusive + quartiles and region maps remain. - **Impact:** most business decks have charts; editing their data is routine. - **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h). From b3420a0b04f078d97f56f15e7e8a012d69579a62 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 01:16:09 +0300 Subject: [PATCH 06/27] Charts: multi-level treemaps and sunbursts; inclusive box quartiles Read, keep and write every cx:lvl of a chartEx category dimension, build a category tree from runs of parent labels, and draw treemaps as nested squarified groups with banners and sunbursts as one ring per level. Box & whisker honours quartileMethod="inclusive" (QUARTILE.INC) on import, render, save and the chart.options command. Co-authored-by: Cursor --- crates/engine/src/cmd/insert.rs | 19 +++- crates/model/src/chart.rs | 140 +++++++++++++++++++++++++++- crates/pptx/src/read/chartex.rs | 8 ++ crates/pptx/src/write/chart.rs | 5 +- crates/pptx/tests/roundtrip.rs | 37 ++++++++ crates/render/src/chart.rs | 3 +- crates/render/src/chartex.rs | 160 +++++++++++++++++++++++++++++++- crates/render/src/tests.rs | 26 ++++++ docs/file-format-parity.md | 2 +- docs/gaps.md | 8 +- 10 files changed, 394 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 810a96e2..8a0a7d28 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -64,7 +64,7 @@ pub fn specs() -> Vec { "Edit Data", ["Chart Design", "Data"], None, - "{categories?: [..], series?: [{name, values}], id?}", + "{categories?: [..], levels?: [[..]] (outer category levels, innermost first), series?: [{name, values}], id?}", has_selection, chart_data ), @@ -73,7 +73,7 @@ pub fn specs() -> Vec { "Chart Elements", ["Chart Design", "Chart Layouts"], None, - "{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], id?}", + "{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], quartiles?: exclusive|inclusive, id?}", has_selection, chart_options ), @@ -669,10 +669,21 @@ fn chart_data(s: &mut Session, p: &Value) -> Result { }) .collect() }); + let levels: Option>> = p.get("levels").and_then(Value::as_array).map(|a| { + a.iter() + .take(deckcraft_model::chart::MAX_LEVELS) + .map(|l| { + l.as_array().map(|v| v.iter().map(|x| x.as_str().map(String::from).unwrap_or_else(|| x.to_string())).collect()).unwrap_or_default() + }) + .collect() + }); with_chart(s, p, "chart.data", |c| { if let Some(cs) = &cats { c.categories = cs.clone(); } + if let Some(l) = &levels { + c.category_levels = l.clone(); + } if let Some(se) = &series { // Keep series formatting by position. let old = std::mem::take(&mut c.series); @@ -722,6 +733,10 @@ fn chart_options(s: &mut Session, p: &Value) -> Result { t.dedup(); c.subtotals = t; } + if let Some(q) = p.get("quartiles").and_then(Value::as_str) { + c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive"); + c.raw = None; + } if bin_count.is_some() || bin_size.is_some() || subtotals.is_some() { c.raw = None; } diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index 583a447b..54e28dcd 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -206,6 +206,13 @@ pub struct Chart { /// the running total, instead of floating. #[serde(skip_serializing_if = "Vec::is_empty")] pub subtotals: Vec, + /// Outer category levels of a treemap or sunburst, innermost first (the leaves' parents, + /// then theirs…), each with one label per category. A blank label continues the one above it. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub category_levels: Vec>, + /// Box and whisker quartiles by the inclusive method (QUARTILE.INC) instead of exclusive. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub inclusive_quartiles: bool, /// A histogram's bin count (`cx:binCount`); with neither this nor `bin_size` the bins are /// automatic (Scott's rule, as Excel does). #[serde(skip_serializing_if = "Option::is_none")] @@ -253,6 +260,8 @@ impl Default for Chart { y2_hidden: false, lang: None, subtotals: vec![], + category_levels: vec![], + inclusive_quartiles: false, bin_count: None, bin_size: None, raw: None, @@ -266,6 +275,15 @@ impl Chart { let cats = ["Q1", "Q2", "Q3", "Q4"]; let data = [[4.2, 2.6, 2.1], [2.8, 4.1, 2.4], [3.6, 1.9, 3.3], [4.7, 3.0, 5.1]]; let mut c = Chart { kind, categories: cats.iter().map(|s| s.to_string()).collect(), ..Default::default() }; + if matches!(kind, ChartType::Treemap | ChartType::Sunburst) { + // A two-level hierarchy: months inside quarters. + c.categories = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"].iter().map(|s| s.to_string()).collect(); + c.category_levels = vec![["Q1", "Q1", "Q1", "Q2", "Q2", "Q2", "Q3", "Q3"].iter().map(|s| s.to_string()).collect()]; + let v = [4.2, 2.8, 3.6, 4.7, 2.6, 4.1, 1.9, 3.0]; + c.series.push(Series { name: "Series 1".into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }); + c.vary_colors = true; + return c; + } if matches!(kind, ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) { // These plot raw observations: a histogram bins one series, a box chart summarises // each category's points. @@ -743,7 +761,7 @@ impl Chart { .filter_map(|(_, v)| *v) .filter(|v| v.is_finite()) .collect(); - box_stats(pts) + box_stats_by(pts, self.inclusive_quartiles) }) .collect() }) @@ -763,13 +781,29 @@ fn quantile_exc(v: &[f64], p: f64) -> f64 { a + (b - a) * frac } -/// The summary of one group's points. -pub fn box_stats(mut pts: Vec) -> Option { +/// Excel's QUARTILE.INC position `p·(n-1)` (0-based) on sorted data. +fn quantile_inc(v: &[f64], p: f64) -> f64 { + let n = v.len(); + let pos = (p * (n as f64 - 1.0)).clamp(0.0, (n - 1) as f64); + let i = pos.floor() as usize; + let a = v[i.min(n - 1)]; + let b = v[(i + 1).min(n - 1)]; + a + (b - a) * (pos - i as f64) +} + +/// The summary of one group's points, with exclusive quartiles. +pub fn box_stats(pts: Vec) -> Option { + box_stats_by(pts, false) +} + +/// The summary of one group's points; `inclusive` picks QUARTILE.INC over QUARTILE.EXC. +pub fn box_stats_by(mut pts: Vec, inclusive: bool) -> Option { if pts.is_empty() { return None; } pts.sort_by(f64::total_cmp); - let (q1, median, q3) = (quantile_exc(&pts, 0.25), quantile_exc(&pts, 0.5), quantile_exc(&pts, 0.75)); + let q = |p: f64| if inclusive { quantile_inc(&pts, p) } else { quantile_exc(&pts, p) }; + let (q1, median, q3) = (q(0.25), q(0.5), q(0.75)); let iqr = q3 - q1; let (lo_fence, hi_fence) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr); let inside: Vec = pts.iter().copied().filter(|v| *v >= lo_fence && *v <= hi_fence).collect(); @@ -780,6 +814,75 @@ pub fn box_stats(mut pts: Vec) -> Option { Some(BoxStats { low, q1, median, q3, high, mean, outliers }) } +/// A node of a treemap or sunburst hierarchy: its label, the total of the leaves under it, and +/// its children (none for a leaf, which names its category index). +#[derive(Clone, Debug, PartialEq)] +pub struct TreeNode { + pub name: String, + pub value: f64, + pub leaf: Option, + pub children: Vec, +} + +/// Levels of hierarchy a chart may have (a hostile file cannot nest deeper). +pub const MAX_LEVELS: usize = 16; + +impl Chart { + /// The first series as a hierarchy: top-level nodes, each holding the consecutive + /// categories that share its label, down to the leaves. Without outer levels every + /// category is a top-level leaf. Negative and missing values count as zero. + pub fn category_tree(&self) -> Vec { + let Some(s) = self.series.first() else { return vec![] }; + let n = self.categories.len().max(s.values.len()); + let levels: Vec<&Vec> = self.category_levels.iter().take(MAX_LEVELS).collect(); + // Each leaf's path from the top level down, blanks filled from the row above. + let mut last: Vec = vec![String::new(); levels.len()]; + let paths: Vec> = (0..n) + .map(|i| { + let mut path = Vec::with_capacity(levels.len() + 1); + for (d, lv) in levels.iter().enumerate().rev() { + let l = lv.get(i).map(|x| x.trim()).unwrap_or(""); + if !l.is_empty() { + last[d] = l.to_string(); + // A new outer label starts fresh inner groups. + for inner in last.iter_mut().take(d) { + inner.clear(); + } + } + path.push(last[d].clone()); + } + path.push(self.categories.get(i).cloned().unwrap_or_default()); + path + }) + .collect(); + let value = |i: usize| s.values.get(i).copied().flatten().filter(|v| v.is_finite()).unwrap_or(0.0).max(0.0); + fn build(rows: &[(usize, &[String])], value: &dyn Fn(usize) -> f64) -> Vec { + let mut out: Vec = Vec::new(); + let mut i = 0; + while i < rows.len() { + let (idx, path) = rows[i]; + if path.len() <= 1 { + out.push(TreeNode { name: path.first().cloned().unwrap_or_default(), value: value(idx), leaf: Some(idx), children: vec![] }); + i += 1; + continue; + } + let head = &path[0]; + let mut j = i; + while j < rows.len() && rows[j].1.len() > 1 && &rows[j].1[0] == head { + j += 1; + } + let sub: Vec<(usize, &[String])> = rows[i..j].iter().map(|(k, p)| (*k, &p[1..])).collect(); + let children = build(&sub, value); + out.push(TreeNode { name: head.clone(), value: children.iter().map(|c| c.value).sum(), leaf: None, children }); + i = j; + } + out + } + let rows: Vec<(usize, &[String])> = paths.iter().enumerate().map(|(i, p)| (i, p.as_slice())).collect(); + build(&rows, &value) + } +} + #[cfg(test)] mod chartex_tests { use super::*; @@ -840,6 +943,35 @@ mod chartex_tests { assert_eq!((one.low, one.q1, one.median, one.q3, one.high), (5.0, 5.0, 5.0, 5.0, 5.0)); } + #[test] + fn inclusive_quartiles_follow_quartile_inc() { + let b = box_stats_by(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0], true).unwrap(); + // QUARTILE.INC on 8 points: positions 1.75, 3.5, 5.25 (0-based). + assert_eq!((b.q1, b.median, b.q3), (2.75, 4.5, 6.25)); + } + + #[test] + fn category_tree_groups_runs_and_fills_blank_parents() { + let mut c = Chart::sample(ChartType::Treemap); + let t = c.category_tree(); + assert_eq!(t.iter().map(|n| n.name.as_str()).collect::>(), ["Q1", "Q2", "Q3"]); + assert_eq!(t[0].children.len(), 3); + assert_eq!(t[0].children[1].leaf, Some(1)); + assert!((t[0].value - (4.2 + 2.8 + 3.6)).abs() < 1e-9); + // Blank parents continue the label above; two outer levels nest. + c.category_levels = vec![ + vec!["a".into(), "".into(), "b".into(), "".into(), "c".into(), "".into(), "".into(), "".into()], + vec!["H1".into(), "".into(), "".into(), "".into(), "H2".into(), "".into(), "".into(), "".into()], + ]; + let t = c.category_tree(); + assert_eq!(t.len(), 2); + assert_eq!(t[0].children.iter().map(|n| n.name.as_str()).collect::>(), ["a", "b"]); + assert_eq!(t[1].children[0].children.len(), 4); + // No levels: every category is a leaf. + c.category_levels.clear(); + assert!(c.category_tree().iter().all(|n| n.leaf.is_some())); + } + #[test] fn box_groups_follow_category_labels() { let c = Chart::sample(ChartType::BoxWhisker); diff --git a/crates/pptx/src/read/chartex.rs b/crates/pptx/src/read/chartex.rs index 5c6e2f77..339d26c3 100644 --- a/crates/pptx/src/read/chartex.rs +++ b/crates/pptx/src/read/chartex.rs @@ -14,6 +14,8 @@ const MAX_POINTS: usize = 100_000; #[derive(Default)] struct Data { categories: Vec, + /// Outer category levels, innermost first. + levels: Vec>, numbers: HashMap>, Option)>, } @@ -45,6 +47,8 @@ fn data(d: &El) -> Data { if dim.is("strDim") || (dim.is("numDim") && ty == "cat") { if ty == "cat" || out.categories.is_empty() { out.categories = points(lvl); + // Further levels are the hierarchy's parents, innermost first. + out.levels = dim.children_named("lvl").skip(1).take(deckcraft_model::chart::MAX_LEVELS).map(points).collect(); } } else if dim.is("numDim") { let vals = points(lvl).into_iter().map(|s| s.trim().parse::().ok().filter(|v| v.is_finite())).collect(); @@ -109,6 +113,10 @@ pub(crate) fn chartex_from(root: &El) -> Option { ["val", "size"].iter().find_map(|t| d.numbers.get(*t)).or_else(|| d.numbers.values().next()).cloned().unwrap_or_default(); if c.categories.is_empty() { c.categories = d.categories.clone(); + c.category_levels = d.levels.clone(); + } + if let Some(st) = s.child("layoutPr").and_then(|l| l.child("statistics")) { + c.inclusive_quartiles = st.attr("quartileMethod") == Some("inclusive"); } if let Some(b) = s.child("layoutPr").and_then(|l| l.child("binning")) { c.bin_count = b.child("binCount").and_then(|n| n.u32("val")).filter(|n| *n > 0); diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 172acf49..e0e3743e 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -546,6 +546,9 @@ pub fn build_chartex(c: &Chart) -> Vec { if !c.categories.is_empty() { w.open("cx:strDim", A::new().a("type", "cat")); cx_level(&mut w, &c.categories, None); + for lv in &c.category_levels { + cx_level(&mut w, lv, None); + } w.close("cx:strDim"); } let ty = if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) { "size" } else { "val" }; @@ -606,7 +609,7 @@ pub fn build_chartex(c: &Chart) -> Vec { ChartType::BoxWhisker => { w.open0("cx:layoutPr"); w.empty("cx:visibility", A::new().a("meanLine", "0").a("meanMarker", "1").a("nonoutliers", "0").a("outliers", "1")); - w.empty("cx:statistics", A::new().a("quartileMethod", "exclusive")); + w.empty("cx:statistics", A::new().a("quartileMethod", if c.inclusive_quartiles { "inclusive" } else { "exclusive" })); w.close("cx:layoutPr"); } _ => {} diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index addcb39f..ae144dd0 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -1160,3 +1160,40 @@ fn chartex_new_charts_save_as_their_own_type() { assert_eq!((after.bin_count, after.subtotals.clone()), (before.bin_count, before.subtotals.clone()), "{ty}"); } } + +/// A two-level treemap keeps its parent level, and a box chart its inclusive quartiles, through +/// a save and reopen. +#[test] +fn chartex_levels_and_quartiles_round_trip() { + use deckcraft_engine::Session; + let chart = |d: &Presentation| -> Option { + d.slides.iter().flat_map(|s| s.shapes.iter()).find_map(|sh| match &sh.kind { + ShapeKind::Chart(c) => Some((**c).clone()), + _ => None, + }) + }; + for ty in ["treemap", "sunburst", "boxwhisker"] { + let mut s = Session::with_new(); + s.execute("slide.new", &serde_json::json!({"layout": "blank"})).unwrap(); + s.execute("insert.chart", &serde_json::json!({"type": ty})).unwrap(); + let id = s.doc().unwrap().selection.shapes[0].0; + if ty == "boxwhisker" { + s.execute("chart.options", &serde_json::json!({"id": id, "quartiles": "inclusive"})).unwrap(); + } else { + s.execute( + "chart.data", + &serde_json::json!({"id": id, "levels": [["Q1", "", "", "Q2", "", "", "Q3", ""], ["H1", "", "", "", "", "", "H2", ""]]}), + ) + .unwrap(); + } + let before = chart(&s.doc().unwrap().doc).unwrap(); + let out = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap(); + let after = chart(&deckcraft_pptx::import(&out).unwrap()).unwrap(); + assert_eq!(after.category_levels, before.category_levels, "{ty}"); + assert_eq!(after.inclusive_quartiles, before.inclusive_quartiles, "{ty}"); + assert_eq!(after.category_tree(), before.category_tree(), "{ty}"); + // Unchanged since import, the part is written back as read. + let again = deckcraft_pptx::export(&deckcraft_pptx::import(&out).unwrap()).unwrap(); + assert_eq!(chart(&deckcraft_pptx::import(&again).unwrap()).unwrap().category_levels, before.category_levels, "{ty}"); + } +} diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index 82d1116d..5cfeb691 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -178,7 +178,8 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, let legend_items: Vec<(String, Rgba)> = if waterfall { ["Increase", "Decrease", "Total"].iter().enumerate().map(|(i, n)| (n.to_string(), series_color(rctx, c, i))).collect() } else if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) { - c.categories.iter().enumerate().map(|(i, n)| (n.clone(), series_color(rctx, c, i))).collect() + // A hierarchy's legend names its top-level groups. + c.category_tree().iter().enumerate().map(|(i, n)| (n.name.clone(), series_color(rctx, c, i))).collect() } else if c.kind == ChartType::Funnel { vec![] } else if pie { diff --git a/crates/render/src/chartex.rs b/crates/render/src/chartex.rs index 04d0ab1f..9e988351 100644 --- a/crates/render/src/chartex.rs +++ b/crates/render/src/chartex.rs @@ -3,7 +3,7 @@ //! the column chart of their bins). use deckcraft_color::Rgba; -use deckcraft_model::chart::{BoxStats, Chart, ChartType}; +use deckcraft_model::chart::{BoxStats, Chart, ChartType, TreeNode}; use deckcraft_model::resolve::Ctx; use deckcraft_model::text::{Align, Anchor}; use kurbo::{Affine, BezPath, Point, Rect, Shape as _}; @@ -23,7 +23,9 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec let labels = c.data_labels || c.kind == ChartType::Funnel; match c.kind { ChartType::Funnel => funnel(ctx, rctx, c, m, area, base, text_col, &vals, &value_label), + ChartType::Treemap if !c.category_levels.is_empty() => tree_treemap(ctx, rctx, c, m, area, base, labels.then_some(&value_label)), ChartType::Treemap => treemap(ctx, rctx, c, m, area, base, &vals, labels.then_some(&value_label)), + _ if !c.category_levels.is_empty() => tree_sunburst(ctx, rctx, c, m, area, base), _ => sunburst(ctx, rctx, c, m, area, base, &vals), } } @@ -189,6 +191,162 @@ fn sunburst(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rec } } +/// `col` mixed toward white by `k` (0..1): deeper hierarchy levels are lighter. +fn lighten(col: Rgba, k: f64) -> Rgba { + let k = k.clamp(0.0, 0.8); + let f = |v: u8| (f64::from(v) + (255.0 - f64::from(v)) * k).round() as u8; + Rgba::rgb(f(col.r), f(col.g), f(col.b)) +} + +/// A multi-level treemap: top-level groups squarified by their totals, each with its label in +/// a banner along the top, and their children squarified inside, down to the leaves. A group +/// and everything in it take the group's colour. +#[allow(clippy::too_many_arguments)] +fn tree_treemap(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, value_label: Option<&dyn Fn(f64) -> String>) { + let tree = c.category_tree(); + #[allow(clippy::too_many_arguments)] + fn place( + ctx: &mut RenderContext, + rctx: &Ctx, + m: Affine, + nodes: &[TreeNode], + r: Rect, + base: f64, + col: &dyn Fn(usize) -> Rgba, + value_label: Option<&dyn Fn(f64) -> String>, + depth: usize, + ) { + let vals: Vec = nodes.iter().map(|n| n.value).collect(); + for (i, (n, t)) in nodes.iter().zip(squarify(&vals, r)).enumerate() { + if t.area() <= 0.0 { + continue; + } + let fill = col(i); + if n.children.is_empty() { + ctx.set_transform(m); + ctx.set_paint(color(fill, 1.0)); + ctx.fill_rect(&t); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.0)); + ctx.stroke_path(&t.to_path(0.1)); + let mut text = n.name.clone(); + if let Some(f) = value_label { + text = format!("{text}\n{}", f(n.value)); + } + let inner = t.inset(-base * 0.3); + if inner.width() > base && inner.height() > base { + label(ctx, rctx, m, &text, inner, base * 0.9, Align::Left, Anchor::Top, Rgba::WHITE, false); + } + continue; + } + // A group: its banner, then its children below it. + let banner = (base * 1.5).min(t.height() * 0.3); + ctx.set_transform(m); + ctx.set_paint(color(fill, 1.0)); + ctx.fill_rect(&t); + if t.width() > base * 2.0 && banner > base * 0.8 { + let head = Rect::new(t.x0 + base * 0.3, t.y0, t.x1 - base * 0.3, t.y0 + banner); + label(ctx, rctx, m, &n.name, head, base, Align::Left, Anchor::Middle, Rgba::WHITE, true); + } + let inner = Rect::new(t.x0 + 1.0, t.y0 + banner, t.x1 - 1.0, t.y1 - 1.0); + let k = lighten(fill, 0.12 * (depth + 1) as f64); + place(ctx, rctx, m, &n.children, inner, base, &|_| k, value_label, depth + 1); + ctx.set_transform(m); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(2.0)); + ctx.stroke_path(&t.to_path(0.1)); + } + } + place(ctx, rctx, m, &tree, area, base, &|i| series_color(rctx, c, i), value_label, 0); +} + +/// A multi-level sunburst: the top level as the inner ring, each deeper level in the next ring +/// out within its parent's angle, coloured by top-level group and lighter further out. +fn tree_sunburst(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64) { + let tree = c.category_tree(); + fn depth(n: &[TreeNode]) -> usize { + n.iter().map(|c| 1 + depth(&c.children)).max().unwrap_or(0) + } + let levels = depth(&tree).max(1); + let total: f64 = tree.iter().map(|n| n.value).sum(); + if total <= 0.0 { + return; + } + let r = area.width().min(area.height()) / 2.0 * 0.95; + let hole = r * 0.2; + let ring = (r - hole) / levels as f64; + let cen = area.center(); + #[allow(clippy::too_many_arguments)] + fn ring_of( + ctx: &mut RenderContext, + rctx: &Ctx, + m: Affine, + nodes: &[TreeNode], + a0: f64, + span: f64, + d: usize, + geo: (Point, f64, f64, f64), + base: f64, + col: &dyn Fn(usize) -> Rgba, + ) { + let (cen, hole, ring, total) = geo; + let sum: f64 = nodes.iter().map(|n| n.value).sum(); + if sum <= 0.0 { + return; + } + let (ri, ro) = (hole + ring * d as f64, hole + ring * (d + 1) as f64); + let mut a = a0; + for (i, n) in nodes.iter().enumerate() { + let sweep = n.value / sum * span; + if sweep <= 0.0 { + continue; + } + let fill = col(i); + let mut p = BezPath::new(); + p.move_to(Point::new(cen.x + ri * a.cos(), cen.y + ri * a.sin())); + p.line_to(Point::new(cen.x + ro * a.cos(), cen.y + ro * a.sin())); + p.extend(kurbo::Arc::new(cen, (ro, ro), a, sweep, 0.0).append_iter(0.1)); + let a1 = a + sweep; + p.line_to(Point::new(cen.x + ri * a1.cos(), cen.y + ri * a1.sin())); + p.extend(kurbo::Arc::new(cen, (ri, ri), a1, -sweep, 0.0).append_iter(0.1)); + p.close_path(); + ctx.set_transform(m); + ctx.set_paint(color(fill, 1.0)); + ctx.fill_path(&p); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.5)); + ctx.stroke_path(&p); + // Labels where the segment has room for them. + let mid = a + sweep / 2.0; + let rr = (ri + ro) / 2.0; + if sweep * rr > base * 2.5 && !n.name.is_empty() { + let lp = Point::new(cen.x + rr * mid.cos(), cen.y + rr * mid.sin()); + let half = (ring * 0.48).max(base); + label( + ctx, + rctx, + m, + &n.name, + Rect::new(lp.x - half, lp.y - base, lp.x + half, lp.y + base), + base * 0.9, + Align::Center, + Anchor::Middle, + Rgba::WHITE, + false, + ); + } + if !n.children.is_empty() { + let k = lighten(fill, 0.15); + ring_of(ctx, rctx, m, &n.children, a, sweep, d + 1, (cen, hole, ring, total), base, &|_| k); + } + a = a1; + } + } + ring_of(ctx, rctx, m, &tree, -std::f64::consts::FRAC_PI_2, std::f64::consts::TAU, 0, (cen, hole, ring, total), base, &|i| { + series_color(rctx, c, i) + }); +} + /// A box chart's summaries: per series, per group. pub struct Boxes { pub stats: Vec>>, diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 7b179485..f36adad1 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1777,3 +1777,29 @@ fn histogram_pareto_and_box_charts_draw() { assert!(n > blank + 400, "{kind:?}: {n} inked samples (blank {blank})"); } } + +/// Multi-level treemaps and sunbursts draw their groups: the treemap's group banners and the +/// sunburst's inner ring of groups, coloured by top-level group. +#[test] +fn multilevel_treemap_and_sunburst_draw_their_groups() { + for kind in [ChartType::Treemap, ChartType::Sunburst] { + let c = Chart::sample(kind); + let sh = Shape { id: ShapeId(623), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }; + let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + if let Ok(dir) = std::env::var("DECKCRAFT_CHART_SHOTS") { + let _ = std::fs::write(format!("{dir}/{kind:?}-levels.png"), img.to_png()); + } + // Distinct colours: one hue per top-level group plus lighter children. + let mut seen: Vec<[u8; 3]> = Vec::new(); + for y in (60..520).step_by(8) { + for x in (40..920).step_by(8) { + let p = img.pixel(x, y); + let c = [p[0] / 16, p[1] / 16, p[2] / 16]; + if c != [15, 15, 15] && !seen.contains(&c) { + seen.push(c); + } + } + } + assert!(seen.len() >= 6, "{kind:?}: {} colours", seen.len()); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 7d0927f2..2fdebd52 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -73,7 +73,7 @@ the round trip even though DeckCraft doesn't model it. | Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | | | Tables with styles | yes | yes | | | Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw` | -| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, Pareto, box) | yes | yes | Unchanged parts kept; edited and new ones written from the model with a plain-chart fallback. Only the innermost hierarchy level; no region maps | +| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, Pareto, box) | yes | yes | Unchanged parts kept; edited and new ones written from the model with a plain-chart fallback. Treemap and sunburst hierarchy levels kept; no region maps | | SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save | | Equations (OMML, `a14:m`) | no | no | PR #55 open | | Audio / video (`p14:media`, trim, fades, bookmarks) | yes | partly | Playback options lost on save (#74) | diff --git a/docs/gaps.md b/docs/gaps.md index 253022cd..1d57e9ca 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -148,10 +148,10 @@ Detail in [file-format-parity.md](file-format-parity.md). chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, Pareto, box & whisker) are read, drawn, inserted, edited and saved as `chartEx` (kept unchanged, else written from the model, with a plain chart fallback). Waterfalls float and total, funnels, - treemaps (squarified) and sunbursts (innermost ring only) have their own layouts, histograms and - Paretos bin their data (count, width or Scott's rule), and box & whisker charts use exclusive - quartiles with 1.5 IQR whiskers. Multi-level treemap and sunburst hierarchies, inclusive - quartiles and region maps remain. + treemaps (squarified, nested by level with group banners) and sunbursts (one ring per level) + have their own layouts, histograms and Paretos bin their data (count, width or Scott's rule), + and box & whisker charts use exclusive or inclusive quartiles with 1.5 IQR whiskers. Region maps + remain. - **Impact:** most business decks have charts; editing their data is routine. - **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h). From 210df9a3d1f9aa9ae99a023ba8c65e4371d789f2 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 01:23:54 +0300 Subject: [PATCH 07/27] Charts: keep and draw region map charts Read the regionMap chartEx layout as a RegionMap chart (regions and their colour values), write the part back as read while unchanged, and when edited rebuild it with the original geography (projection, culture and cached boundaries) carried over. With no map data in the repo, the renderer draws each region as a value-coloured tile with a colour scale and a note that the map itself is not drawn. Co-authored-by: Cursor --- crates/model/src/chart.rs | 6 ++- crates/pptx/src/read/chartex.rs | 6 +-- crates/pptx/src/write/chart.rs | 59 ++++++++++++++++++++- crates/render/src/chart.rs | 5 +- crates/render/src/chartex.rs | 91 +++++++++++++++++++++++++++++++++ crates/render/src/tests.rs | 31 +++++++++++ docs/file-format-parity.md | 2 +- docs/gaps.md | 5 +- 8 files changed, 194 insertions(+), 11 deletions(-) diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index 54e28dcd..5b646f53 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -34,6 +34,9 @@ pub enum ChartType { Funnel, Treemap, Sunburst, + /// A filled (choropleth) map of regions coloured by value, kept and drawn from its data; it + /// is not offered in the chart menus (PowerPoint needs an online map service to make one). + RegionMap, Combo, } @@ -51,7 +54,7 @@ impl ChartType { /// Whether the type is saved as a `chartEx` part (with a plain chart as the fallback). pub fn is_chartex(self) -> bool { - Self::OFFICE_2016.contains(&self) + Self::OFFICE_2016.contains(&self) || self == ChartType::RegionMap } pub const MAIN: [ChartType; 12] = [ @@ -96,6 +99,7 @@ impl ChartType { ChartType::Funnel => "Funnel", ChartType::Treemap => "Treemap", ChartType::Sunburst => "Sunburst", + ChartType::RegionMap => "Map", ChartType::Combo => "Combo", } } diff --git a/crates/pptx/src/read/chartex.rs b/crates/pptx/src/read/chartex.rs index 339d26c3..d8f3e9ce 100644 --- a/crates/pptx/src/read/chartex.rs +++ b/crates/pptx/src/read/chartex.rs @@ -66,12 +66,12 @@ fn kind_of(layout: &str) -> Option { "sunburst" => ChartType::Sunburst, "clusteredColumn" => ChartType::Histogram, "boxWhisker" => ChartType::BoxWhisker, + "regionMap" => ChartType::RegionMap, _ => return None, }) } -/// Parse a `chartEx` part. `None` for anything else, and for layouts DeckCraft has no type -/// for (region maps). +/// Parse a `chartEx` part. `None` for anything else, and for layouts DeckCraft has no type for. pub fn read_chartex_xml(bytes: &[u8]) -> Option { let doc = crate::xml::parse(bytes).ok()?; let mut root = doc.root; @@ -110,7 +110,7 @@ pub(crate) fn chartex_from(root: &El) -> Option { }); let d = datas.get(s.child("dataId").and_then(|i| i.attr("val")).unwrap_or("0"))?; let (values, format) = - ["val", "size"].iter().find_map(|t| d.numbers.get(*t)).or_else(|| d.numbers.values().next()).cloned().unwrap_or_default(); + ["val", "size", "colorVal"].iter().find_map(|t| d.numbers.get(*t)).or_else(|| d.numbers.values().next()).cloned().unwrap_or_default(); if c.categories.is_empty() { c.categories = d.categories.clone(); c.category_levels = d.levels.clone(); diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index e0e3743e..7b8c1718 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -534,6 +534,18 @@ fn cx_level(w: &mut W, pts: &[String], format: Option<&str>) { w.close("cx:lvl"); } +/// The `cx:layoutPr` of the region map series in a kept `chartEx` part. +fn kept_region_layout(raw: &str) -> Option { + let doc = crate::xml::parse(raw.as_bytes()).ok()?; + let mut root = doc.root; + crate::read::resolve_mc(&mut root, 0); + let region = root.child("chart")?.child("plotArea")?.child("plotAreaRegion")?; + let s = region.children_named("series").find(|s| s.attr("layoutId") == Some("regionMap"))?; + let lp = s.child("layoutPr")?; + // Written under our `cx` prefix; a part that bound chartex to another prefix is not copied. + lp.name.starts_with("cx:").then(|| lp.to_xml()) +} + /// Writes an Office 2016 chart from the model, as PowerPoint lays the part out: one `cx:data` /// per series (categories and values), then the plot area with each series' layout, the axes /// the layout uses, the title and the legend. @@ -551,7 +563,11 @@ pub fn build_chartex(c: &Chart) -> Vec { } w.close("cx:strDim"); } - let ty = if matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) { "size" } else { "val" }; + let ty = match c.kind { + ChartType::Treemap | ChartType::Sunburst => "size", + ChartType::RegionMap => "colorVal", + _ => "val", + }; w.open("cx:numDim", A::new().a("type", ty)); let vals: Vec = s.values.iter().map(|v| v.map(fmt_num).unwrap_or_default()).collect(); cx_level(&mut w, &vals, Some(s.number_format.as_deref().unwrap_or("General"))); @@ -573,6 +589,7 @@ pub fn build_chartex(c: &Chart) -> Vec { ChartType::Treemap => "treemap", ChartType::Sunburst => "sunburst", ChartType::BoxWhisker => "boxWhisker", + ChartType::RegionMap => "regionMap", _ => "clusteredColumn", }; let one = !matches!(c.kind, ChartType::BoxWhisker); @@ -612,6 +629,13 @@ pub fn build_chartex(c: &Chart) -> Vec { w.empty("cx:statistics", A::new().a("quartileMethod", if c.inclusive_quartiles { "inclusive" } else { "exclusive" })); w.close("cx:layoutPr"); } + ChartType::RegionMap => { + // The map's geography (projection, region level and the cached boundaries + // PowerPoint downloaded) is not modelled: carry it over from the part we read. + if let Some(lp) = c.raw.as_deref().and_then(kept_region_layout) { + w.raw(&lp); + } + } _ => {} } w.close("cx:series"); @@ -622,7 +646,7 @@ pub fn build_chartex(c: &Chart) -> Vec { w.close("cx:series"); } w.close("cx:plotAreaRegion"); - if !matches!(c.kind, ChartType::Treemap | ChartType::Sunburst) { + if !matches!(c.kind, ChartType::Treemap | ChartType::Sunburst | ChartType::RegionMap) { w.open("cx:axis", A::new().a("id", "0")); w.empty("cx:catScaling", A::new().a("gapWidth", if matches!(c.kind, ChartType::Histogram | ChartType::Pareto) { "0" } else { "0.5" })); w.empty0("cx:tickLabels"); @@ -652,3 +676,34 @@ pub fn build_chartex(c: &Chart) -> Vec { w.close("cx:chartSpace"); w.finish() } + +#[cfg(test)] +mod region_map_tests { + use super::*; + + /// A region map part as PowerPoint writes it (our own numbers; the cached geography is a + /// stand-in blob). + const MAP: &str = r#"Sheet1!$A$2:$A$4MinskBrestGomelSheet1!$B$2:$B$41258Sheet1!$B$1SalesZGVja2NyYWZ0LXRlc3Q="#; + + #[test] + fn region_maps_keep_their_geography_through_save_and_edit() { + let c = crate::read::read_chartex_xml(MAP.as_bytes()).expect("a region map is read"); + assert_eq!(c.kind, ChartType::RegionMap); + assert_eq!(c.categories, ["Minsk", "Brest", "Gomel"]); + assert_eq!(c.series.first().map(|s| s.values.clone()), Some(vec![Some(12.0), Some(5.0), Some(8.0)])); + // Unchanged: written back as read. + let kept = String::from_utf8(chartex_xml(&c).unwrap_or_default()).unwrap_or_default(); + assert!(kept.contains("ZGVja2NyYWZ0LXRlc3Q=") && kept.contains("cultureRegion=\"BY\""), "{kept}"); + // Edited: rebuilt from the model, with the geography carried over. + let mut e = c.clone(); + if let Some(s) = e.series.first_mut() { + s.values[1] = Some(20.0); + } + let built = String::from_utf8(chartex_xml(&e).unwrap_or_default()).unwrap_or_default(); + assert!(built.contains(" = s.values.iter().map(|v| v.unwrap_or(0.0).max(0.0)).collect(); if vals.iter().all(|v| *v <= 0.0) { return; @@ -64,6 +68,93 @@ fn funnel( } } +/// Mix `a` towards `b` by `t` (0..1). +fn mix(a: Rgba, b: Rgba, t: f64) -> Rgba { + let t = t.clamp(0.0, 1.0); + let f = |x: u8, y: u8| (x as f64 + (y as f64 - x as f64) * t).round().clamp(0.0, 255.0) as u8; + Rgba::rgb(f(a.r, b.r), f(a.g, b.g), f(a.b, b.b)) +} + +/// A region map without its geography: the boundaries PowerPoint caches come from an online +/// map service in an undocumented format and DeckCraft ships no map data, so each region is a +/// tile in a grid, coloured on the map's value scale, with the scale below and a note saying +/// the map itself is not drawn. +fn region_tiles(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, text_col: Rgba) { + let Some(s) = c.series.first() else { return }; + let regions: Vec<(String, Option)> = + c.categories.iter().enumerate().map(|(i, n)| (n.clone(), s.values.get(i).copied().flatten())).filter(|(n, _)| !n.is_empty()).collect(); + let note_h = base * 1.4; + let scale_h = base * 2.6; + let grid = Rect::new(area.x0, area.y0, area.x1, (area.y1 - note_h - scale_h).max(area.y0)); + let hi = series_color(rctx, c, 0); + let lo = mix(hi, Rgba::WHITE, 0.85); + let (min, max) = regions.iter().filter_map(|(_, v)| *v).fold((f64::MAX, f64::MIN), |(a, b), v| (a.min(v), b.max(v))); + let span = if max > min { max - min } else { 1.0 }; + let n = regions.len(); + if n > 0 && grid.area() > 0.0 { + // Columns so that tiles come out about twice as wide as tall. + let aspect = grid.width() / grid.height().max(1.0); + let cols = ((n as f64 * aspect / 2.0).sqrt().ceil() as usize).clamp(1, n); + let rows = n.div_ceil(cols).max(1); + let (tw, th) = (grid.width() / cols as f64, grid.height() / rows as f64); + for (i, (name, v)) in regions.iter().enumerate() { + let (col, row) = (i % cols, i / cols); + let t = Rect::new(grid.x0 + tw * col as f64, grid.y0 + th * row as f64, grid.x0 + tw * (col + 1) as f64, grid.y0 + th * (row + 1) as f64) + .inset(-1.5); + let fill = v.map_or(Rgba::rgb(0xD9, 0xD9, 0xD9), |v| mix(lo, hi, (v - min) / span)); + ctx.set_transform(m); + ctx.set_paint(color(fill, 1.0)); + ctx.fill_rect(&t); + let dark = v.is_some_and(|v| (v - min) / span > 0.55); + let mut text = name.clone(); + if c.data_labels + && let Some(v) = v + { + text = format!( + "{text} +{}", + c.data_label(s, *v) + ); + } + let inner = t.inset(-base * 0.25); + if inner.width() > base && inner.height() > base * 0.8 { + label(ctx, rctx, m, &text, inner, base * 0.9, Align::Center, Anchor::Middle, if dark { Rgba::WHITE } else { text_col }, false); + } + } + } + // The colour scale: a gradient bar from the lowest to the highest value. + if min <= max { + let bar_w = (area.width() * 0.5).min(base * 24.0); + let y = grid.y1 + base * 0.6; + let bar = Rect::new(area.center().x - bar_w / 2.0, y, area.center().x + bar_w / 2.0, y + base * 0.8); + let steps = 48; + for k in 0..steps { + let x0 = bar.x0 + bar.width() * k as f64 / steps as f64; + let x1 = bar.x0 + bar.width() * (k + 1) as f64 / steps as f64 + 0.5; + ctx.set_transform(m); + ctx.set_paint(color(mix(lo, hi, k as f64 / (steps - 1) as f64), 1.0)); + ctx.fill_rect(&Rect::new(x0, bar.y0, x1.min(bar.x1), bar.y1)); + } + let ly = Rect::new(bar.x0 - base * 8.0, bar.y0 - base * 0.2, bar.x0 - base * 0.4, bar.y1 + base * 0.2); + label(ctx, rctx, m, &c.data_label(s, min), ly, base, Align::Right, Anchor::Middle, text_col, false); + let ry = Rect::new(bar.x1 + base * 0.4, bar.y0 - base * 0.2, bar.x1 + base * 8.0, bar.y1 + base * 0.2); + label(ctx, rctx, m, &c.data_label(s, max), ry, base, Align::Left, Anchor::Middle, text_col, false); + } + let note = Rect::new(area.x0, area.y1 - note_h, area.x1, area.y1); + label( + ctx, + rctx, + m, + "Map preview unavailable: regions shown as tiles", + note, + base * 0.85, + Align::Center, + Anchor::Middle, + mix(text_col, Rgba::WHITE, 0.4), + false, + ); +} + /// Squarified treemap (Bruls, Huizing, van Wijk): rows of tiles laid along the short side, /// each row kept while it improves the worst aspect ratio. pub fn squarify(vals: &[f64], r: Rect) -> Vec { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index f36adad1..28fe9887 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1803,3 +1803,34 @@ fn multilevel_treemap_and_sunburst_draw_their_groups() { assert!(seen.len() >= 6, "{kind:?}: {} colours", seen.len()); } } + +/// A region map without drawable geography falls back to coloured tiles on the value scale. +#[test] +fn region_map_draws_tiles_on_its_colour_scale() { + let names = ["Brest", "Vitebsk", "Gomel", "Grodno", "Minsk", "Mogilev", "Minsk City"]; + let vals = [3.1, 2.2, 2.9, 4.8, 6.0, 1.4, 9.5]; + let c = Chart { + kind: ChartType::RegionMap, + title: Some("Sales by region".into()), + categories: names.iter().map(|s| s.to_string()).collect(), + series: vec![deckcraft_model::chart::Series { name: "Sales".into(), values: vals.iter().map(|v| Some(*v)).collect(), ..Default::default() }], + data_labels: true, + ..Default::default() + }; + let sh = Shape { id: ShapeId(624), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }; + let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + if let Ok(dir) = std::env::var("DECKCRAFT_CHART_SHOTS") { + let _ = std::fs::write(format!("{dir}/RegionMap-tiles.png"), img.to_png()); + } + let mut seen: Vec<[u8; 3]> = Vec::new(); + for y in (60..520).step_by(6) { + for x in (40..920).step_by(6) { + let p = img.pixel(x, y); + let c = [p[0] / 16, p[1] / 16, p[2] / 16]; + if c != [15, 15, 15] && !seen.contains(&c) { + seen.push(c); + } + } + } + assert!(seen.len() >= 5, "{} colours", seen.len()); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2fdebd52..ea9a6339 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -73,7 +73,7 @@ the round trip even though DeckCraft doesn't model it. | Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | | | Tables with styles | yes | yes | | | Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw` | -| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, Pareto, box) | yes | yes | Unchanged parts kept; edited and new ones written from the model with a plain-chart fallback. Treemap and sunburst hierarchy levels kept; no region maps | +| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, Pareto, box) | yes | yes | Unchanged parts kept; edited and new ones written from the model with a plain-chart fallback. Treemap and sunburst hierarchy levels kept; region maps kept, drawn as tiles (no map geometry) | | SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save | | Equations (OMML, `a14:m`) | no | no | PR #55 open | | Audio / video (`p14:media`, trim, fades, bookmarks) | yes | partly | Playback options lost on save (#74) | diff --git a/docs/gaps.md b/docs/gaps.md index 1d57e9ca..85dae7b9 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -150,8 +150,9 @@ Detail in [file-format-parity.md](file-format-parity.md). written from the model, with a plain chart fallback). Waterfalls float and total, funnels, treemaps (squarified, nested by level with group banners) and sunbursts (one ring per level) have their own layouts, histograms and Paretos bin their data (count, width or Scott's rule), - and box & whisker charts use exclusive or inclusive quartiles with 1.5 IQR whiskers. Region maps - remain. + and box & whisker charts use exclusive or inclusive quartiles with 1.5 IQR whiskers. Region maps are kept + (their cached geography too, also through edits) and drawn as value-coloured tiles with a + colour scale, since DeckCraft ships no map boundaries. - **Impact:** most business decks have charts; editing their data is routine. - **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h). From 768641615afa017ba239f868543fb0cd1b401a59 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 01:29:51 +0300 Subject: [PATCH 08/27] Render: draw chart trendlines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fit linear, exponential, logarithmic, polynomial, power and moving-average lines, including forecast, and draw them with the equation and R² label. --- ROADMAP.md | 4 +- crates/model/src/chart.rs | 30 ++ crates/model/src/lib.rs | 1 + crates/model/src/trendline.rs | 614 ++++++++++++++++++++++++++++++++++ crates/render/src/chart.rs | 298 ++++++++++++++++- crates/render/src/tests.rs | 106 +++++- docs/file-format-parity.md | 5 +- docs/gaps.md | 10 +- docs/roadmap.md | 3 +- 9 files changed, 1056 insertions(+), 15 deletions(-) create mode 100644 crates/model/src/trendline.rs diff --git a/ROADMAP.md b/ROADMAP.md index eb16cb98..e6a8f9df 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: chart trendlines round-trip; previous: minor, hours to ~95% per audience added; ready ~55%; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: chart trendlines are drawn; previous: trivial, chart trendlines round-trip; hours to ~95% per audience added; ready ~55%; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-14 | Chart trendlines are drawn: linear, exponential, logarithmic, polynomial, power and moving average, with forecast and the equation and R² label. The value axis grows to include the fit | | 2026-10-14 | Chart trendlines round-trip (type, polynomial order, moving-average period, forecast, intercept, equation and R²). An owned corpus slice also checks combo charts with data labels, merged tables with diagonal borders, and text columns, autofit, vertical text and a WordArt warp | | 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless | | 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline | @@ -151,6 +152,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Progress log: chart trendlines are drawn | | 2026-10-14 | trivial | Progress log: chart trendlines | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index 9e4e4a3e..2b166d6b 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -379,6 +379,17 @@ impl Chart { hi = hi.max(p); lo = lo.min(m); } + if !percent { + for s in &ser { + if self.series_kind(s).stacked() { + continue; + } + for y in s.trendline_ys(crate::trendline::series_is_scatter(self.series_kind(s))) { + hi = hi.max(y); + lo = lo.min(y); + } + } + } if percent { hi = hi.max(1.0); } @@ -436,6 +447,14 @@ impl Chart { if self.kind.percent() { return (0.0, 1.0); } + if !self.kind.stacked() { + for s in &self.series { + for y in s.trendline_ys(crate::trendline::series_is_scatter(self.series_kind(s))) { + hi = hi.max(y); + lo = lo.min(y); + } + } + } (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) } } @@ -546,6 +565,17 @@ mod tests { } } + #[test] + fn a_forecast_grows_the_value_axis() { + let mut c = Chart::sample(ChartType::Line); + c.series.truncate(1); + c.series[0].values = vec![Some(3.0), Some(5.0), Some(7.0), Some(9.0)]; + let before = c.value_range().1; + c.series[0].trendlines.push(Trendline { forward: Some(1.0), ..Default::default() }); + let after = c.value_range().1; + assert!(after > before && after >= 11.0 - 1e-6, "{before} {after}"); + } + #[test] fn axis_labels_use_the_format_and_language() { let mut c = Chart::sample(ChartType::Column); diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 43561f94..3e30b90d 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -17,6 +17,7 @@ pub mod style; pub mod table; pub mod text; pub mod theme; +mod trendline; pub mod words; use std::sync::Arc; diff --git a/crates/model/src/trendline.rs b/crates/model/src/trendline.rs new file mode 100644 index 00000000..eeddd2bd --- /dev/null +++ b/crates/model/src/trendline.rs @@ -0,0 +1,614 @@ +//! Least-squares trendlines: linear, exponential, logarithmic, polynomial, power, and a +//! moving average. The fit is what the chart drawer plots and what the axis grows to include. + +use crate::chart::{ChartType, Series, Trendline, TrendlineType}; + +/// A fitted trendline. `y` is `None` where the model is undefined (a log of a non-positive x, +/// an exponential that overflowed). +#[derive(Clone, Debug)] +pub struct TrendFit { + kind: TrendlineType, + model: Model, + r2: f64, + x_min: f64, + x_max: f64, +} + +#[derive(Clone, Debug)] +enum Model { + /// `y = c[0] + c[1] x + c[2] x² + …` + Poly(Vec), + /// `y = c · e^(b x)` + Exp { c: f64, b: f64 }, + /// `y = b + c · ln(x)` + Log { b: f64, c: f64 }, + /// `y = c · x^b` + Power { c: f64, b: f64 }, + /// Window averages in x order. No equation. + Moving(Vec<(f64, f64)>), +} + +impl Trendline { + /// Fit `xs`/`ys` (paired). Non-finite pairs are dropped. `None` when the type needs more + /// points than it was given, or the normal equations have no solution. + pub fn fit(&self, xs: &[f64], ys: &[f64]) -> Option { + let mut pairs: Vec<(f64, f64)> = + xs.iter().zip(ys).map(|(x, y)| (*x, *y)).filter(|(x, y)| x.is_finite() && y.is_finite()).take(10_000).collect(); + if pairs.len() < 2 { + return None; + } + pairs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + let (x_min, x_max) = (pairs.first().map(|p| p.0)?, pairs.last().map(|p| p.0)?); + let intercept = self.intercept.filter(|v| v.is_finite()); + let (model, used_y, yhat) = match self.kind { + TrendlineType::Linear => { + let (xs, ys) = split(&pairs); + let (b, m) = linreg(&xs, &ys, intercept)?; + let yhat: Vec = xs.iter().map(|x| b + m * x).collect(); + (Model::Poly(vec![b, m]), ys, yhat) + } + TrendlineType::Poly => { + let order = self.order.unwrap_or(2).clamp(2, 6) as usize; + if pairs.len() < order + 1 { + return None; + } + let (xs, ys) = split(&pairs); + let coeff = polyfit(&xs, &ys, order, intercept)?; + let yhat: Vec = xs.iter().map(|x| poly_y(&coeff, *x)).collect(); + (Model::Poly(coeff), ys, yhat) + } + TrendlineType::Exp => { + let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(_, y)| *y > 0.0).collect(); + if pairs.len() < 2 { + return None; + } + let (xs, ln_y): (Vec, Vec) = pairs.iter().map(|(x, y)| (*x, y.ln())).unzip(); + let fixed = intercept.filter(|c| *c > 0.0).map(|c| c.ln()); + let (ln_c, b) = linreg(&xs, &ln_y, fixed)?; + let c = ln_c.exp(); + if !c.is_finite() || c <= 0.0 { + return None; + } + let ys: Vec = pairs.iter().map(|(_, y)| *y).collect(); + let yhat: Vec = xs.iter().map(|x| c * (b * x).exp()).collect(); + (Model::Exp { c, b }, ys, yhat) + } + TrendlineType::Log => { + let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(x, _)| *x > 0.0).collect(); + if pairs.len() < 2 { + return None; + } + let (ln_x, ys): (Vec, Vec) = pairs.iter().map(|(x, y)| (x.ln(), *y)).unzip(); + let (b, c) = linreg(&ln_x, &ys, intercept)?; + let yhat: Vec = ln_x.iter().map(|u| b + c * u).collect(); + (Model::Log { b, c }, ys, yhat) + } + TrendlineType::Power => { + let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(x, y)| *x > 0.0 && *y > 0.0).collect(); + if pairs.len() < 2 { + return None; + } + let (ln_x, ln_y): (Vec, Vec) = pairs.iter().map(|(x, y)| (x.ln(), y.ln())).unzip(); + let fixed = intercept.filter(|c| *c > 0.0).map(|c| c.ln()); + let (ln_c, b) = linreg(&ln_x, &ln_y, fixed)?; + let c = ln_c.exp(); + if !c.is_finite() || c <= 0.0 { + return None; + } + let ys: Vec = pairs.iter().map(|(_, y)| *y).collect(); + let yhat: Vec = pairs.iter().map(|(x, _)| c * x.powf(b)).collect(); + (Model::Power { c, b }, ys, yhat) + } + TrendlineType::MovingAvg => { + let period = self.period.unwrap_or(2).clamp(2, 255) as usize; + if pairs.len() <= period { + return None; + } + let mut pts = Vec::new(); + let mut ys = Vec::new(); + let mut yhat = Vec::new(); + for i in period - 1..pairs.len() { + let window = &pairs[i + 1 - period..=i]; + let avg = window.iter().map(|(_, y)| y).sum::() / period as f64; + if avg.is_finite() { + pts.push((pairs[i].0, avg)); + ys.push(pairs[i].1); + yhat.push(avg); + } + } + if pts.len() < 2 { + return None; + } + (Model::Moving(pts), ys, yhat) + } + }; + if yhat.iter().any(|v| !v.is_finite()) { + return None; + } + // A moving average starts at the first full window, not at the first data point. + let (x_min, x_max) = if let Model::Moving(pts) = &model { + match (pts.first(), pts.last()) { + (Some(a), Some(b)) => (a.0, b.0), + _ => (x_min, x_max), + } + } else { + (x_min, x_max) + }; + Some(TrendFit { kind: self.kind, model, r2: r_squared(&used_y, &yhat), x_min, x_max }) + } + + /// The equation and R² lines this trendline asks to show. Empty when neither is on, and a + /// moving average has no equation. + pub fn caption(&self, fit: &TrendFit) -> String { + let mut lines = Vec::new(); + if self.disp_eq + && let Some(eq) = fit.equation() + { + lines.push(eq); + } + if self.disp_r_sqr { + lines.push(fit.r2_label()); + } + lines.join("\n") + } +} + +impl TrendFit { + pub fn y(&self, x: f64) -> Option { + if !x.is_finite() { + return None; + } + let y = match &self.model { + Model::Poly(c) => poly_y(c, x), + Model::Exp { c, b } => c * (b * x).exp(), + Model::Log { b, c } => { + if x <= 0.0 { + return None; + } + b + c * x.ln() + } + Model::Power { c, b } => { + if x <= 0.0 { + return None; + } + c * x.powf(*b) + } + Model::Moving(_) => return None, + }; + y.is_finite().then_some(y) + } + + /// Samples from `x0` to `x1`, forecast included. A moving average returns its window points + /// and ignores the forecast (Excel does not extend one). + pub fn samples(&self, x0: f64, x1: f64) -> Vec<(f64, f64)> { + let (x0, x1) = if x0 <= x1 { (x0, x1) } else { (x1, x0) }; + if !x0.is_finite() || !x1.is_finite() { + return Vec::new(); + } + if let Model::Moving(pts) = &self.model { + return pts.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).take(10_000).collect(); + } + if matches!(self.kind, TrendlineType::Linear) { + return [x0, x1].into_iter().filter_map(|x| self.y(x).map(|y| (x, y))).collect(); + } + let steps = 48; + (0..=steps) + .filter_map(|i| { + let x = x0 + (x1 - x0) * i as f64 / steps as f64; + self.y(x).map(|y| (x, y)) + }) + .collect() + } + + /// Data x plus forecast, in category (or scatter x) units. Forecast is capped so a hostile + /// value cannot walk off the chart. + pub fn span(&self, forward: Option, backward: Option) -> (f64, f64) { + let pad = |v: Option| v.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(24.0)).unwrap_or(0.0); + if matches!(self.kind, TrendlineType::MovingAvg) { (self.x_min, self.x_max) } else { (self.x_min - pad(backward), self.x_max + pad(forward)) } + } + + pub fn equation(&self) -> Option { + let body = match &self.model { + Model::Poly(c) => poly_equation(c), + Model::Exp { c, b } => Some(format!("y = {}e^({}x)", num(*c), num(*b))), + Model::Log { b, c } => poly_equation(&[*b, *c]).map(|s| s.replacen("x", "ln(x)", 1)), + Model::Power { c, b } => { + let mag = if (c.abs() - 1.0).abs() < 1e-6 { String::new() } else { num(c.abs()) }; + let sign = if *c < 0.0 { "-" } else { "" }; + Some(format!("y = {sign}{mag}{}", x_pow(*b))) + } + Model::Moving(_) => None, + }?; + Some(body) + } + + pub fn r2_label(&self) -> String { + let r = if self.r2.is_finite() { self.r2 } else { 0.0 }; + if (r - 1.0).abs() < 5e-4 { "R² = 1".to_string() } else { format!("R² = {r:.3}") } + } +} + +impl Series { + /// Trendline y values, including a short forecast, that sit near this series. The axis uses + /// them so a forecast is not clipped off the plot. A value many times the series' own range + /// is left out, so a wild polynomial cannot flatten the chart. + pub fn trendline_ys(&self, scatter: bool) -> Vec { + let n = self.values.len().min(10_000); + let mut xs = Vec::new(); + let mut ys = Vec::new(); + for i in 0..n { + let Some(y) = self.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue }; + let x = if scatter { + let Some(x) = self.x_values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue }; + x + } else { + (i + 1) as f64 + }; + xs.push(x); + ys.push(y); + } + let (data_lo, data_hi) = ys.iter().fold((f64::MAX, f64::MIN), |(lo, hi), y| (lo.min(*y), hi.max(*y))); + if !data_lo.is_finite() || !data_hi.is_finite() { + return Vec::new(); + } + let span = (data_hi - data_lo).abs().max(1.0); + let mid = (data_lo + data_hi) / 2.0; + let mut out = Vec::new(); + for t in self.trendlines.iter().take(8) { + let Some(fit) = t.fit(&xs, &ys) else { continue }; + let (x0, x1) = fit.span(t.forward, t.backward); + for (_, y) in fit.samples(x0, x1) { + if y.is_finite() && (y - mid).abs() <= span * 8.0 { + out.push(y); + } + } + } + out + } +} + +/// Whether a series is plotted against numeric x values rather than category positions. +pub fn series_is_scatter(kind: ChartType) -> bool { + matches!(kind, ChartType::Scatter | ChartType::Bubble) +} + +fn split(pairs: &[(f64, f64)]) -> (Vec, Vec) { + pairs.iter().map(|(x, y)| (*x, *y)).unzip() +} + +/// `y = b + m x`. `intercept` fixes `b`. +fn linreg(xs: &[f64], ys: &[f64], intercept: Option) -> Option<(f64, f64)> { + if xs.len() != ys.len() || xs.len() < 2 { + return None; + } + if let Some(b) = intercept { + let mut sxx = 0.0; + let mut sxy = 0.0; + for (x, y) in xs.iter().zip(ys) { + sxx += x * x; + sxy += x * (y - b); + } + if sxx.abs() < 1e-18 { + return None; + } + let m = sxy / sxx; + return (m.is_finite() && b.is_finite()).then_some((b, m)); + } + let n = xs.len() as f64; + let (mut sx, mut sy, mut sxx, mut sxy) = (0.0, 0.0, 0.0, 0.0); + for (x, y) in xs.iter().zip(ys) { + sx += x; + sy += y; + sxx += x * x; + sxy += x * y; + } + let den = n * sxx - sx * sx; + if den.abs() < 1e-18 { + return None; + } + let m = (n * sxy - sx * sy) / den; + let b = (sy - m * sx) / n; + (m.is_finite() && b.is_finite()).then_some((b, m)) +} + +fn poly_y(coeff: &[f64], x: f64) -> f64 { + let mut acc = 0.0; + let mut xp = 1.0; + for c in coeff { + acc += c * xp; + if !acc.is_finite() { + return f64::NAN; + } + xp *= x; + } + acc +} + +/// Polynomial through the normal equations. `x` is centred when the intercept is free, then +/// expanded back to powers of `x`, so order 6 on category numbers stays solvable. +#[allow(clippy::needless_range_loop)] // the index is the power in the normal equations +fn polyfit(xs: &[f64], ys: &[f64], order: usize, intercept: Option) -> Option> { + let n = xs.len(); + if n != ys.len() || !(1..=6).contains(&order) || n < order + 1 { + return None; + } + if let Some(b0) = intercept { + let m = order; + let mut ata = vec![vec![0.0; m]; m]; + let mut atb = vec![0.0; m]; + for (x, y) in xs.iter().zip(ys) { + let dy = y - b0; + for i in 0..m { + let xi = x.powi((i + 1) as i32); + atb[i] += dy * xi; + for j in i..m { + ata[i][j] += xi * x.powi((j + 1) as i32); + } + } + } + for i in 0..m { + for j in 0..i { + ata[i][j] = ata[j][i]; + } + } + let solved = solve(ata, atb)?; + let mut coeff = Vec::with_capacity(order + 1); + coeff.push(b0); + coeff.extend(solved); + return coeff.iter().all(|v| v.is_finite()).then_some(coeff); + } + let xm = xs.iter().sum::() / n as f64; + let us: Vec = xs.iter().map(|x| x - xm).collect(); + let m = order + 1; + let mut ata = vec![vec![0.0; m]; m]; + let mut atb = vec![0.0; m]; + for (u, y) in us.iter().zip(ys) { + for i in 0..m { + let ui = if i == 0 { 1.0 } else { u.powi(i as i32) }; + atb[i] += y * ui; + for j in i..m { + let uj = if j == 0 { 1.0 } else { u.powi(j as i32) }; + ata[i][j] += ui * uj; + } + } + } + for i in 0..m { + for j in 0..i { + ata[i][j] = ata[j][i]; + } + } + let centred = solve(ata, atb)?; + let coeff = expand_shift(¢red, xm); + coeff.iter().all(|v| v.is_finite()).then_some(coeff) +} + +/// `a[k]` is the coefficient of `(x - xm)^k`. Return coefficients of powers of `x`. +#[allow(clippy::needless_range_loop)] // j is the power of x after the binomial shift +fn expand_shift(a: &[f64], xm: f64) -> Vec { + let mut c = vec![0.0; a.len()]; + for (k, ak) in a.iter().enumerate() { + let mut binom = 1.0; + for j in 0..=k { + c[j] += ak * binom * (-xm).powi((k - j) as i32); + if j < k { + binom *= (k - j) as f64 / (j + 1) as f64; + } + } + } + c +} + +#[allow(clippy::needless_range_loop)] // Gaussian elimination walks rows and columns by index +fn solve(mut a: Vec>, mut b: Vec) -> Option> { + let n = b.len(); + if n == 0 || n > 8 || a.len() != n { + return None; + } + for col in 0..n { + let mut piv = col; + for r in col + 1..n { + if a[r][col].abs() > a[piv][col].abs() { + piv = r; + } + } + if a[piv][col].abs() < 1e-12 { + return None; + } + a.swap(col, piv); + b.swap(col, piv); + let div = a[col][col]; + for c in col..n { + a[col][c] /= div; + } + b[col] /= div; + for r in 0..n { + if r == col { + continue; + } + let f = a[r][col]; + if f == 0.0 { + continue; + } + for c in col..n { + a[r][c] -= f * a[col][c]; + } + b[r] -= f * b[col]; + } + } + b.iter().all(|v| v.is_finite()).then_some(b) +} + +fn r_squared(ys: &[f64], yhat: &[f64]) -> f64 { + if ys.is_empty() || ys.len() != yhat.len() { + return 0.0; + } + let mean = ys.iter().sum::() / ys.len() as f64; + let mut ss_tot = 0.0; + let mut ss_res = 0.0; + for (y, h) in ys.iter().zip(yhat) { + ss_tot += (y - mean) * (y - mean); + ss_res += (y - h) * (y - h); + } + if ss_tot < 1e-18 { + return if ss_res < 1e-12 { 1.0 } else { 0.0 }; + } + let r = 1.0 - ss_res / ss_tot; + if r.is_finite() { r } else { 0.0 } +} + +fn num(v: f64) -> String { + if !v.is_finite() { + return "0".to_string(); + } + let a = v.abs(); + if a == 0.0 { + return "0".to_string(); + } + if a >= 1.0e6 || a < 1.0e-3 { + return format!("{v:.2e}").replace('+', ""); + } + let digits = if a >= 100.0 { + 1 + } else if a >= 10.0 { + 2 + } else { + 3 + }; + let mut s = format!("{v:.prec$}", prec = digits); + if let Some(dot) = s.find('.') { + while s.ends_with('0') { + s.pop(); + } + if s.len() == dot + 1 { + s.pop(); + } + } + s +} + +fn x_pow(p: f64) -> String { + if (p - 1.0).abs() < 1e-6 { + "x".to_string() + } else if (p - 2.0).abs() < 1e-6 { + "x²".to_string() + } else if (p - 3.0).abs() < 1e-6 { + "x³".to_string() + } else { + format!("x^{}", num(p)) + } +} + +fn poly_equation(coeff: &[f64]) -> Option { + let scale = coeff.iter().map(|c| c.abs()).fold(0.0, f64::max).max(1.0); + let mut body = String::new(); + let mut first = true; + for (k, c) in coeff.iter().enumerate().rev() { + if c.abs() < scale * 1e-8 { + continue; + } + let pow = match k { + 0 => String::new(), + 1 => "x".to_string(), + _ => x_pow(k as f64), + }; + let mag = if pow.is_empty() { + num(c.abs()) + } else if (c.abs() - 1.0).abs() < scale * 1e-8 { + pow + } else { + format!("{}{}", num(c.abs()), pow) + }; + if first { + if *c < 0.0 { + body.push('-'); + } + body.push_str(&mag); + first = false; + } else if *c < 0.0 { + body.push_str(" - "); + body.push_str(&mag); + } else { + body.push_str(" + "); + body.push_str(&mag); + } + } + if body.is_empty() { + body.push('0'); + } + Some(format!("y = {body}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn line(kind: TrendlineType) -> Trendline { + Trendline { kind, ..Default::default() } + } + + #[test] + fn linear_recovers_slope_and_a_fixed_intercept() { + let xs = [1.0, 2.0, 3.0, 4.0]; + let ys = [3.0, 5.0, 7.0, 9.0]; + let fit = line(TrendlineType::Linear).fit(&xs, &ys).expect("fit"); + assert!((fit.y(1.0).unwrap() - 3.0).abs() < 1e-9); + assert!((fit.y(4.0).unwrap() - 9.0).abs() < 1e-9); + assert_eq!(fit.equation().as_deref(), Some("y = 2x + 1")); + assert_eq!(fit.r2_label(), "R² = 1"); + let mut forced = line(TrendlineType::Linear); + forced.intercept = Some(0.0); + let fit = forced.fit(&[1.0, 2.0], &[2.0, 4.0]).expect("fit"); + assert!((fit.y(3.0).unwrap() - 6.0).abs() < 1e-6); + assert_eq!(fit.equation().as_deref(), Some("y = 2x")); + } + + #[test] + fn exp_log_power_and_quadratic_round_their_points() { + let xs = [1.0, 2.0, 3.0, 4.0]; + let exp = line(TrendlineType::Exp).fit(&xs, &[1.0, 2.0, 4.0, 8.0]).expect("exp"); + for (x, y) in xs.iter().zip([1.0, 2.0, 4.0, 8.0]) { + assert!((exp.y(*x).unwrap() - y).abs() < 1e-6, "{x}"); + } + assert!(exp.equation().unwrap().contains("e^(")); + let log = line(TrendlineType::Log).fit(&xs, &[1.0_f64.ln(), 2.0_f64.ln(), 3.0_f64.ln(), 4.0_f64.ln()]).expect("log"); + assert!((log.y(2.0).unwrap() - 2.0_f64.ln()).abs() < 1e-6); + assert!(log.equation().unwrap().contains("ln(x)")); + let power = line(TrendlineType::Power).fit(&xs, &[1.0, 4.0, 9.0, 16.0]).expect("power"); + assert!((power.y(3.0).unwrap() - 9.0).abs() < 1e-6); + assert_eq!(power.equation().as_deref(), Some("y = x²")); + let mut poly = line(TrendlineType::Poly); + poly.order = Some(2); + let quad = poly.fit(&xs, &[1.0, 4.0, 9.0, 16.0]).expect("poly"); + assert!((quad.y(3.0).unwrap() - 9.0).abs() < 1e-6); + assert_eq!(quad.equation().as_deref(), Some("y = x²")); + } + + #[test] + fn moving_average_and_forecast_span() { + let fit = Trendline { kind: TrendlineType::MovingAvg, period: Some(2), ..Default::default() } + .fit(&[1.0, 2.0, 3.0, 4.0], &[2.0, 8.0, 4.0, 10.0]) + .expect("ma"); + let pts = fit.samples(0.0, 10.0); + assert_eq!(pts.len(), 3); + assert!((pts[0].1 - 5.0).abs() < 1e-9); + assert!((pts[1].1 - 6.0).abs() < 1e-9); + assert!((pts[2].1 - 7.0).abs() < 1e-9); + assert!(fit.equation().is_none()); + let linear = line(TrendlineType::Linear).fit(&[1.0, 2.0], &[1.0, 2.0]).expect("line"); + assert_eq!(linear.span(Some(1.5), Some(-3.0)), (1.0, 3.5)); + assert_eq!(fit.span(Some(4.0), Some(4.0)), (2.0, 4.0)); + } + + #[test] + fn too_few_points_and_non_finite_input_fit_nothing() { + assert!(line(TrendlineType::Linear).fit(&[1.0], &[2.0]).is_none()); + assert!(line(TrendlineType::Exp).fit(&[1.0, 2.0], &[-1.0, -2.0]).is_none()); + assert!(line(TrendlineType::Poly).fit(&[1.0, 2.0], &[1.0, 2.0]).is_none()); + assert!(line(TrendlineType::Linear).fit(&[f64::NAN, f64::INFINITY], &[1.0, 2.0]).is_none()); + let mut t = line(TrendlineType::Linear); + t.disp_eq = true; + t.disp_r_sqr = true; + let fit = t.fit(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0]).expect("fit"); + assert_eq!(t.caption(&fit), "y = x\nR² = 1"); + } +} diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index eb2b0a96..0ead3648 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -2,7 +2,7 @@ use deckcraft_color::{ColorTransform, Rgba, SchemeSlot}; use deckcraft_model::Shape; -use deckcraft_model::chart::{Chart, ChartType, axis_scale}; +use deckcraft_model::chart::{Chart, ChartType, TrendlineType, axis_scale}; use deckcraft_model::resolve::Ctx; use deckcraft_model::style::{ColorRef, Fill}; use deckcraft_model::text::{Align, Anchor, Bullet, TextBody}; @@ -344,11 +344,15 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } } let ncat = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).max(1); - let band = if horiz { plot.height() / ncat as f64 } else { plot.width() / ncat as f64 }; + // Forecast reserves empty category slots so the line can run past the data. Charts with no + // forecast keep the old band. + let (back, fwd) = forecast_pad(c); + let nspan = ncat as f64 + back + fwd; + let band = if horiz { plot.height() / nspan } else { plot.width() / nspan }; // Category labels: wrapped onto two lines when they don't fit their band (long date ranges). let wraps = !horiz && c.categories.iter().any(|n| n.chars().count() as f64 * base * 0.5 > band); for (i, name) in c.categories.iter().enumerate() { - let mid = if horiz { plot.y0 + band * (i as f64 + 0.5) } else { plot.x0 + band * (i as f64 + 0.5) }; + let mid = if horiz { plot.y0 + band * (i as f64 + back + 0.5) } else { plot.x0 + band * (i as f64 + back + 0.5) }; if horiz { label( ctx, @@ -398,7 +402,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h for (bi, (si, s)) in bars.iter().enumerate() { let sec = c.on_secondary(s); let v = s.values.get(i).copied().flatten().unwrap_or(0.0) / total; - let start = band * i as f64 + (band - group) / 2.0 + if stacked { 0.0 } else { bar * bi as f64 }; + let start = band * (i as f64 + back) + (band - group) / 2.0 + if stacked { 0.0 } else { bar * bi as f64 }; let (a, b) = if stacked { if v >= 0.0 { let r = (pos_acc, pos_acc + v); @@ -471,9 +475,9 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h let xs: Vec = s.x_values.iter().flatten().copied().collect(); let (xl, xh) = (xs.iter().copied().fold(f64::MAX, f64::min), xs.iter().copied().fold(f64::MIN, f64::max)); let xv = s.x_values.get(i).copied().flatten().unwrap_or(i as f64); - if xh > xl { plot.x0 + (xv - xl) / (xh - xl) * plot.width() } else { plot.x0 + band * (i as f64 + 0.5) } + if xh > xl { plot.x0 + (xv - xl) / (xh - xl) * plot.width() } else { plot.x0 + band * (i as f64 + back + 0.5) } } - _ => plot.x0 + band * (i as f64 + 0.5), + _ => plot.x0 + band * (i as f64 + back + 0.5), }; pts.push(Point::new(x, to_axis(sec, v))); } @@ -511,6 +515,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } } } + draw_trendlines(ctx, rctx, c, m, plot, band, back, ncat, horiz, base, text_col, &to_axis); // Axis line. let mut ax = BezPath::new(); if horiz { @@ -526,6 +531,255 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h ctx.stroke_path(&ax); } +/// A corner of `plot` whose box stays clear of `pts` (the drawn trendline). +fn caption_at(plot: Rect, box_w: f64, box_h: f64, pts: &[Point], n: usize) -> (f64, f64) { + let pad = 4.0; + let shift = n as f64 * (box_h + 2.0); + let x_right = (plot.x1 - box_w - pad).max(plot.x0 + pad); + let y_bottom = (plot.y1 - box_h - pad - shift).max(plot.y0 + pad); + let y_top = (plot.y0 + pad + shift).min(y_bottom); + let corners = [(plot.x0 + pad, y_top), (x_right, y_top), (plot.x0 + pad, y_bottom), (x_right, y_bottom)]; + let mut best = corners[0]; + let mut best_d = -1.0; + for (x, y) in corners { + let mut min_d = f64::MAX; + for p in pts { + let dx = if p.x < x { + x - p.x + } else if p.x > x + box_w { + p.x - (x + box_w) + } else { + 0.0 + }; + let dy = if p.y < y { + y - p.y + } else if p.y > y + box_h { + p.y - (y + box_h) + } else { + 0.0 + }; + min_d = min_d.min((dx * dx + dy * dy).sqrt()); + } + if pts.is_empty() { + min_d = 1.0; + } + if min_d > best_d { + best_d = min_d; + best = (x, y); + } + } + best +} + +fn pad_units(v: Option) -> f64 { + v.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.min(3.0)).unwrap_or(0.0) +} + +/// Empty category slots reserved for a trendline forecast, capped so the data still fills the plot. +fn forecast_pad(c: &Chart) -> (f64, f64) { + if matches!(c.kind, ChartType::Scatter | ChartType::Bubble) { + return (0.0, 0.0); + } + let mut back = 0.0_f64; + let mut fwd = 0.0_f64; + for s in &c.series { + if matches!(c.series_kind(s), ChartType::Scatter | ChartType::Bubble) { + continue; + } + for t in s.trendlines.iter().take(8) { + if t.kind == TrendlineType::MovingAvg { + continue; + } + back = back.max(pad_units(t.backward)); + fwd = fwd.max(pad_units(t.forward)); + } + } + (back, fwd) +} + +const TREND_INK: [Rgba; 4] = [Rgba::rgb(0x22, 0x22, 0x22), Rgba::rgb(0xB0, 0x30, 0x30), Rgba::rgb(0x1A, 0x5C, 0x7A), Rgba::rgb(0xA0, 0x68, 0x00)]; + +fn map_xy( + plot: Rect, + band: f64, + back: f64, + horiz: bool, + scatter: bool, + sx0: f64, + sx1: f64, + sec: bool, + x: f64, + y: f64, + to_axis: &impl Fn(bool, f64) -> f64, +) -> Option { + if !x.is_finite() || !y.is_finite() { + return None; + } + let value = to_axis(sec, y); + if !value.is_finite() { + return None; + } + let along = if scatter && sx1 > sx0 { + plot.x0 + (x - sx0) / (sx1 - sx0) * plot.width() + } else { + let origin = if horiz { plot.y0 } else { plot.x0 }; + origin + band * (x - 1.0 + back + 0.5) + }; + if !along.is_finite() { + return None; + } + Some(if horiz && !scatter { Point::new(value, along) } else { Point::new(along, value) }) +} + +/// The part of segment `a`–`b` that lies inside `r`. +fn clip_seg(a: Point, b: Point, r: Rect) -> Option<(Point, Point)> { + let (mut t0, mut t1) = (0.0, 1.0); + let dx = b.x - a.x; + let dy = b.y - a.y; + let p = [-dx, dx, -dy, dy]; + let q = [a.x - r.x0, r.x1 - a.x, a.y - r.y0, r.y1 - a.y]; + for i in 0..4 { + let (pi, qi) = (p[i], q[i]); + if pi.abs() < 1e-12 { + if qi < 0.0 { + return None; + } + } else { + let t = qi / pi; + if pi < 0.0 { + if t > t1 { + return None; + } else if t > t0 { + t0 = t; + } + } else if t < t0 { + return None; + } else if t < t1 { + t1 = t; + } + } + } + Some((Point::new(a.x + t0 * dx, a.y + t0 * dy), Point::new(a.x + t1 * dx, a.y + t1 * dy))) +} + +fn draw_trendlines( + ctx: &mut RenderContext, + rctx: &Ctx, + c: &Chart, + m: Affine, + plot: Rect, + band: f64, + back: f64, + ncat: usize, + horiz: bool, + base: f64, + text_col: Rgba, + to_axis: &impl Fn(bool, f64) -> f64, +) { + let mut label_n = 0usize; + for s in &c.series { + if s.trendlines.is_empty() { + continue; + } + let kind = c.series_kind(s); + let scatter = matches!(kind, ChartType::Scatter | ChartType::Bubble); + let sec = c.on_secondary(s); + let mut xs = Vec::new(); + let mut ys = Vec::new(); + for i in 0..ncat { + let Some(y) = s.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue }; + let x = if scatter { + let Some(x) = s.x_values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue }; + x + } else { + (i + 1) as f64 + }; + xs.push(x); + ys.push(y); + } + // The same x extent the scatter marks use, so the fit sits on the points. + let (sx0, sx1) = if scatter { + let mut lo = f64::MAX; + let mut hi = f64::MIN; + for v in s.x_values.iter().copied().flatten().filter(|v| v.is_finite()) { + lo = lo.min(v); + hi = hi.max(v); + } + (lo, hi) + } else { + (0.0, 0.0) + }; + for (ti, t) in s.trendlines.iter().take(8).enumerate() { + let Some(fit) = t.fit(&xs, &ys) else { continue }; + let (x0, x1) = fit.span(t.forward, t.backward); + let samples = fit.samples(x0, x1); + let mut path = BezPath::new(); + let mut prev: Option = None; + let mut ink_pts = Vec::new(); + let mut any = false; + for w in samples.windows(2) { + let (Some(a), Some(b)) = ( + map_xy(plot, band, back, horiz, scatter, sx0, sx1, sec, w[0].0, w[0].1, to_axis), + map_xy(plot, band, back, horiz, scatter, sx0, sx1, sec, w[1].0, w[1].1, to_axis), + ) else { + prev = None; + continue; + }; + let Some((ca, cb)) = clip_seg(a, b, plot) else { + prev = None; + continue; + }; + let cont = prev.is_some_and(|p| (p.x - ca.x).abs() < 0.6 && (p.y - ca.y).abs() < 0.6); + if cont { + path.line_to(cb); + } else { + path.move_to(ca); + path.line_to(cb); + } + prev = Some(cb); + if ink_pts.len() < 64 { + ink_pts.push(ca); + ink_pts.push(cb); + } + any = true; + } + if !any { + continue; + } + ctx.set_transform(m); + let ink = TREND_INK.get(ti % TREND_INK.len()).copied().unwrap_or(Rgba::rgb(0x22, 0x22, 0x22)); + ctx.set_paint(color(ink, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.6).with_dashes(0.0, [6.0, 3.5]).with_caps(kurbo::Cap::Round)); + ctx.stroke_path(&path); + let caption = t.caption(&fit); + if caption.is_empty() { + continue; + } + let lines = caption.chars().filter(|ch| *ch == '\n').count() + 1; + let box_h = base * 1.3 * lines as f64 + 2.0; + let widest = caption.lines().map(|l| l.chars().count()).max().unwrap_or(1) as f64; + let box_w = (widest * base * 0.62).clamp(48.0, plot.width() * 0.72); + let (x, y) = caption_at(plot, box_w, box_h, &ink_pts, label_n); + ctx.set_transform(m); + ctx.set_paint(color(Rgba::WHITE, 1.0)); + ctx.fill_rect(&Rect::new(x, y, x + box_w, y + box_h)); + label( + ctx, + rctx, + m, + &caption, + Rect::new(x, y, x + box_w, y + box_h), + (base * 0.9).clamp(8.0, 13.0), + Align::Left, + Anchor::Top, + text_col, + false, + ); + label_n += 1; + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -561,4 +815,36 @@ mod tests { assert_eq!(plot, AREA); assert_eq!(o.x, 340.0); } + + #[test] + fn clip_keeps_the_part_inside_the_plot() { + let r = Rect::new(0.0, 0.0, 100.0, 50.0); + let (a, b) = clip_seg(Point::new(-50.0, 25.0), Point::new(150.0, 25.0), r).expect("clipped"); + assert!((a.x - 0.0).abs() < 1e-6 && (b.x - 100.0).abs() < 1e-6); + assert!((a.y - 25.0).abs() < 1e-6); + assert!(clip_seg(Point::new(-10.0, -10.0), Point::new(-1.0, -1.0), r).is_none()); + let (a, b) = clip_seg(Point::new(10.0, 10.0), Point::new(40.0, 20.0), r).expect("inside"); + assert!((a.x - 10.0).abs() < 1e-6 && (b.x - 40.0).abs() < 1e-6); + } + + #[test] + fn caption_keeps_clear_of_a_line_into_the_top_right() { + let plot = Rect::new(0.0, 0.0, 200.0, 100.0); + let pts = [Point::new(20.0, 80.0), Point::new(180.0, 15.0)]; + let (x, y) = caption_at(plot, 60.0, 20.0, &pts, 0); + assert!(x > 100.0 && y > 40.0, "label on the line at {x} {y}"); + } + + #[test] + fn forecast_reserves_at_most_three_category_slots() { + use deckcraft_model::chart::Trendline; + let mut c = Chart::sample(ChartType::Line); + c.series[0].trendlines.push(Trendline { forward: Some(10.0), backward: Some(0.5), ..Default::default() }); + assert_eq!(forecast_pad(&c), (0.5, 3.0)); + c.series[0].trendlines[0].kind = TrendlineType::MovingAvg; + assert_eq!(forecast_pad(&c), (0.0, 0.0)); + c.kind = ChartType::Scatter; + c.series[0].trendlines[0].kind = TrendlineType::Linear; + assert_eq!(forecast_pad(&c), (0.0, 0.0)); + } } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 0de3d695..470e5505 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1,5 +1,5 @@ use super::*; -use deckcraft_model::chart::ChartType; +use deckcraft_model::chart::{ChartType, Trendline, TrendlineType}; use deckcraft_model::style::ColorRef; use deckcraft_model::text::TextBody; use deckcraft_model::{Chart, Geom, Presentation, Shape, ShapeId, ShapeKind, ShapeStyle, Table, Xfrm}; @@ -155,6 +155,110 @@ fn combo_chart_draws_columns_and_a_line_on_its_own_axis() { assert!(col[0] < 250 || col[1] < 250 || col[2] < 250, "no column: {col:?}"); } +fn trend_chart(title: &str, kind: ChartType, ys: &[f64], trend: Trendline, y_max: f64) -> Chart { + let mut c = Chart::sample(kind); + c.title = Some(title.into()); + c.legend = None; + c.categories = vec!["A".into(), "B".into(), "C".into(), "D".into()]; + c.series.truncate(1); + c.series[0].name = "Data".into(); + c.series[0].values = ys.iter().copied().map(Some).collect(); + c.y_min = Some(0.0); + c.y_max = Some(y_max); + c.series[0].trendlines = vec![trend]; + c +} + +fn trend_line(kind: TrendlineType) -> Trendline { + Trendline { kind, disp_eq: true, disp_r_sqr: true, ..Default::default() } +} + +/// Six charts, one trendline type each. `draw` drops the trendlines. `forecast` extends the linear fit. +fn trend_gallery(draw: bool, forecast: bool) -> Image { + let mut linear = trend_line(TrendlineType::Linear); + if forecast { + linear.forward = Some(1.0); + linear.backward = Some(0.5); + } + let mut poly = trend_line(TrendlineType::Poly); + poly.order = Some(2); + let mut ma = trend_line(TrendlineType::MovingAvg); + ma.period = Some(2); + let mut charts = vec![ + trend_chart("Linear", ChartType::Column, &[3.0, 5.0, 7.0, 9.0], linear, 12.0), + trend_chart("Exponential", ChartType::LineMarkers, &[1.0, 2.0, 4.0, 8.0], trend_line(TrendlineType::Exp), 10.0), + trend_chart( + "Logarithmic", + ChartType::LineMarkers, + &[1.0_f64.ln(), 2.0_f64.ln(), 3.0_f64.ln(), 4.0_f64.ln()], + trend_line(TrendlineType::Log), + 2.0, + ), + trend_chart("Polynomial", ChartType::LineMarkers, &[1.0, 4.0, 9.0, 16.0], poly, 18.0), + trend_chart("Power", ChartType::LineMarkers, &[2.0, 2.0 * 2.0_f64.sqrt(), 2.0 * 3.0_f64.sqrt(), 4.0], trend_line(TrendlineType::Power), 6.0), + trend_chart("Moving average", ChartType::LineMarkers, &[2.0, 8.0, 4.0, 10.0], ma, 12.0), + ]; + if !draw { + for c in &mut charts { + c.series[0].trendlines.clear(); + } + } + let shapes = charts + .into_iter() + .enumerate() + .map(|(i, c)| Shape { + id: ShapeId(800 + i as u32), + xfrm: Some(Xfrm::new(12.0 + (i % 3) as f64 * 316.0, 12.0 + (i / 3) as f64 * 264.0, 304.0, 252.0)), + kind: ShapeKind::Chart(Box::new(c)), + ..Default::default() + }) + .collect(); + render_slide(&deck_with(shapes), 0, &RenderOpts { scale: 1.0, ..Default::default() }) +} + +#[test] +fn trendlines_draw_through_the_points_with_a_label() { + let on = trend_gallery(true, false); + let off = trend_gallery(false, false); + assert_eq!((on.width, on.height), (960, 540)); + let dark = on + .pixels + .as_chunks::<4>() + .0 + .iter() + .zip(off.pixels.as_chunks::<4>().0) + .filter(|(a, b)| a[0] < 50 && a[1] < 50 && a[2] < 50 && b[0] as i32 - a[0] as i32 > 20) + .count(); + assert!(dark > 40, "dashed trendline ink {dark}"); + // A scatter fit uses the real x values and still paints the dash. + let mut scatter = trend_chart("Scatter", ChartType::Scatter, &[2.0, 4.0, 6.0, 8.0], trend_line(TrendlineType::Linear), 10.0); + scatter.series[0].x_values = (1..=4).map(|x| Some(x as f64)).collect(); + let sh = Shape { + id: ShapeId(810), + xfrm: Some(Xfrm::new(0.0, 0.0, 480.0, 360.0)), + kind: ShapeKind::Chart(Box::new(scatter.clone())), + ..Default::default() + }; + let with = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + scatter.series[0].trendlines.clear(); + let sh = + Shape { id: ShapeId(810), xfrm: Some(Xfrm::new(0.0, 0.0, 480.0, 360.0)), kind: ShapeKind::Chart(Box::new(scatter)), ..Default::default() }; + let without = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let scatter_dark = with + .pixels + .as_chunks::<4>() + .0 + .iter() + .zip(without.pixels.as_chunks::<4>().0) + .filter(|(a, b)| a[0] < 50 && a[1] < 50 && a[2] < 50 && b[0] as i32 - a[0] as i32 > 20) + .count(); + assert!(scatter_dark > 20, "scatter trendline ink {scatter_dark}"); + if std::env::var_os("DECKCRAFT_TREND_SHOT").is_some() { + let png = trend_gallery(true, true).to_png(); + std::fs::write("/tmp/deckcraft-trendlines.png", &png).expect("write trendline screenshot"); + } +} + #[test] fn placeholder_prompts_only_in_edit_view() { let p = Presentation::default(); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d8c0667e..43afc9e5 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (chart trendlines round-trip; previously trivial: groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (chart trendlines are drawn; previously minor: chart trendlines round-trip; groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested. Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready** @@ -72,7 +72,7 @@ the round trip even though DeckCraft doesn't model it. | Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip | | Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | | | Tables with styles | yes | yes | Merges and both diagonal borders round-trip | -| Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw`. Trendlines (type, order or period, forecast, intercept, equation and R²) are read and written; they are not drawn. A combo chart's secondary value axis is read and is not written back when the chart is rebuilt | +| Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw`. Trendlines (type, order or period, forecast, intercept, equation and R²) are read, written and drawn. A combo chart's secondary value axis is read and is not written back when the chart is rebuilt | | Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, box) | no | no | | | SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save | | Equations (OMML, `a14:m`) | no | no | PR #55 open | @@ -99,6 +99,7 @@ the round trip even though DeckCraft doesn't model it. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Chart trendlines are drawn (linear, exponential, logarithmic, polynomial, power, moving average, forecast, equation and R²) | | 2026-10-14 | minor | Chart trendlines round-trip (`corpus_charts.rs`); merges, diagonal borders, columns, autofit, vertical text and WordArt warp are in that slice | | 2026-10-12 | minor | EMF/WMF text and pattern brushes drawn; OLE (VML) and ActiveX previews read | | 2026-10-12 | minor | Text warps rendered; their adjust values read and written | diff --git a/docs/gaps.md b/docs/gaps.md index 20327e5a..c3b955b2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (chart trendlines round-trip; previous: minor, 3-D bevel, camera and face lighting rendered; IME composition and CJK interface text landed, #101; core-workflow alpha gate added; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (chart trendlines are drawn; previous: minor, chart trendlines round-trip; 3-D bevel, camera and face lighting rendered; IME composition and CJK interface text landed, #101; core-workflow alpha gate added; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every known shortfall, one entry each, ranked by how much it stops someone who presents with PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in @@ -148,9 +148,10 @@ Detail in [file-format-parity.md](file-format-parity.md). ### Charts: data grid, elements and the newer chart types - **Evidence:** checklist rows "Edit chart data" (P0, partial), "Chart elements" (partial), "Other chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, box & - whisker) aren't read. Trendlines round-trip (`corpus_charts.rs`) and are not drawn. A combo - chart's secondary value axis is read (`y2_*`, `Series.secondary_axis`) and is not written back - when the chart is rebuilt. + whisker) aren't read. Trendlines round-trip (`corpus_charts.rs`) and are drawn: linear, + exponential, logarithmic, polynomial, power and moving average, with forecast and the equation + and R² label. A combo chart's secondary value axis is read (`y2_*`, `Series.secondary_axis`) and + is not written back when the chart is rebuilt. - **Impact:** most business decks have charts; editing their data is routine. - **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h). @@ -260,6 +261,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Chart trendlines are drawn (six types, forecast, equation and R²). The charts gap stays open for the data grid, chartex and secondary-axis export; the real-deck corpus gap stays open | | 2026-10-14 | minor | Chart trendlines round-trip. The real-deck corpus gap stays open; secondary-axis export and drawing trendlines stay in the charts gap | | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) | | 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines | diff --git a/docs/roadmap.md b/docs/roadmap.md index 0321e67b..a291388d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # DeckCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (chart trendlines round-trip; the real-deck corpus stays open; previous: minor, core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (chart trendlines are drawn; the charts gap stays open for the data grid, chartex and secondary-axis export; previous: trivial, chart trendlines round-trip; the real-deck corpus stays open; core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Forward-looking: where DeckCraft is going and in what order. The one-page summary is [ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in @@ -109,6 +109,7 @@ plan above. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Chart trendlines are drawn. The charts gap stays open for the data grid, chartex and secondary-axis export; the real-deck corpus (item 2) stays open | | 2026-10-14 | trivial | Chart trendlines round-trip in an owned corpus slice; the real-deck corpus (item 2) stays open | | 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha | | 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history | From c176d42bb468388b57244254e85479a5df8e07ff Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 01:31:40 +0300 Subject: [PATCH 09/27] Charts: polar radar, stock high-low lines and up/down bars, combo sample Radar and filled radar charts are drawn polar: spokes per category, polygon gridlines and value labels up the first spoke. Stock charts draw high-low lines with close dashes, or up/down bars between open and close, and are saved as c:stockChart (hiLowLines, upDownBars) instead of a line chart; c:upDownBars is read, and chart.options takes upDownBars. New stock charts start as high-low-close and new combo charts as columns with a line. Co-authored-by: Cursor --- crates/engine/src/cmd/insert.rs | 6 +- crates/model/src/chart.rs | 20 ++++ crates/pptx/src/read/chart.rs | 5 +- crates/pptx/src/write/chart.rs | 56 +++++++++- crates/pptx/tests/roundtrip.rs | 34 ++++++ crates/render/src/chart.rs | 18 ++- crates/render/src/lib.rs | 1 + crates/render/src/radar.rs | 187 ++++++++++++++++++++++++++++++++ crates/render/src/tests.rs | 49 +++++++++ docs/gaps.md | 5 +- 10 files changed, 371 insertions(+), 10 deletions(-) create mode 100644 crates/render/src/radar.rs diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 8a0a7d28..5d6b334c 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -73,7 +73,7 @@ pub fn specs() -> Vec { "Chart Elements", ["Chart Design", "Chart Layouts"], None, - "{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], quartiles?: exclusive|inclusive, id?}", + "{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], quartiles?: exclusive|inclusive, upDownBars?: bool, id?}", has_selection, chart_options ), @@ -733,6 +733,10 @@ fn chart_options(s: &mut Session, p: &Value) -> Result { t.dedup(); c.subtotals = t; } + if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) { + c.up_down_bars = b; + c.raw = None; + } if let Some(q) = p.get("quartiles").and_then(Value::as_str) { c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive"); c.raw = None; diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index 5b646f53..84a7d27f 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -217,6 +217,10 @@ pub struct Chart { /// Box and whisker quartiles by the inclusive method (QUARTILE.INC) instead of exclusive. #[serde(skip_serializing_if = "std::ops::Not::not")] pub inclusive_quartiles: bool, + /// A stock chart's up/down bars (`c:upDownBars`): open-high-low-close, the box between + /// the first and last series, hollow when the price rose. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub up_down_bars: bool, /// A histogram's bin count (`cx:binCount`); with neither this nor `bin_size` the bins are /// automatic (Scott's rule, as Excel does). #[serde(skip_serializing_if = "Option::is_none")] @@ -266,6 +270,7 @@ impl Default for Chart { subtotals: vec![], category_levels: vec![], inclusive_quartiles: false, + up_down_bars: false, bin_count: None, bin_size: None, raw: None, @@ -309,6 +314,15 @@ impl Chart { c.legend = (kind == ChartType::BoxWhisker).then(|| "b".to_string()); return c; } + if kind == ChartType::Stock { + // High-low-close over five days, the series in the order PowerPoint expects. + c.categories = ["Mon", "Tue", "Wed", "Thu", "Fri"].iter().map(|s| s.to_string()).collect(); + let hlc = [("High", [55.0, 57.0, 57.0, 58.0, 58.0]), ("Low", [11.0, 12.0, 13.0, 11.0, 25.0]), ("Close", [25.0, 38.0, 50.0, 35.0, 43.0])]; + for (name, v) in hlc { + c.series.push(Series { name: name.into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }); + } + return c; + } let n_series = if matches!(kind, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Waterfall | ChartType::Treemap) { 1 } else { 3 }; for s in 0..n_series { @@ -320,6 +334,12 @@ impl Chart { }); } c.vary_colors = n_series == 1; + if kind == ChartType::Combo { + // Columns with the last series as a line, as PowerPoint's combo starts. + for (i, s) in c.series.iter_mut().enumerate() { + s.kind = Some(if i + 1 == n_series { ChartType::Line } else { ChartType::Column }); + } + } c } /// A series' own chart type: its own in a combo chart (columns when it names none), else the diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 7cecf457..63e7fa24 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -166,7 +166,10 @@ pub(crate) fn chart_from(root: &El) -> Option { ChartType::Radar } } - "stockChart" => ChartType::Stock, + "stockChart" => { + c.up_down_bars = g.child("upDownBars").is_some(); + ChartType::Stock + } "surfaceChart" | "surface3DChart" => ChartType::Surface, _ => continue, }; diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 7b8c1718..4f399d33 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -121,11 +121,13 @@ enum Group { Scatter, Radar, Bubble, + Stock, } fn group_of(k: ChartType) -> Group { match k { - ChartType::Line | ChartType::LineMarkers | ChartType::StackedLine | ChartType::Stock => Group::Line, + ChartType::Line | ChartType::LineMarkers | ChartType::StackedLine => Group::Line, + ChartType::Stock => Group::Stock, ChartType::Area | ChartType::StackedArea | ChartType::Surface => Group::Area, ChartType::Pie => Group::Pie, ChartType::Doughnut | ChartType::Sunburst => Group::Doughnut, @@ -143,7 +145,32 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) w.open0("c:tx"); str_cache(w, &format!("Sheet1!${}$1", col(i + 1)), std::slice::from_ref(&s.name)); w.close("c:tx"); - sp_pr(w, s.fill.as_ref(), s.line.as_ref()); + if g == Group::Stock && s.line.is_none() { + // A stock chart's series are points joined by the high-low lines, not lines of their own. + w.open0("c:spPr"); + w.open("a:ln", A::new().a("w", 19050)); + w.empty0("a:noFill"); + w.close("a:ln"); + w.close("c:spPr"); + } else { + sp_pr(w, s.fill.as_ref(), s.line.as_ref()); + } + if g == Group::Stock { + // High-low-close: the close is a dash on the high-low line; with up/down bars (or a set + // marker) the series keep their own. + let close = !c.up_down_bars && i + 1 == c.series.len() && c.series.len() == 3; + let sym = s.marker.as_deref().filter(|m| !m.is_empty() && m.chars().all(|ch| ch.is_ascii_alphanumeric())).unwrap_or(if close { + "dash" + } else { + "none" + }); + w.open0("c:marker"); + w.val("c:symbol", sym); + if sym != "none" { + w.val("c:size", 7); + } + w.close("c:marker"); + } if g == Group::Bar { w.val("c:invertIfNegative", 0); } @@ -323,6 +350,7 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)]) Group::Scatter => "c:scatterChart", Group::Radar => "c:radarChart", Group::Bubble => "c:bubbleChart", + Group::Stock => "c:stockChart", }; w.open0(tag); match g { @@ -335,7 +363,10 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)]) Group::Radar => w.val("c:radarStyle", if kind == ChartType::FilledRadar { "filled" } else { "marker" }), _ => {} } - w.val("c:varyColors", vary); + // `c:stockChart` has no `c:varyColors`. + if g != Group::Stock { + w.val("c:varyColors", vary); + } for (i, s) in list { series(w, c, s, *i, kind, g); } @@ -351,6 +382,24 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)]) } } Group::Line => w.val("c:marker", 1), + Group::Stock => { + w.open0("c:hiLowLines"); + w.open0("c:spPr"); + w.open("a:ln", A::new().a("w", 9525)); + w.open0("a:solidFill"); + w.empty("a:schemeClr", A::new().a("val", "tx1")); + w.close("a:solidFill"); + w.close("a:ln"); + w.close("c:spPr"); + w.close("c:hiLowLines"); + if c.up_down_bars { + w.open0("c:upDownBars"); + w.val("c:gapWidth", 150); + w.empty0("c:upBars"); + w.empty0("c:downBars"); + w.close("c:upDownBars"); + } + } Group::Pie => w.val("c:firstSliceAng", 0), Group::Doughnut => { w.val("c:firstSliceAng", 0); @@ -475,6 +524,7 @@ pub fn chart_xml(c: &Chart) -> Vec { | ChartType::Bubble | ChartType::Radar | ChartType::FilledRadar + | ChartType::Stock ) { log::warn!("pptx: {} chart written as a column chart", c.kind.label()); } diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index ae144dd0..83a76180 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -1197,3 +1197,37 @@ fn chartex_levels_and_quartiles_round_trip() { assert_eq!(chart(&deckcraft_pptx::import(&again).unwrap()).unwrap().category_levels, before.category_levels, "{ty}"); } } + +/// Radar, stock (with and without up/down bars) and combo charts keep their own plot groups +/// through a save and reopen, written as PowerPoint writes them. +#[test] +fn radar_stock_and_combo_round_trip() { + use deckcraft_engine::Session; + let chart = |d: &Presentation| -> Option { + d.slides.iter().flat_map(|s| s.shapes.iter()).find_map(|sh| match &sh.kind { + ShapeKind::Chart(c) => Some((**c).clone()), + _ => None, + }) + }; + for (ty, bars) in [("radar", false), ("filledradar", false), ("stock", false), ("stock", true), ("combo", false)] { + let mut s = Session::with_new(); + s.execute("slide.new", &serde_json::json!({"layout": "blank"})).unwrap(); + s.execute("insert.chart", &serde_json::json!({"type": ty})).unwrap(); + let id = s.doc().unwrap().selection.shapes[0].0; + if bars { + s.execute("chart.options", &serde_json::json!({"id": id, "upDownBars": true})).unwrap(); + } + let before = chart(&s.doc().unwrap().doc).unwrap(); + let out = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap(); + let after = chart(&deckcraft_pptx::import(&out).unwrap()).unwrap(); + assert_eq!(after.kind, before.kind, "{ty}"); + assert_eq!(after.up_down_bars, bars, "{ty}"); + assert_eq!(after.series.len(), before.series.len(), "{ty}"); + let kinds: Vec<_> = after.series.iter().map(|x| after.series_kind(x)).collect(); + let want: Vec<_> = before.series.iter().map(|x| before.series_kind(x)).collect(); + assert_eq!(kinds, want, "{ty}"); + for (a, b) in after.series.iter().zip(&before.series) { + assert_eq!(a.values, b.values, "{ty}"); + } + } +} diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index 140462b8..dda40b85 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -29,7 +29,7 @@ pub fn series_color(ctx: &Ctx, c: &Chart, i: usize) -> Rgba { } } -fn series_fill(ctx: &Ctx, c: &Chart, i: usize, s: Option<&deckcraft_model::chart::Series>) -> Rgba { +pub(crate) fn series_fill(ctx: &Ctx, c: &Chart, i: usize, s: Option<&deckcraft_model::chart::Series>) -> Rgba { match s.and_then(|s| s.fill.as_ref()) { Some(Fill::Solid { color: cc }) => ctx.color(cc, None), _ => series_color(ctx, c, i), @@ -38,7 +38,7 @@ fn series_fill(ctx: &Ctx, c: &Chart, i: usize, s: Option<&deckcraft_model::chart /// A line series' colour: its line's own (where PowerPoint keeps it), else its fill's, else the /// palette's. -fn series_line(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series) -> Rgba { +pub(crate) fn series_line(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series) -> Rgba { match s.line.as_ref().and_then(|l| l.fill.as_ref()) { Some(Fill::Solid { color: cc }) => ctx.color(cc, None), _ => series_fill(ctx, c, i, Some(s)), @@ -279,6 +279,10 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, crate::chartex::draw(ctx, rctx, c, m, area, base, text_col); return; } + if matches!(c.kind, ChartType::Radar | ChartType::FilledRadar) { + crate::radar::draw(ctx, rctx, c, m, area, base, text_col); + return; + } let combo = c.kind == ChartType::Combo; // Bars are drawn first and lines over them; a combo chart has both. let bar_kind = c.series.iter().map(|s| c.series_kind(s)).find(|k| k.is_bar_like()); @@ -510,11 +514,17 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, } } } - // Lines, areas, scatter, radar (drawn as lines): the series that aren't bars. + // A stock chart's high-low lines and up/down bars (or close dashes) stand in for its series. + if c.kind == ChartType::Stock { + for i in 0..ncat { + crate::radar::stock_marks(ctx, rctx, c, m, plot.x0 + band * (i as f64 + 0.5), band, i, &|v| to_axis(false, v)); + } + } + // Lines, areas, scatter: the series that aren't bars. let mut acc = vec![0.0; ncat]; for (si, s) in c.series.iter().enumerate() { let kind = c.series_kind(s); - if kind.is_bar_like() { + if kind.is_bar_like() || kind == ChartType::Stock { continue; } let sec = c.on_secondary(s); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e6570c5c..98b54a57 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -24,6 +24,7 @@ mod pattern; mod pathgrad; mod placed; +mod radar; mod ramp; mod table; mod transition; diff --git a/crates/render/src/radar.rs b/crates/render/src/radar.rs new file mode 100644 index 00000000..3416c7ce --- /dev/null +++ b/crates/render/src/radar.rs @@ -0,0 +1,187 @@ +//! Radar charts: one spoke per category, the value axis running up the first spoke, a polygon +//! of gridlines per major unit and each series a closed polygon (filled for a filled radar). + +use deckcraft_color::Rgba; +use deckcraft_model::chart::{Chart, ChartType}; +use deckcraft_model::resolve::Ctx; +use deckcraft_model::text::{Align, Anchor}; +use kurbo::{Affine, BezPath, Point, Rect, Shape as _}; +use vello_cpu::RenderContext; + +use crate::chart::{label, series_fill, series_line}; +use crate::color; + +/// The point at `frac` of the radius along spoke `i` of `n` (the first spoke points up). +fn spoke(cen: Point, r: f64, i: usize, n: usize, frac: f64) -> Point { + let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * i as f64 / n.max(1) as f64; + Point::new(cen.x + r * frac * a.cos(), cen.y + r * frac * a.sin()) +} + +pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, area: Rect, base: f64, text_col: Rgba) { + let n = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)); + if n < 3 { + // Fewer than three spokes make no polygon; PowerPoint draws a line, we draw nothing. + return; + } + let grid_col = Rgba::rgb(0xD9, 0xD9, 0xD9); + // Room for the category labels around the outside. + let r = (area.width().min(area.height()) / 2.0 - base * 1.9).max(base); + let cen = area.center(); + let (min, max, step) = c.axis_scale(false, 5).unwrap_or((0.0, 1.0, 0.2)); + let span = (max - min).max(1e-9); + let ticks = (((max - min) / step.max(1e-12)).round() as i64).clamp(1, 20); + ctx.set_transform(m); + ctx.set_stroke(kurbo::Stroke::new(0.75)); + ctx.set_paint(color(grid_col, 1.0)); + if c.gridlines { + for k in 1..=ticks { + let frac = k as f64 / ticks as f64; + let mut p = BezPath::new(); + for i in 0..n { + let q = spoke(cen, r, i, n, frac); + if i == 0 { p.move_to(q) } else { p.line_to(q) } + } + p.close_path(); + ctx.stroke_path(&p); + } + } + for i in 0..n { + let mut p = BezPath::new(); + p.move_to(cen); + p.line_to(spoke(cen, r, i, n, 1.0)); + ctx.set_transform(m); + ctx.set_paint(color(grid_col, 1.0)); + ctx.stroke_path(&p); + if let Some(name) = c.categories.get(i) { + let q = spoke(cen, r + base * 1.3, i, n, 1.0); + let w = name.chars().count() as f64 * base * 0.55 + base; + let align = if (q.x - cen.x).abs() < base { + Align::Center + } else if q.x > cen.x { + Align::Left + } else { + Align::Right + }; + let x0 = match align { + Align::Left => q.x - base * 0.3, + Align::Right => q.x - w + base * 0.3, + _ => q.x - w / 2.0, + }; + label(ctx, rctx, m, name, Rect::new(x0, q.y - base * 0.7, x0 + w, q.y + base * 0.7), base, align, Anchor::Middle, text_col, false); + } + } + // Value labels up the first spoke. + if !c.y_hidden { + for k in 0..=ticks { + let v = min + step * k as f64; + let q = spoke(cen, r, 0, n, k as f64 / ticks as f64); + let t = c.axis_label(false, v); + let w = t.chars().count() as f64 * base * 0.55 + base * 0.4; + label( + ctx, + rctx, + m, + &t, + Rect::new(q.x - w - base * 0.3, q.y - base * 0.6, q.x - base * 0.3, q.y + base * 0.6), + base * 0.9, + Align::Right, + Anchor::Middle, + text_col, + false, + ); + } + } + let filled = c.kind == ChartType::FilledRadar; + for (si, s) in c.series.iter().enumerate() { + let pts: Vec = (0..n) + .map(|i| { + let v = s.values.get(i).copied().flatten().unwrap_or(min); + spoke(cen, r, i, n, ((v - min) / span).clamp(0.0, 1.2)) + }) + .collect(); + let mut p = BezPath::new(); + for (i, q) in pts.iter().enumerate() { + if i == 0 { p.move_to(*q) } else { p.line_to(*q) } + } + p.close_path(); + ctx.set_transform(m); + if filled { + ctx.set_paint(color(series_fill(rctx, c, si, Some(s)), 0.75)); + ctx.fill_path(&p); + } else { + let col = series_line(rctx, c, si, s); + let lw = s.line.as_ref().and_then(|l| l.width).filter(|w| w.is_finite() && *w > 0.0).unwrap_or(2.25).clamp(0.25, 20.0); + ctx.set_paint(color(col, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(lw).with_join(kurbo::Join::Round)); + ctx.stroke_path(&p); + if s.marker.is_some() { + for q in &pts { + ctx.fill_path(&kurbo::Circle::new(*q, 3.5).to_path(0.1)); + } + } + } + if c.data_labels || s.data_labels == Some(true) { + for (i, q) in pts.iter().enumerate() { + if let Some(v) = s.values.get(i).copied().flatten() { + let t = c.data_label(s, v); + label( + ctx, + rctx, + m, + &t, + Rect::new(q.x - 40.0, q.y - base * 1.4, q.x + 40.0, q.y - base * 0.2), + base * 0.9, + Align::Center, + Anchor::Bottom, + text_col, + false, + ); + } + } + } + } +} + +/// A stock chart's marks at one category: the high-low line, then the up/down bar between the +/// first (open) and last (close) series, else a dash at the close. +#[allow(clippy::too_many_arguments)] +pub fn stock_marks(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, x: f64, band: f64, i: usize, to_y: &dyn Fn(f64) -> f64) { + let vals: Vec = c.series.iter().filter_map(|s| s.values.get(i).copied().flatten()).collect(); + if vals.len() < 2 { + return; + } + let hi = vals.iter().copied().fold(f64::MIN, f64::max); + let lo = vals.iter().copied().fold(f64::MAX, f64::min); + let ink = Rgba::rgb(0x40, 0x40, 0x40); + ctx.set_transform(m); + ctx.set_paint(color(ink, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(1.0)); + let mut p = BezPath::new(); + p.move_to((x, to_y(hi))); + p.line_to((x, to_y(lo))); + ctx.stroke_path(&p); + let open = c.series.first().and_then(|s| s.values.get(i).copied().flatten()); + let close = c.series.last().and_then(|s| s.values.get(i).copied().flatten()); + if c.up_down_bars && c.series.len() >= 4 { + let (Some(o), Some(cl)) = (open, close) else { return }; + let w = band * 0.4; + let (y0, y1) = (to_y(o.max(cl)), to_y(o.min(cl))); + let r = Rect::new(x - w / 2.0, y0, x + w / 2.0, y1.max(y0 + 1.0)); + // PowerPoint's default: white when the price rose, dark when it fell. + ctx.set_paint(color(if cl >= o { Rgba::WHITE } else { ink }, 1.0)); + ctx.fill_rect(&r); + ctx.set_paint(color(ink, 1.0)); + ctx.stroke_path(&r.to_path(0.1)); + return; + } + if let Some(cl) = close { + let col = c.series.last().map(|s| series_line(rctx, c, c.series.len() - 1, s)).unwrap_or(ink); + let y = to_y(cl); + let mut d = BezPath::new(); + d.move_to((x - band * 0.12, y)); + d.line_to((x + band * 0.12, y)); + ctx.set_paint(color(col, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(2.5)); + ctx.stroke_path(&d); + } +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 28fe9887..ce8f4b3a 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1834,3 +1834,52 @@ fn region_map_draws_tiles_on_its_colour_scale() { } assert!(seen.len() >= 5, "{} colours", seen.len()); } + +/// Radar charts are polar, stock charts draw high-low lines (and up/down bars), and a combo +/// chart draws its line series over its columns. +#[test] +fn radar_stock_and_combo_draw_their_own_layouts() { + let mut ohlc = Chart::sample(ChartType::Stock); + ohlc.series.insert( + 0, + deckcraft_model::chart::Series { + name: "Open".into(), + values: [44.0, 25.0, 38.0, 50.0, 35.0].iter().map(|v| Some(*v)).collect(), + ..Default::default() + }, + ); + ohlc.up_down_bars = true; + let charts = [ + ("Radar", Chart::sample(ChartType::Radar)), + ("FilledRadar", Chart::sample(ChartType::FilledRadar)), + ("Stock", Chart::sample(ChartType::Stock)), + ("StockOHLC", ohlc), + ("Combo", Chart::sample(ChartType::Combo)), + ]; + for (name, c) in charts { + let sh = Shape { + id: ShapeId(625), + xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), + kind: ShapeKind::Chart(Box::new(c.clone())), + ..Default::default() + }; + let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + if let Ok(dir) = std::env::var("DECKCRAFT_CHART_SHOTS") { + let _ = std::fs::write(format!("{dir}/{name}.png"), img.to_png()); + } + let ink = |x: u32, y: u32| img.pixel(x, y)[..3] != [255, 255, 255]; + match name { + // The radar's top spoke runs straight up from the centre; a line chart has nothing there. + "Radar" | "FilledRadar" => assert!((150..260).any(|y| ink(480, y)), "{name}"), + // No series lines: well right of the first category there is only gridline grey. + "Stock" | "StockOHLC" => { + let coloured = (60..480).step_by(2).flat_map(|y| (40..920).step_by(4).map(move |x| (x, y))).any(|(x, y)| { + let p = img.pixel(x, y); + p[2] > p[0].saturating_add(60) + }); + assert!(!coloured || name == "Stock", "{name}: series drawn as lines"); + } + _ => {} + } + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 85dae7b9..3cff27e0 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -152,7 +152,10 @@ Detail in [file-format-parity.md](file-format-parity.md). have their own layouts, histograms and Paretos bin their data (count, width or Scott's rule), and box & whisker charts use exclusive or inclusive quartiles with 1.5 IQR whiskers. Region maps are kept (their cached geography too, also through edits) and drawn as value-coloured tiles with a - colour scale, since DeckCraft ships no map boundaries. + colour scale, since DeckCraft ships no map boundaries. Radar charts are drawn polar (filled + or lines), stock charts as high-low lines with close dashes or up/down bars and saved as + `c:stockChart`, and combo charts start as columns with a line. Secondary-axis writing and + trendlines are tracked separately. - **Impact:** most business decks have charts; editing their data is routine. - **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h). From 7e4e628b1353ca61d229a178fcb8ae1a9ca15827 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 01:32:31 +0300 Subject: [PATCH 10/27] =?UTF-8?q?Render:=20omit=20the=20moving-average=20R?= =?UTF-8?q?=C2=B2=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PowerPoint does not show R² for a moving average, and the value can be negative. The line is still drawn. --- crates/model/src/trendline.rs | 15 ++++++++++++--- docs/gaps.md | 4 +++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/model/src/trendline.rs b/crates/model/src/trendline.rs index eeddd2bd..f893a764 100644 --- a/crates/model/src/trendline.rs +++ b/crates/model/src/trendline.rs @@ -137,8 +137,9 @@ impl Trendline { Some(TrendFit { kind: self.kind, model, r2: r_squared(&used_y, &yhat), x_min, x_max }) } - /// The equation and R² lines this trendline asks to show. Empty when neither is on, and a - /// moving average has no equation. + /// The equation and R² lines this trendline asks to show. Empty when neither is on. A moving + /// average has no equation, and PowerPoint does not show its R², so that label is left off + /// even when the file asks for it. pub fn caption(&self, fit: &TrendFit) -> String { let mut lines = Vec::new(); if self.disp_eq @@ -146,7 +147,7 @@ impl Trendline { { lines.push(eq); } - if self.disp_r_sqr { + if self.disp_r_sqr && self.kind != TrendlineType::MovingAvg { lines.push(fit.r2_label()); } lines.join("\n") @@ -599,6 +600,14 @@ mod tests { assert_eq!(fit.span(Some(4.0), Some(4.0)), (2.0, 4.0)); } + #[test] + fn moving_average_shows_neither_equation_nor_r2() { + let t = Trendline { kind: TrendlineType::MovingAvg, period: Some(2), disp_eq: true, disp_r_sqr: true, ..Default::default() }; + let fit = t.fit(&[1.0, 2.0, 3.0, 4.0], &[2.0, 8.0, 4.0, 10.0]).expect("ma"); + assert!(fit.r2_label().starts_with("R² =")); + assert!(t.caption(&fit).is_empty(), "{}", t.caption(&fit)); + } + #[test] fn too_few_points_and_non_finite_input_fit_nothing() { assert!(line(TrendlineType::Linear).fit(&[1.0], &[2.0]).is_none()); diff --git a/docs/gaps.md b/docs/gaps.md index c3b955b2..831deb9e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -150,7 +150,8 @@ Detail in [file-format-parity.md](file-format-parity.md). chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, box & whisker) aren't read. Trendlines round-trip (`corpus_charts.rs`) and are drawn: linear, exponential, logarithmic, polynomial, power and moving average, with forecast and the equation - and R² label. A combo chart's secondary value axis is read (`y2_*`, `Series.secondary_axis`) and + and R² label. A moving average is drawn with neither label. A combo chart's secondary value axis + is read (`y2_*`, `Series.secondary_axis`) and is not written back when the chart is rebuilt. - **Impact:** most business decks have charts; editing their data is routine. - **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h). @@ -261,6 +262,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | A moving average trendline is drawn without an equation or an R² label | | 2026-10-14 | minor | Chart trendlines are drawn (six types, forecast, equation and R²). The charts gap stays open for the data grid, chartex and secondary-axis export; the real-deck corpus gap stays open | | 2026-10-14 | minor | Chart trendlines round-trip. The real-deck corpus gap stays open; secondary-axis export and drawing trendlines stay in the charts gap | | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) | From 5fc8aaee7c1abf5edc2b07ea481af33864d63a06 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 02:47:47 +0300 Subject: [PATCH 11/27] Metafile: EMF+ cardinal curves and region fills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DrawCurve, DrawClosedCurve and FillClosedCurve (GDI+ cardinal splines, the smoothed lines of charts and freehand shapes) draw as cubic Béziers with the record's tension, offset and segment count; FillRegion fills the region object's area. These records were skipped, so the shapes were missing. Co-authored-by: Cursor --- crates/metafile/src/emfplus.rs | 65 ++++++++++++++++++++++++++++++++++ crates/metafile/src/tests.rs | 60 +++++++++++++++++++++++++++++++ crates/render/src/tests.rs | 15 ++++++++ 3 files changed, 140 insertions(+) diff --git a/crates/metafile/src/emfplus.rs b/crates/metafile/src/emfplus.rs index 48d1af89..064ce609 100644 --- a/crates/metafile/src/emfplus.rs +++ b/crates/metafile/src/emfplus.rs @@ -328,6 +328,39 @@ impl Plus { self.painter().stroke(cv, &ops, pen); Some(()) } + // Cardinal splines: FillClosedCurve, DrawClosedCurve, DrawCurve. + 0x4016 => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let tension = finite(r.f32(4)?)?; + let pts = points(r, 12, r.u32(8)? as usize, compressed)?; + self.painter().fill(cv, &curve_ops(&pts, tension, true, 0, usize::MAX), &fill); + Some(()) + } + 0x4017 => { + let pen = self.pen(id)?; + let tension = finite(r.f32(0)?)?; + let pts = points(r, 8, r.u32(4)? as usize, compressed)?; + self.painter().stroke(cv, &curve_ops(&pts, tension, true, 0, usize::MAX), pen); + Some(()) + } + 0x4018 => { + let pen = self.pen(id)?; + let tension = finite(r.f32(0)?)?; + let (offset, segments) = (r.u32(4)? as usize, r.u32(8)? as usize); + let pts = points(r, 16, r.u32(12)? as usize, compressed)?; + self.painter().stroke(cv, &curve_ops(&pts, tension, false, offset, segments), pen); + Some(()) + } + // FillRegion: the region's area (as the clip reads it). + 0x4013 => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let Some(Some(Obj::Region(Some(area)))) = self.objects.get(id).cloned() else { return None }; + if area.len() < 3 { + return None; + } + self.painter().fill(cv, &poly_ops(&area, true), &fill); + Some(()) + } 0x401A => { let img = self.image(id)?; let dst = *rects(r, 24, 1, compressed)?.first()?; @@ -1246,6 +1279,38 @@ fn rect_ops([l, t, r, b]: [f64; 4]) -> Vec { crate::geom::rect(l, t, r, b) } +fn finite(v: f32) -> Option { + v.is_finite().then_some(f64::from(v)) +} + +/// A GDI+ cardinal spline through `pts` as cubic Béziers: each segment's control points lie a +/// third of `tension` (GDI+'s default 0.5) along the neighbouring chords. `closed` joins the +/// last point back to the first; an open curve draws `segments` segments from point `offset`. +fn curve_ops(pts: &[P], tension: f64, closed: bool, offset: usize, segments: usize) -> Vec { + let n = pts.len(); + if n < 2 { + return Vec::new(); + } + let k = tension.clamp(0.0, 10.0) / 3.0; + let at = |i: isize| -> P { + let i = if closed { i.rem_euclid(n as isize) as usize } else { i.clamp(0, n as isize - 1) as usize }; + pts.get(i).copied().unwrap_or((0.0, 0.0)) + }; + let (first, count) = if closed { (0, n) } else { (offset.min(n - 1), segments.min(n - 1 - offset.min(n - 1))) }; + let mut ops = vec![Op::M(at(first as isize))]; + for s in first..first + count { + let i = s as isize; + let (p0, p1, p2, p3) = (at(i - 1), at(i), at(i + 1), at(i + 2)); + let c1 = (p1.0 + (p2.0 - p0.0) * k, p1.1 + (p2.1 - p0.1) * k); + let c2 = (p2.0 - (p3.0 - p1.0) * k, p2.1 - (p3.1 - p1.1) * k); + ops.push(Op::C(c1, c2, p2)); + } + if closed { + ops.push(Op::Z); + } + ops +} + fn poly_ops(pts: &[P], close: bool) -> Vec { let mut ops: Vec = pts.iter().enumerate().map(|(i, p)| if i == 0 { Op::M(*p) } else { Op::L(*p) }).collect(); if close { diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 7ad5071b..43789e3d 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -1849,3 +1849,63 @@ fn emfplus_nested_metafile_image_draws_its_items_into_the_destination() { assert_eq!(fill, Some([255, 255, 255, 255])); assert!(segs.contains(&Seg::Line(40.0, 20.0)), "{segs:?}"); } + +/// Curves and a region fill, as an EMF+ only file (also written to `$DECKCRAFT_EMF_OUT` to look at). +fn curves_file() -> Vec { + let pen = prec(0x4008, 0x0200 | 1, &solid_pen(0xFF20_60C0, 3.0)); + // A closed curve through a diamond's corners, filled orange. + let diamond = f32s(&[100.0, 10.0, 190.0, 100.0, 100.0, 190.0, 10.0, 100.0]); + let fill = prec(0x4016, 0x8000, &cat(vec![h32(0xFFE0_8020), f32s(&[0.5]), h32(4), diamond.clone()])); + let outline = prec(0x4017, 1, &cat(vec![f32s(&[0.5]), h32(4), diamond])); + // An open curve (a smoothed line chart): all four segments of five points. + let line = + prec(0x4018, 1, &cat(vec![f32s(&[0.5]), h32(0), h32(4), h32(5), f32s(&[210.0, 150.0, 260.0, 60.0, 310.0, 120.0, 360.0, 30.0, 410.0, 90.0])])); + // A green rectangle region, filled. + let region = cat(vec![h32(0xDBC0_1002), h32(0), h32(0x1000_0000), f32s(&[220.0, 160.0, 180.0, 30.0])]); + let fill_region = prec(0x4013, 0x8000 | 2, &h32(0xFF30_A040)); + let mut all = vec![plus_header(false), pen, fill, outline, line, prec(0x4008, 0x0400 | 2, ®ion), fill_region]; + all.push(prec(0x4002, 0, &[])); + emf(&[plus_comment(&all)], [0, 0, 420, 200], [0; 4]) +} + +#[test] +fn emfplus_cardinal_curves_and_region_fill() { + let bytes = curves_file(); + if let Ok(path) = std::env::var("DECKCRAFT_EMF_OUT") { + let _ = std::fs::write(path, &bytes); + } + let p = parse(&bytes).unwrap(); + assert_eq!(p.items.len(), 4, "{:?}", p.items.len()); + // The closed curve: four cubics through the diamond's corners, closed. + let (segs, fill, ..) = path_segs(&p.items[0]); + assert_eq!(fill, Some([0xE0, 0x80, 0x20, 0xFF])); + let cubics: Vec<(f32, f32)> = segs.iter().filter_map(|s| if let Seg::Cubic(.., x, y) = s { Some((*x, *y)) } else { None }).collect(); + assert_eq!(cubics.len(), 4, "{segs:?}"); + assert_eq!(segs.last(), Some(&Seg::Close)); + // The open curve: four cubics and no close; its controls bulge past the straight chords. + let (segs, fill, stroke, _) = path_segs(&p.items[2]); + assert_eq!((fill, stroke.map(|s| s.0)), (None, Some([0x20, 0x60, 0xC0, 0xFF]))); + assert_eq!(segs.iter().filter(|s| matches!(s, Seg::Cubic(..))).count(), 4); + assert!(!segs.contains(&Seg::Close)); + // The region fill is its rectangle. + let (segs, fill, ..) = path_segs(&p.items[3]); + assert_eq!(fill, Some([0x30, 0xA0, 0x40, 0xFF])); + assert_eq!(segs.iter().filter(|s| matches!(s, Seg::Line(..))).count(), 3, "{segs:?}"); +} + +#[test] +fn emfplus_curve_offsets_and_degenerate_input_are_safe() { + let pen = prec(0x4008, 0x0200 | 1, &solid_pen(0xFF00_0000, 1.0)); + // Segments from point 2 of 5, asking for more than there are: two segments. + let line = prec(0x4018, 1, &cat(vec![f32s(&[0.5]), h32(2), h32(99), h32(5), f32s(&[0.0, 0.0, 10.0, 5.0, 20.0, 0.0, 30.0, 5.0, 40.0, 0.0])])); + let p = plus_pic(vec![pen.clone(), line]); + let (segs, ..) = path_segs(&p.items[0]); + assert_eq!(segs.iter().filter(|s| matches!(s, Seg::Cubic(..))).count(), 2, "{segs:?}"); + // One point, an offset past the end, a NaN tension, a missing region: nothing, no panic. + let one = prec(0x4018, 1, &cat(vec![f32s(&[0.5]), h32(0), h32(1), h32(1), f32s(&[1.0, 1.0])])); + let past = prec(0x4018, 1, &cat(vec![f32s(&[0.5]), h32(9), h32(9), h32(2), f32s(&[0.0, 0.0, 9.0, 9.0])])); + let nan = prec(0x4017, 1, &cat(vec![f32s(&[f32::NAN]), h32(3), f32s(&[0.0, 0.0, 9.0, 9.0, 0.0, 9.0])])); + let no_region = prec(0x4013, 0x8000 | 7, &h32(0xFF00_0000)); + let p = plus_pic(vec![pen, one, past, nan, no_region]); + assert!(p.items.iter().all(|i| path_segs(i).0.iter().filter(|s| matches!(s, Seg::Cubic(..))).count() == 0)); +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 0de3d695..40bb76c1 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1711,3 +1711,18 @@ fn east_asian_vertical_text_stands_ideographs_upright() { let (w, h) = ink(deckcraft_model::text::TextDir::Vertical); assert!(h > w * 3, "vert: stroke on its side {w}x{h}"); } + +/// Renders the EMF at `$DECKCRAFT_EMF_IN` to `$DECKCRAFT_EMF_PNG` (a look-at helper; does nothing +/// without them). +#[test] +fn render_an_emf_file_to_look_at() { + let (Ok(src), Ok(out)) = (std::env::var("DECKCRAFT_EMF_IN"), std::env::var("DECKCRAFT_EMF_PNG")) else { return }; + let Ok(bytes) = std::fs::read(&src) else { return }; + let mut p = Presentation::default(); + let media = p.add_media("image1.emf", "image/x-emf", bytes); + let fill = deckcraft_model::style::PictureFill { media, ..Default::default() }; + let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(40.0, 40.0, 840.0, 400.0)), kind: ShapeKind::Picture { fill }, ..Default::default() }; + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh]; + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let _ = std::fs::write(out, img.to_png()); +} From fea8c1498f60d209c77c8bb466511bb356cb7a90 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 02:52:57 +0300 Subject: [PATCH 12/27] Metafile: EMF+ Clear, and the EMF+ record coverage written down EmfPlusClear paints the whole picture frame in its colour, ignoring the world transform and clip, as GDI+ does; a transparent clear draws nothing. With it every drawing record of MS-EMFPLUS 2.3.4 is played. The module docs list what is deliberately ignored (rendering hints, rendering origin, serializable image effects, terminal-server records, and StrokeFillPath, which the specification names but gives no layout for), and the parity docs no longer say EMF+ isn't drawn. Co-authored-by: Cursor --- crates/metafile/src/emf.rs | 1 + crates/metafile/src/emfplus.rs | 40 ++++++++++++++++++++++++++++++---- crates/metafile/src/tests.rs | 39 ++++++++++++++++++++++++++++----- docs/file-format-parity.md | 2 +- docs/gaps.md | 3 ++- 5 files changed, 74 insertions(+), 11 deletions(-) diff --git a/crates/metafile/src/emf.rs b/crates/metafile/src/emf.rs index 21ad9b62..6190237e 100644 --- a/crates/metafile/src/emf.rs +++ b/crates/metafile/src/emf.rs @@ -43,6 +43,7 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option), Error> { cv.set_device_mm(px_mm); } let mut plus = crate::emfplus::Plus::new(); + plus.frame = frame.as_ref().map(|f| f.rect); let mut parsed = 0usize; let mut pos = hsize.max(88); while !cv.full() { diff --git a/crates/metafile/src/emfplus.rs b/crates/metafile/src/emfplus.rs index 064ce609..ce10447e 100644 --- a/crates/metafile/src/emfplus.rs +++ b/crates/metafile/src/emfplus.rs @@ -11,10 +11,17 @@ //! - world and page transforms, save/restore and containers; //! - arcs and pies; //! - clipping to rectangles and convex clip paths and regions (others: their bounding rectangle); -//! - nested metafile images, and glyph-index driver strings in the Windows core fonts. +//! - nested metafile images, and glyph-index driver strings in the Windows core fonts; +//! - cardinal splines (DrawCurve, DrawClosedCurve, FillClosedCurve), FillRegion, and Clear +//! (over the picture frame). //! -//! Not drawn yet: image attributes, glyph indices of other fonts, and region combinations other -//! than intersect and union (XOR, exclude and complement keep the left area). +//! Every drawing record of MS-EMFPLUS §2.3.4 is played. Not drawn: image attributes, glyph +//! indices of other fonts, and region combinations other than intersect and union (XOR, exclude +//! and complement keep the left area). Deliberately ignored: rendering hints (antialias, text +//! hint, interpolation, pixel offset, compositing; the renderer has its own), SetRenderingOrigin +//! (hatch phase), SerializableObject image effects, the terminal-server records SetTSGraphics and +//! SetTSClip, and StrokeFillPath (0x4037), which the specification lists but gives no layout for +//! and GDI+ doesn't write. use std::sync::Arc; @@ -217,11 +224,23 @@ pub(crate) struct Plus { objects: Vec>, stack: Vec, partial: Option, + /// The picture frame in device units (`[x, y, w, h]`), what Clear paints. + pub frame: Option<[f64; 4]>, } impl Plus { pub fn new() -> Self { - Plus { active: false, dpi: 96.0, world: IDENTITY, page: 1.0, clip: None, objects: vec![None; MAX_OBJECTS], stack: Vec::new(), partial: None } + Plus { + active: false, + dpi: 96.0, + world: IDENTITY, + page: 1.0, + clip: None, + objects: vec![None; MAX_OBJECTS], + stack: Vec::new(), + partial: None, + frame: None, + } } /// Plays the EMF+ records of one EMR_COMMENT payload (after the identifier). @@ -351,6 +370,19 @@ impl Plus { self.painter().stroke(cv, &curve_ops(&pts, tension, false, offset, segments), pen); Some(()) } + // Clear: the whole picture in one colour, whatever the clip (a transparent clear + // leaves nothing to draw). + 0x4009 => { + let c = argb(r.u32(0)?); + let [x, y, w, h] = self.frame?; + if c[3] == 0 || !(w > 0.0 && h > 0.0) { + return Some(()); + } + let to_device = invert(mul(cv.world(), cv.device_affine()))?; + let p = Painter { xf: to_device, clip: None }; + p.fill(cv, &rect_ops([x, y, x + w, y + h]), &Fill::Solid(c)); + Some(()) + } // FillRegion: the region's area (as the clip reads it). 0x4013 => { let fill = self.brush_of(flags, r.u32(0)?)?; diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 43789e3d..80d41117 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -1863,7 +1863,9 @@ fn curves_file() -> Vec { // A green rectangle region, filled. let region = cat(vec![h32(0xDBC0_1002), h32(0), h32(0x1000_0000), f32s(&[220.0, 160.0, 180.0, 30.0])]); let fill_region = prec(0x4013, 0x8000 | 2, &h32(0xFF30_A040)); - let mut all = vec![plus_header(false), pen, fill, outline, line, prec(0x4008, 0x0400 | 2, ®ion), fill_region]; + // A cream background first. + let clear = prec(0x4009, 0, &h32(0xFFF4_EED8)); + let mut all = vec![plus_header(false), clear, pen, fill, outline, line, prec(0x4008, 0x0400 | 2, ®ion), fill_region]; all.push(prec(0x4002, 0, &[])); emf(&[plus_comment(&all)], [0, 0, 420, 200], [0; 4]) } @@ -1875,20 +1877,21 @@ fn emfplus_cardinal_curves_and_region_fill() { let _ = std::fs::write(path, &bytes); } let p = parse(&bytes).unwrap(); - assert_eq!(p.items.len(), 4, "{:?}", p.items.len()); + assert_eq!(p.items.len(), 5, "{:?}", p.items.len()); + assert_eq!(path_segs(&p.items[0]).1, Some([0xF4, 0xEE, 0xD8, 0xFF]), "the clear comes first"); // The closed curve: four cubics through the diamond's corners, closed. - let (segs, fill, ..) = path_segs(&p.items[0]); + let (segs, fill, ..) = path_segs(&p.items[1]); assert_eq!(fill, Some([0xE0, 0x80, 0x20, 0xFF])); let cubics: Vec<(f32, f32)> = segs.iter().filter_map(|s| if let Seg::Cubic(.., x, y) = s { Some((*x, *y)) } else { None }).collect(); assert_eq!(cubics.len(), 4, "{segs:?}"); assert_eq!(segs.last(), Some(&Seg::Close)); // The open curve: four cubics and no close; its controls bulge past the straight chords. - let (segs, fill, stroke, _) = path_segs(&p.items[2]); + let (segs, fill, stroke, _) = path_segs(&p.items[3]); assert_eq!((fill, stroke.map(|s| s.0)), (None, Some([0x20, 0x60, 0xC0, 0xFF]))); assert_eq!(segs.iter().filter(|s| matches!(s, Seg::Cubic(..))).count(), 4); assert!(!segs.contains(&Seg::Close)); // The region fill is its rectangle. - let (segs, fill, ..) = path_segs(&p.items[3]); + let (segs, fill, ..) = path_segs(&p.items[4]); assert_eq!(fill, Some([0x30, 0xA0, 0x40, 0xFF])); assert_eq!(segs.iter().filter(|s| matches!(s, Seg::Line(..))).count(), 3, "{segs:?}"); } @@ -1909,3 +1912,29 @@ fn emfplus_curve_offsets_and_degenerate_input_are_safe() { let p = plus_pic(vec![pen, one, past, nan, no_region]); assert!(p.items.iter().all(|i| path_segs(i).0.iter().filter(|s| matches!(s, Seg::Cubic(..))).count() == 0)); } + +#[test] +fn emfplus_clear_paints_the_whole_frame_and_transparent_clears_nothing() { + let dot = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[40.0, 40.0, 10.0, 10.0])])); + let clear = |argb: u32| prec(0x4009, 0, &h32(argb)); + // 100 × 50 device units (0..2500 × 0..1250 hundredths of a millimetre at 0.04 units each). + let file = |recs: Vec>| { + let mut all = vec![plus_header(false)]; + all.extend(recs); + parse(&emf(&[plus_comment(&all)], [0, 0, 99, 49], [0, 0, 2500, 1250])).unwrap() + }; + // A translated, clipped world doesn't move or cut the clear. + let shift = prec(0x402D, 0, &f32s(&[30.0, 30.0])); + let clip = prec(0x4032, 0, &f32s(&[0.0, 0.0, 5.0, 5.0])); + let p = file(vec![shift, clip, clear(0xFFFF_FFFF), prec(0x4031, 0, &[]), dot.clone()]); + assert_eq!(p.items.len(), 2); + let (segs, fill, ..) = path_segs(&p.items[0]); + assert_eq!(fill, Some([255, 255, 255, 255])); + let xs: Vec = segs.iter().filter_map(|s| if let Seg::Move(x, _) | Seg::Line(x, _) = s { Some(*x) } else { None }).collect(); + let span = xs.iter().cloned().fold(f32::MIN, f32::max) - xs.iter().cloned().fold(f32::MAX, f32::min); + assert!((span - p.width).abs() < 0.5, "clear spans {span} of {}", p.width); + // Transparent: only the dot. + assert_eq!(file(vec![clear(0x00FF_FFFF), dot.clone()]).items.len(), 1); + // No frame: nothing to clear. + assert_eq!(plus_pic(vec![clear(0xFFFF_FFFF), dot]).items.len(), 1); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1e888c7a..bff4d745 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -38,7 +38,7 @@ PowerPoint opens and saves; Viewer = opens only). |---|---|---|---| | PNG, JPEG, GIF (animated), BMP, TIFF, WebP | yes | yes | `image` 0.25; animated GIFs play since #39 | | SVG | yes (with PNG fallback) | **no** | The PNG fallback shows; SVG files can't be inserted | -| EMF / WMF | yes | partial | Drawn as vectors (paths, pens, pattern and hatch brushes, bitmaps, text in the document's fonts with the file's character advances); clip regions and EMF+ records are not drawn. Legacy OLE objects (VML preview) and ActiveX controls show their pictures | +| EMF / WMF | yes | partial | Drawn as vectors (paths, pens, pattern and hatch brushes, bitmaps, text in the document's fonts with the file's character advances); clip regions; EMF+ only pictures play every EMF+ drawing record (brushes incl. gradients and textures, pens, paths, curves, images, strings, transforms, clipping; see `crates/metafile/src/emfplus.rs` for the few exceptions). Legacy OLE objects (VML preview) and ActiveX controls show their pictures | | HEIC | yes (macOS) | no | | | Audio: MP3, AAC/M4A, ALAC, WAV, AIFF, CAF, FLAC, Ogg Vorbis, Opus | yes (Ogg/Opus partly) | yes | Pure-Rust decoders in `crates/media`, `crates/opus` | | WMA | yes (Windows) | embedded, not playable | | diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..bec31d56 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -78,7 +78,8 @@ Detail in [file-format-parity.md](file-format-parity.md). ### SVG pictures don't render - **Evidence:** `crates/render/src/images.rs` decodes through `image` 0.25 (PNG, JPEG, WebP, GIF, TIFF, BMP), and EMF/WMF through `deckcraft-metafile` (ported from WordCraft: paths, fills, pens, - pattern and hatch brushes, bitmaps and text; clip regions and EMF+ records are not drawn). + pattern and hatch brushes, bitmaps and text; EMF+ only pictures play their EMF+ records; + clip regions are not drawn). Nothing reads SVG; PowerPoint's Insert Icons stores SVG with a PNG fallback. - **Impact:** SVG icons show only their raster fallback, and SVG files can't be inserted. An EMF/WMF that clips its drawing (EXTSELECTCLIPRGN, INTERSECTCLIPRECT) draws past its clip. From cbc6cd0e347e6a7af3e67a7a42d7f3d9d8481b3b Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 03:03:16 +0300 Subject: [PATCH 13/27] Charts: draw data-point fills on columns and bars, smoothed lines, and the file's chart text colour --- crates/pptx/src/read/chart.rs | 31 ++++++++++++++++++++++ crates/render/src/chart.rs | 48 ++++++++++++++++++++++++++++------- crates/render/src/tests.rs | 24 ++++++++++++++++++ 3 files changed, 94 insertions(+), 9 deletions(-) diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 6d7541ff..0045e4a5 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -281,10 +281,33 @@ pub(crate) fn chart_from(root: &El) -> Option { // the chart): the axes' and legend's, else the whole chart's; the title's own. let chart_size = text_size(root.child("txPr")); c.text_size = [val_ax, cat_ax, ch.child("legend")].into_iter().flatten().find_map(|e| text_size(e.child("txPr"))).or(chart_size); + // Text colour likewise: the axes' and legend's, else the whole chart's. + c.text_color = [val_ax, cat_ax, ch.child("legend"), Some(root)].into_iter().flatten().find_map(|e| text_color(e.child("txPr"))); c.title_size = ch.child("title").and_then(|t| text_size(t.child("tx").and_then(|x| x.child("rich"))).or_else(|| text_size(t.child("txPr")))); Some(c) } +/// The colour of the first `a:defRPr` or `a:rPr` with a solid fill under `e`. +fn text_color(e: Option<&El>) -> Option { + fn rec(e: &El, depth: usize) -> Option { + if depth > 8 { + return None; + } + for c in e.elements() { + if (c.is("defRPr") || c.is("rPr")) + && let Some(Fill::Solid { color }) = simple_fill(Some(c)) + { + return Some(color); + } + if let Some(v) = rec(c, depth + 1) { + return Some(v); + } + } + None + } + rec(e?, 0) +} + /// The size in points of the first `a:defRPr` or `a:rPr` with one under `e` (`sz` in hundredths). fn text_size(e: Option<&El>) -> Option { fn rec(e: &El, depth: usize) -> Option { @@ -363,6 +386,14 @@ mod tests { assert!(c.series.iter().all(|s| !s.secondary_axis)); } + #[test] + fn text_colour_is_read_from_the_axes() { + // LibreOffice's templates set light axis text for dark slides on the axes' c:txPr. + let xml = r#"1"#; + let c = read_chart_xml(xml.as_bytes()).unwrap(); + assert_eq!(c.text_color, Some(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))); + } + #[test] fn text_sizes_are_read_from_the_file() { // PowerPoint's new charts set the axes' and legend's text to 11.97 pt and the title's to diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index eb2b0a96..bbdc2330 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -36,6 +36,43 @@ fn series_fill(ctx: &Ctx, c: &Chart, i: usize, s: Option<&deckcraft_model::chart } } +/// The fill of point `k` of a series: its own (`c:dPt`) where the file gives one, else the series'. +fn point_fill(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series, k: usize) -> Rgba { + match s.point_fills.iter().find(|(p, _)| *p == k).map(|(_, f)| f) { + Some(Fill::Solid { color: cc }) => ctx.color(cc, None), + _ => series_fill(ctx, c, i, Some(s)), + } +} + +fn polyline(pts: &[Point]) -> BezPath { + let mut p = BezPath::new(); + for (k, q) in pts.iter().enumerate() { + if k == 0 { + p.move_to(*q); + } else { + p.line_to(*q); + } + } + p +} + +/// A smoothed line (`c:smooth`) through `pts`: a Catmull-Rom spline as cubic Béziers, the curve +/// PowerPoint draws, passing through every point. +pub(crate) fn smooth_path(pts: &[Point]) -> BezPath { + let mut p = BezPath::new(); + let Some(first) = pts.first() else { return p }; + p.move_to(*first); + for k in 0..pts.len() - 1 { + let p0 = pts[k.saturating_sub(1)]; + let (p1, p2) = (pts[k], pts[k + 1]); + let p3 = pts[(k + 2).min(pts.len() - 1)]; + let c1 = p1 + (p2 - p0) / 6.0; + let c2 = p2 - (p3 - p1) / 6.0; + p.curve_to(c1, c2, p2); + } + p +} + /// A line series' colour: its line's own (where PowerPoint keeps it), else its fill's, else the /// palette's. fn series_line(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series) -> Rgba { @@ -418,7 +455,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h Rect::new(plot.x0 + start, to_axis(sec, b), plot.x0 + start + bar, to_axis(sec, a)) }; ctx.set_transform(m); - ctx.set_paint(color(series_fill(rctx, c, *si, Some(s)), 1.0)); + ctx.set_paint(color(point_fill(rctx, c, *si, s, i), 1.0)); ctx.fill_rect(&r.abs()); if c.data_labels { let t = c.data_label(s, s.values.get(i).copied().flatten().unwrap_or(0.0)); @@ -480,14 +517,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h if pts.is_empty() { continue; } - let mut p = BezPath::new(); - for (k, q) in pts.iter().enumerate() { - if k == 0 { - p.move_to(*q); - } else { - p.line_to(*q); - } - } + let p = if s.smooth && !area_kind && pts.len() > 2 { smooth_path(&pts) } else { polyline(&pts) }; ctx.set_transform(m); if area_kind { let mut a = p.clone(); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 0de3d695..232352b4 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -155,6 +155,30 @@ fn combo_chart_draws_columns_and_a_line_on_its_own_axis() { assert!(col[0] < 250 || col[1] < 250 || col[2] < 250, "no column: {col:?}"); } +#[test] +fn column_points_take_their_own_fill_and_smooth_lines_curve() { + // One column series where only the middle point is given a red c:dPt fill. + let mut c = Chart::sample(ChartType::Column); + c.title = None; + c.legend = None; + c.series.truncate(1); + c.categories = vec!["A".into(), "B".into(), "C".into()]; + c.series[0].values = vec![Some(10.0), Some(10.0), Some(10.0)]; + c.series[0].point_fills = vec![(1, deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))))]; + let sh = Shape { id: ShapeId(621), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }; + let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let red = |x: u32| (300..500).any(|y| img.pixel(x, y)[..3] == [255, 0, 0]); + assert!(red(480), "the middle column takes its point's fill"); + assert!(!red(200) && !red(760), "the others keep the series colour"); + + // A smoothed line through a peak overshoots no point but leaves the straight chord. + let pts = [kurbo::Point::new(0.0, 0.0), kurbo::Point::new(10.0, 10.0), kurbo::Point::new(20.0, 0.0)]; + let p = crate::chart::smooth_path(&pts); + assert_eq!(p.elements().len(), 3, "a cubic per segment"); + let mid = kurbo::ParamCurve::eval(&p.segments().next().unwrap(), 0.5); + assert!(mid.y > mid.x, "the first half bows towards the peak instead of following the chord: {mid:?}"); +} + #[test] fn placeholder_prompts_only_in_edit_view() { let p = Presentation::default(); From 8e124d32a869a5831de716e3221ad1685415f3db Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 03:09:16 +0300 Subject: [PATCH 14/27] Charts: clustered bar widths from gap width and overlap as fractions of one bar; schema defaults when absent --- crates/pptx/src/read/chart.rs | 9 +++++++++ crates/render/src/chart.rs | 11 ++++++++--- crates/render/src/tests.rs | 19 +++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 0045e4a5..2f306858 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -173,11 +173,18 @@ pub(crate) fn chart_from(root: &El) -> Option { if let Some(v) = g.child_val("varyColors") { c.vary_colors = v == "1" || v == "true"; } + // A bar group without c:gapWidth / c:overlap takes the schema's defaults (150 %, 0 %), + // not the 219 % / -27 % PowerPoint writes into its new charts. + let bar_group = g.is("barChart") || g.is("bar3DChart"); if let Some(v) = g.child_val("gapWidth").and_then(|v| v.parse::().ok()) { c.gap_width = (v / 100.0).clamp(0.0, 5.0); + } else if bar_group { + c.gap_width = 1.5; } if let Some(v) = g.child_val("overlap").and_then(|v| v.parse::().ok()) { c.overlap = (v / 100.0).clamp(-1.0, 1.0); + } else if bar_group { + c.overlap = 0.0; } if let Some(v) = g.child_val("holeSize").and_then(|v| v.parse::().ok()) { c.hole_size = (v / 100.0).clamp(0.0, 0.9); @@ -392,6 +399,8 @@ mod tests { let xml = r#"1"#; let c = read_chart_xml(xml.as_bytes()).unwrap(); assert_eq!(c.text_color, Some(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))); + // No c:gapWidth / c:overlap: the schema's 150 % and 0 %. + assert_eq!((c.gap_width, c.overlap), (1.5, 0.0)); } #[test] diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index bbdc2330..9b394f71 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -420,10 +420,15 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h let bars: Vec<(usize, &deckcraft_model::chart::Series)> = c.series.iter().enumerate().filter(|(_, s)| c.series_kind(s).is_bar_like()).collect(); let ns = bars.len().max(1); + // Gap width and overlap are fractions of one bar's width: a category's band holds its bars + // (overlapping by `overlap` of a bar between neighbours) plus one gap. let gap = c.gap_width.clamp(0.0, 5.0); - let group = band / (1.0 + gap); let stacked = bk.stacked(); - let bar = if stacked { group } else { group / ns as f64 }; + let ov = if stacked { 1.0 } else { c.overlap.clamp(-1.0, 1.0) }; + let n = ns as f64; + let bar = band / (n - (n - 1.0) * ov + gap); + let step = bar * (1.0 - ov); + let group = bar + step * (n - 1.0); for i in 0..ncat { let mut pos_acc = 0.0; let mut neg_acc = 0.0; @@ -435,7 +440,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h for (bi, (si, s)) in bars.iter().enumerate() { let sec = c.on_secondary(s); let v = s.values.get(i).copied().flatten().unwrap_or(0.0) / total; - let start = band * i as f64 + (band - group) / 2.0 + if stacked { 0.0 } else { bar * bi as f64 }; + let start = band * i as f64 + (band - group) / 2.0 + if stacked { 0.0 } else { step * bi as f64 }; let (a, b) = if stacked { if v >= 0.0 { let r = (pos_acc, pos_acc + v); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 232352b4..8409ca5c 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -171,6 +171,25 @@ fn column_points_take_their_own_fill_and_smooth_lines_curve() { assert!(red(480), "the middle column takes its point's fill"); assert!(!red(200) && !red(760), "the others keep the series colour"); + // Two series, gap 150 %, no overlap: each bar is a band / 3.5 wide (PowerPoint's and + // LibreOffice's geometry), not band / 2.5 split in two. + let mut c = Chart::sample(ChartType::Column); + c.title = None; + c.legend = None; + c.series.truncate(2); + c.categories = vec!["A".into()]; + c.series[0].values = vec![Some(10.0)]; + c.series[1].values = vec![Some(10.0)]; + c.series[0].fill = Some(deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)))); + (c.gap_width, c.overlap) = (1.5, 0.0); + let sh = Shape { id: ShapeId(622), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }; + let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let y = (0..540).rev().find(|y| (0..960).any(|x| img.pixel(x, *y)[..3] == [255, 0, 0])).unwrap() - 5; + let reds = (0..960).filter(|x| img.pixel(*x, y)[..3] == [255, 0, 0]).count() as f64; + // The plot is ~850 px wide with one category: a bar is ~240 px (it was ~170 when the band + // was split as 1 + gap and then shared between the series). + assert!((200.0..280.0).contains(&reds), "bar {reds} px"); + // A smoothed line through a peak overshoots no point but leaves the straight chord. let pts = [kurbo::Point::new(0.0, 0.0), kurbo::Point::new(10.0, 10.0), kurbo::Point::new(20.0, 0.0)]; let p = crate::chart::smooth_path(&pts); From 71b02327cfdfdaf337fa1f582be340a5898b4f36 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 03:16:35 +0300 Subject: [PATCH 15/27] Charts: category axis orientation (maxMin) read, written and drawn; horizontal bars run bottom-up --- crates/model/src/chart.rs | 4 ++++ crates/pptx/src/read/chart.rs | 8 ++++++++ crates/pptx/src/read/mod.rs | 2 +- crates/pptx/src/write/chart.rs | 18 +++++++++++++++++- crates/render/src/chart.rs | 12 ++++++++---- crates/render/src/tests.rs | 20 ++++++++++++++++++++ 6 files changed, 58 insertions(+), 6 deletions(-) diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index aebee497..49f16c63 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -178,6 +178,9 @@ pub struct Chart { pub y_format: Option, /// The value axis is deleted (`c:delete`): no labels, though gridlines may stay. pub y_hidden: bool, + /// The category axis runs backwards (`c:orientation val="maxMin"`): right to left, or on a + /// horizontal bar chart top to bottom (the default puts the first category at the bottom). + pub cat_reversed: bool, /// A combo chart's secondary value axis: bounds, major unit and number format. pub y2_min: Option, pub y2_max: Option, @@ -219,6 +222,7 @@ impl Default for Chart { y_major: None, y_format: None, y_hidden: false, + cat_reversed: false, y2_min: None, y2_max: None, y2_major: None, diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 2f306858..63ee3125 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -282,6 +282,7 @@ pub(crate) fn chart_from(root: &El) -> Option { c.y_hidden = deleted(v); } if let Some(a) = cat_ax { + c.cat_reversed = a.child("scaling").and_then(|s| s.child_val("orientation")).is_some_and(|o| o == "maxMin"); c.axis_titles.0 = a.child("title").map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into())); } // Text sizes as the file sets them (PowerPoint draws chart text at these sizes, not scaled to @@ -399,6 +400,13 @@ mod tests { let xml = r#"1"#; let c = read_chart_xml(xml.as_bytes()).unwrap(); assert_eq!(c.text_color, Some(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))); + assert!(!c.cat_reversed); + let rev = read_chart_xml( + xml.replace("", "") + .as_bytes(), + ) + .unwrap(); + assert!(rev.cat_reversed, "maxMin reverses the category axis"); // No c:gapWidth / c:overlap: the schema's 150 % and 0 %. assert_eq!((c.gap_width, c.overlap), (1.5, 0.0)); } diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index 057614eb..9a8b305d 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -1,6 +1,6 @@ //! PPTX import: package → [`Presentation`]. -mod chart; +pub(crate) mod chart; pub(crate) mod dml; mod shapes; mod theme; diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 76567c9e..b1125d2c 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -251,7 +251,7 @@ fn axes(w: &mut W, c: &Chart, g: Group) { w.open0("c:catAx"); w.val("c:axId", AX_CAT); w.open0("c:scaling"); - w.val("c:orientation", "minMax"); + w.val("c:orientation", if c.cat_reversed { "maxMin" } else { "minMax" }); w.close("c:scaling"); w.val("c:delete", 0); w.val("c:axPos", cat_pos); @@ -479,3 +479,19 @@ pub fn chart_xml(c: &Chart) -> Vec { w.close("c:chartSpace"); w.finish() } + +#[cfg(test)] +mod tests { + use deckcraft_model::chart::{Chart, ChartType}; + + #[test] + fn reversed_category_axis_round_trips() { + let mut c = Chart::sample(ChartType::Bar); + c.cat_reversed = true; + let xml = super::chart_xml(&c); + assert!(String::from_utf8_lossy(&xml).contains(r#""#)); + assert!(crate::read::chart::read_chart_xml(&xml).unwrap().cat_reversed); + c.cat_reversed = false; + assert!(!crate::read::chart::read_chart_xml(&super::chart_xml(&c)).unwrap().cat_reversed); + } +} diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index 9b394f71..20dae6b4 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -382,10 +382,14 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } let ncat = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).max(1); let band = if horiz { plot.height() / ncat as f64 } else { plot.width() / ncat as f64 }; + // Where category `i`'s band starts along its axis: left to right, or bottom to top on a + // horizontal bar chart, unless the axis is reversed. + let backwards = horiz != c.cat_reversed; + let slot = |i: usize| if backwards { ncat.saturating_sub(i + 1) as f64 } else { i as f64 }; // Category labels: wrapped onto two lines when they don't fit their band (long date ranges). let wraps = !horiz && c.categories.iter().any(|n| n.chars().count() as f64 * base * 0.5 > band); for (i, name) in c.categories.iter().enumerate() { - let mid = if horiz { plot.y0 + band * (i as f64 + 0.5) } else { plot.x0 + band * (i as f64 + 0.5) }; + let mid = if horiz { plot.y0 + band * (slot(i) + 0.5) } else { plot.x0 + band * (slot(i) + 0.5) }; if horiz { label( ctx, @@ -440,7 +444,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h for (bi, (si, s)) in bars.iter().enumerate() { let sec = c.on_secondary(s); let v = s.values.get(i).copied().flatten().unwrap_or(0.0) / total; - let start = band * i as f64 + (band - group) / 2.0 + if stacked { 0.0 } else { step * bi as f64 }; + let start = band * slot(i) + (band - group) / 2.0 + if stacked { 0.0 } else { step * bi as f64 }; let (a, b) = if stacked { if v >= 0.0 { let r = (pos_acc, pos_acc + v); @@ -513,9 +517,9 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h let xs: Vec = s.x_values.iter().flatten().copied().collect(); let (xl, xh) = (xs.iter().copied().fold(f64::MAX, f64::min), xs.iter().copied().fold(f64::MIN, f64::max)); let xv = s.x_values.get(i).copied().flatten().unwrap_or(i as f64); - if xh > xl { plot.x0 + (xv - xl) / (xh - xl) * plot.width() } else { plot.x0 + band * (i as f64 + 0.5) } + if xh > xl { plot.x0 + (xv - xl) / (xh - xl) * plot.width() } else { plot.x0 + band * (slot(i) + 0.5) } } - _ => plot.x0 + band * (i as f64 + 0.5), + _ => plot.x0 + band * (slot(i) + 0.5), }; pts.push(Point::new(x, to_axis(sec, v))); } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 8409ca5c..1743f873 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -198,6 +198,26 @@ fn column_points_take_their_own_fill_and_smooth_lines_curve() { assert!(mid.y > mid.x, "the first half bows towards the peak instead of following the chord: {mid:?}"); } +#[test] +fn horizontal_bars_list_the_first_category_at_the_bottom_unless_reversed() { + let draw = |reversed: bool| { + let mut c = Chart::sample(ChartType::Bar); + c.title = None; + c.legend = None; + c.series.truncate(1); + c.categories = vec!["A".into(), "B".into(), "C".into()]; + c.series[0].values = vec![Some(10.0), Some(10.0), Some(10.0)]; + c.series[0].point_fills = vec![(0, deckcraft_model::style::Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))))]; + c.cat_reversed = reversed; + let sh = Shape { id: ShapeId(623), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() }; + let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let rows: Vec = (0..540).filter(|y| (0..960).any(|x| img.pixel(x, *y)[..3] == [255, 0, 0])).collect(); + (rows[0] + rows[rows.len() - 1]) / 2 + }; + assert!(draw(false) > 360, "first category at the bottom: {}", draw(false)); + assert!(draw(true) < 180, "reversed: first category at the top: {}", draw(true)); +} + #[test] fn placeholder_prompts_only_in_edit_view() { let p = Presentation::default(); From ed20cebed87b28785c13218078dec0857b7490e8 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 04:22:19 +0300 Subject: [PATCH 16/27] Charts: scatter and bubble charts get a value x axis --- crates/render/src/chart.rs | 52 ++++++++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index eb2b0a96..7f3515d1 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -86,6 +86,17 @@ fn fmt_num(v: f64) -> String { if (v - v.round()).abs() < 1e-9 { format!("{}", v.round() as i64) } else { format!("{v:.1}") } } +/// A scatter or bubble chart's value x axis (min, max, major unit): one scale over every series' +/// x values, from zero when they're all positive. `None` for other charts and when there are no x values. +fn value_x_scale(c: &Chart, max_intervals: usize) -> Option<(f64, f64, f64)> { + if c.series.is_empty() || !c.series.iter().all(|s| matches!(c.series_kind(s), ChartType::Scatter | ChartType::Bubble)) { + return None; + } + let xs = c.series.iter().flat_map(|s| s.x_values.iter().flatten().copied()).filter(|v| v.is_finite()); + let (lo, hi) = xs.fold(None, |r: Option<(f64, f64)>, v| Some(r.map_or((v, v), |(a, b)| (a.min(v), b.max(v)))))?; + Some(axis_scale(lo.min(0.0), hi, None, None, None, max_intervals, false)) +} + /// Where the legend goes for `c:legendPos`, and the plot area left over. /// `size` is the legend block (width, height); the result is its top-left corner and the plot area. /// `r` (also the OOXML default and the fallback for unknown values) and `l` are vertical stacks centred @@ -345,9 +356,30 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } let ncat = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).max(1); let band = if horiz { plot.height() / ncat as f64 } else { plot.width() / ncat as f64 }; + // Scatter and bubble charts have a value x axis: one scale over every series' x values, with its + // own tick labels in place of category names. + let xscale = if horiz { None } else { value_x_scale(c, (plot.width() / (base * 2.5)).floor().clamp(2.0, 12.0) as usize) }; + let to_x = |v: f64| xscale.map(|(lo, hi, _)| plot.x0 + (v - lo) / (hi - lo).max(1e-9) * plot.width()); + if let Some(sc) = xscale { + for v in ticks(sc) { + let pos = plot.x0 + (v - sc.0) / (sc.1 - sc.0).max(1e-9) * plot.width(); + label( + ctx, + rctx, + m, + &fmt_num(v), + Rect::new(pos - 30.0, plot.y1 + 3.0, pos + 30.0, plot.y1 + base * 1.6), + base, + Align::Center, + Anchor::Top, + text_col, + false, + ); + } + } // Category labels: wrapped onto two lines when they don't fit their band (long date ranges). let wraps = !horiz && c.categories.iter().any(|n| n.chars().count() as f64 * base * 0.5 > band); - for (i, name) in c.categories.iter().enumerate() { + for (i, name) in c.categories.iter().enumerate().filter(|_| xscale.is_none()) { let mid = if horiz { plot.y0 + band * (i as f64 + 0.5) } else { plot.x0 + band * (i as f64 + 0.5) }; if horiz { label( @@ -468,10 +500,12 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h }; let x = match kind { ChartType::Scatter | ChartType::Bubble => { - let xs: Vec = s.x_values.iter().flatten().copied().collect(); - let (xl, xh) = (xs.iter().copied().fold(f64::MAX, f64::min), xs.iter().copied().fold(f64::MIN, f64::max)); - let xv = s.x_values.get(i).copied().flatten().unwrap_or(i as f64); - if xh > xl { plot.x0 + (xv - xl) / (xh - xl) * plot.width() } else { plot.x0 + band * (i as f64 + 0.5) } + match (s.x_values.get(i).copied().flatten().and_then(to_x), s.x_values.iter().flatten().next()) { + (Some(x), _) => x, + // No x values at all: the points are numbered 1, 2, 3... + (None, None) => to_x(i as f64 + 1.0).unwrap_or(plot.x0 + band * (i as f64 + 0.5)), + (None, Some(_)) => continue, + } } _ => plot.x0 + band * (i as f64 + 0.5), }; @@ -532,6 +566,14 @@ mod tests { const AREA: Rect = Rect::new(0.0, 0.0, 400.0, 300.0); + #[test] + fn scatter_x_is_a_value_scale_over_all_series() { + let mut c = Chart::sample(ChartType::Scatter); + c.series[0].x_values = vec![Some(0.5), Some(7.2), None]; + assert_eq!(value_x_scale(&c, 8), Some((0.0, 8.0, 1.0))); + assert_eq!(value_x_scale(&Chart::sample(ChartType::Column), 8), None); + } + #[test] fn right_legend_is_a_centred_column_and_narrows_the_plot() { let (o, plot) = legend_layout("r", (60.0, 100.0), AREA, 10.0, false); From aed386e26936970fd3d19ccb7483f4401d281a0d Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 04:39:00 +0300 Subject: [PATCH 17/27] Charts: draw, read and write value-axis minor gridlines --- crates/model/src/chart.rs | 3 +++ crates/pptx/src/read/chart.rs | 11 +++++++++++ crates/pptx/src/write/chart.rs | 3 +++ crates/pptx/src/write/mod.rs | 2 +- crates/render/src/chart.rs | 20 ++++++++++++++++++++ 5 files changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index aebee497..936d0c9e 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -159,6 +159,8 @@ pub struct Chart { pub title_size: Option, pub data_labels: bool, pub gridlines: bool, + /// `c:minorGridlines` on the value axis: five to a major unit, between the major ones. + pub minor_gridlines: bool, pub axis_titles: (Option, Option), /// Chart style number (1–48) and colour palette (`colorful1`, `monochrome3`…). pub style: u32, @@ -204,6 +206,7 @@ impl Default for Chart { title_size: None, data_labels: false, gridlines: true, + minor_gridlines: false, axis_titles: (None, None), style: 2, palette: "colorful1".into(), diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 6d7541ff..a3fe4e86 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -266,6 +266,7 @@ pub(crate) fn chart_from(root: &El) -> Option { let cat_ax = pa.child("catAx").or_else(|| pa.child("dateAx")).or_else(|| pa.children_named("valAx").find(|a| !vertical(a))); if let Some(v) = val_ax { c.gridlines = v.child("majorGridlines").is_some(); + c.minor_gridlines = v.child("minorGridlines").is_some(); let sc = v.child("scaling"); c.y_min = sc.and_then(|s| s.child_val("min")).and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite()); c.y_max = sc.and_then(|s| s.child_val("max")).and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite()); @@ -310,6 +311,16 @@ fn text_size(e: Option<&El>) -> Option { mod tests { use super::*; + #[test] + fn minor_gridlines_are_read_and_written_back() { + let mut c = Chart::sample(ChartType::Line); + c.minor_gridlines = true; + let back = read_chart_xml(&crate::write::chart::chart_xml(&c)).unwrap(); + assert!(back.gridlines && back.minor_gridlines); + c.minor_gridlines = false; + assert!(!read_chart_xml(&crate::write::chart::chart_xml(&c)).unwrap().minor_gridlines); + } + #[test] fn legend_position_and_overlay_are_read() { let legend = r#""#; diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 76567c9e..fc7406fe 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -286,6 +286,9 @@ fn axes(w: &mut W, c: &Chart, g: Group) { if c.gridlines { w.empty0("c:majorGridlines"); } + if c.minor_gridlines { + w.empty0("c:minorGridlines"); + } if let Some(t) = &c.axis_titles.1 { title(w, t); } diff --git a/crates/pptx/src/write/mod.rs b/crates/pptx/src/write/mod.rs index 7fb6a713..a5ebcf95 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -1,6 +1,6 @@ //! PPTX export: [`Presentation`] → package. -mod chart; +pub(crate) mod chart; pub(crate) mod dml; mod shapes; mod theme; diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index eb2b0a96..cf71fe0b 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -282,6 +282,26 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h (true, Some((min2, max2, _))) => plot.y1 - (v - min2) / (max2 - min2).max(1e-9) * plot.height(), _ => to_v(v), }; + // Minor gridlines, under the major ones: five to a major unit. + if c.minor_gridlines { + let minor = step / 5.0; + let n = (((max - min) / minor.max(1e-12)).round() as i64).clamp(1, 500); + let mut g = BezPath::new(); + for k in (0..=n).filter(|k| k % 5 != 0) { + let pos = to_v(min + k as f64 * minor); + if horiz { + g.move_to((pos, plot.y0)); + g.line_to((pos, plot.y1)); + } else { + g.move_to((plot.x0, pos)); + g.line_to((plot.x1, pos)); + } + } + ctx.set_transform(m); + ctx.set_paint(color(Rgba::rgb(0xF2, 0xF2, 0xF2), 1.0)); + ctx.set_stroke(kurbo::Stroke::new(0.75)); + ctx.stroke_path(&g); + } // Gridlines and value labels. for v in ticks((min, max, step)) { let pos = to_v(v); From 4a7ad03eb0cbdd287ec44651d8041dd35fac2760 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 04:45:26 +0300 Subject: [PATCH 18/27] Charts: logarithmic value axes --- crates/model/src/chart.rs | 30 ++++++++++++++++++++++++++++++ crates/pptx/src/read/chart.rs | 7 +++++++ crates/pptx/src/write/chart.rs | 3 +++ crates/render/src/chart.rs | 30 ++++++++++++++++++++++++------ 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index aebee497..04b591ce 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -174,6 +174,8 @@ pub struct Chart { pub y_max: Option, /// The value axis' major unit, when the chart fixes it. pub y_major: Option, + /// `c:scaling/c:logBase`: the value axis is logarithmic in this base (2–1000). + pub y_log_base: Option, /// The value axis' number format (an Excel format code such as `#,##0` or `0.0%`). pub y_format: Option, /// The value axis is deleted (`c:delete`): no labels, though gridlines may stay. @@ -217,6 +219,7 @@ impl Default for Chart { y_min: None, y_max: None, y_major: None, + y_log_base: None, y_format: None, y_hidden: false, y2_min: None, @@ -311,6 +314,22 @@ impl Chart { Some(axis_scale(lo, hi, fmin, fmax, major, max_intervals, !full_percent)) } + /// A logarithmic value axis' scale in exponents — (min, max, unit), so its labels are + /// `base^min`, `base^(min+unit)`… — covering the positive values: whole powers, as PowerPoint + /// and LibreOffice draw them. `None` when the axis isn't logarithmic or nothing is positive. + pub fn log_axis_scale(&self, max_intervals: usize) -> Option<(f64, f64, f64)> { + let b = self.y_log_base.filter(|b| b.is_finite() && *b >= 2.0)?; + let vals = self.series.iter().filter(|s| !self.on_secondary(s)).flat_map(|s| s.values.iter().flatten().copied()); + let (lo, hi) = vals + .filter(|v| v.is_finite() && *v > 0.0) + .fold(None, |r: Option<(f64, f64)>, v| Some(r.map_or((v, v), |(a, z)| (a.min(v), z.max(v)))))?; + let e = |v: f64| v.log(b); + let lo = self.y_min.filter(|v| *v > 0.0).map_or(e(lo).floor(), e); + let hi = self.y_max.filter(|v| *v > 0.0).map_or(e(hi).ceil(), e).max(lo + 1.0); + let unit = ((hi - lo) / max_intervals.max(1) as f64).ceil().max(1.0); + Some((lo, lo + ((hi - lo) / unit).ceil() * unit, unit)) + } + /// A value axis label in the axis' number format and the chart's language. pub fn axis_label(&self, secondary: bool, v: f64) -> String { let code = if secondary { &self.y2_format } else { &self.y_format }; @@ -431,6 +450,17 @@ pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) { mod tests { use super::*; + #[test] + fn log_axes_run_over_whole_powers() { + let mut c = Chart::sample(ChartType::Line); + c.series.truncate(1); + c.series[0].values = vec![Some(1.0), Some(10.0), Some(1000.0), Some(50000.0)]; + assert_eq!(c.log_axis_scale(8), None); + c.y_log_base = Some(10.0); + assert_eq!(c.log_axis_scale(8), Some((0.0, 5.0, 1.0))); + assert_eq!(c.log_axis_scale(2), Some((0.0, 6.0, 3.0))); + } + #[test] fn nice_scale_covers() { let (a, b, s) = nice_scale(0.0, 5.1); diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 6d7541ff..2d2981bb 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -271,6 +271,7 @@ pub(crate) fn chart_from(root: &El) -> Option { c.y_max = sc.and_then(|s| s.child_val("max")).and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite()); c.axis_titles.1 = v.child("title").map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into())); c.y_major = num(v, "majorUnit"); + c.y_log_base = sc.and_then(|s| s.child_val("logBase")).and_then(|v| v.parse().ok()).filter(|b: &f64| b.is_finite() && *b >= 2.0); c.y_format = format_code(v); c.y_hidden = deleted(v); } @@ -310,6 +311,12 @@ fn text_size(e: Option<&El>) -> Option { mod tests { use super::*; + #[test] + fn log_base_is_read() { + let xml = br#""#; + assert_eq!(read_chart_xml(xml).unwrap().y_log_base, Some(10.0)); + } + #[test] fn legend_position_and_overlay_are_read() { let legend = r#""#; diff --git a/crates/pptx/src/write/chart.rs b/crates/pptx/src/write/chart.rs index 76567c9e..a3cf7042 100644 --- a/crates/pptx/src/write/chart.rs +++ b/crates/pptx/src/write/chart.rs @@ -273,6 +273,9 @@ fn axes(w: &mut W, c: &Chart, g: Group) { w.open0("c:valAx"); w.val("c:axId", AX_VAL); w.open0("c:scaling"); + if let Some(b) = c.y_log_base { + w.val("c:logBase", fmt_num(b)); + } w.val("c:orientation", "minMax"); if let Some(m) = c.y_max.filter(|v| v.is_finite()) { w.val("c:max", fmt_num(m)); diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index eb2b0a96..6fc482cf 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -249,7 +249,16 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h // As many major units as the value axis has room for labels (PowerPoint's automatic unit). let axis_len = if horiz { area.width() - base * 4.5 } else { area.height() - base * 1.8 }; let max_intervals = (axis_len / if horiz { base * 6.0 } else { base * 1.9 }).floor().clamp(2.0, 12.0) as usize; - let (min, max, step) = c.axis_scale(false, max_intervals).unwrap_or_else(|| axis_scale(0.0, 1.0, None, None, None, max_intervals, true)); + // A logarithmic axis is scaled in exponents of its base. + let log = c.log_axis_scale(max_intervals).map(|sc| (c.y_log_base.unwrap_or(10.0), sc)); + let (min, max, step) = match log { + Some((_, sc)) => sc, + None => c.axis_scale(false, max_intervals).unwrap_or_else(|| axis_scale(0.0, 1.0, None, None, None, max_intervals, true)), + }; + let to_e = |v: f64| match log { + Some((b, _)) => v.max(f64::MIN_POSITIVE).log(b).max(min - 1.0), + None => v, + }; // A combo chart's secondary value axis, on the right (vertical value axes only). let second = if horiz { None } else { c.axis_scale(true, max_intervals) }; let ticks = |(lo, hi, st): (f64, f64, f64)| { @@ -257,8 +266,11 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h (0..=n).map(move |k| lo + k as f64 * st) }; // Label gutters as wide as the longest value label. - let label_w = - |sec: bool, sc: (f64, f64, f64)| ticks(sc).map(|v| c.axis_label(sec, v).chars().count()).max().unwrap_or(1) as f64 * base * 0.55 + base * 0.6; + let primary_ticks: Vec = ticks((min, max, step)).map(|k| log.map_or(k, |(b, _)| b.powf(k))).collect(); + let label_w = |sec: bool, sc: (f64, f64, f64)| { + let vals: Vec = if sec { ticks(sc).collect() } else { primary_ticks.clone() }; + vals.iter().map(|v| c.axis_label(sec, *v).chars().count()).max().unwrap_or(1) as f64 * base * 0.55 + base * 0.6 + }; let gutter = if horiz { base * 4.5 } else if c.y_hidden { @@ -276,14 +288,17 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h crate::paint::fill_path(ctx, rctx, f, None, &plot.to_path(0.1), plot, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres); } let span = (max - min).max(1e-9); - let to_v = |v: f64| if horiz { plot.x0 + (v - min) / span * plot.width() } else { plot.y1 - (v - min) / span * plot.height() }; + let to_v = |v: f64| { + let v = to_e(v); + if horiz { plot.x0 + (v - min) / span * plot.width() } else { plot.y1 - (v - min) / span * plot.height() } + }; // A series' value as a position on its own axis. let to_axis = |sec: bool, v: f64| match (sec, second) { (true, Some((min2, max2, _))) => plot.y1 - (v - min2) / (max2 - min2).max(1e-9) * plot.height(), _ => to_v(v), }; // Gridlines and value labels. - for v in ticks((min, max, step)) { + for &v in &primary_ticks { let pos = to_v(v); let text = if c.y_hidden { String::new() } else { c.axis_label(false, v) }; let mut g = BezPath::new(); @@ -378,7 +393,10 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h ); } } - let zero = to_v(0.0f64.clamp(min, max)); + let zero = match log { + Some((b, _)) => to_v(b.powf(min)), + None => to_v(0.0f64.clamp(min, max)), + }; if let Some(bk) = bar_kind { let bars: Vec<(usize, &deckcraft_model::chart::Series)> = c.series.iter().enumerate().filter(|(_, s)| c.series_kind(s).is_bar_like()).collect(); From c4a9f25754708d3c14dd4c701abb6f1378aea780 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 05:58:30 +0300 Subject: [PATCH 19/27] Charts: a chart frame with no width no longer panics --- crates/render/src/chart.rs | 6 ++--- crates/render/src/tests.rs | 48 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index eb2b0a96..2b8763ca 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -264,9 +264,9 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h } else if c.y_hidden { base * 0.5 } else { - label_w(false, (min, max, step)).clamp(base * 1.5, area.width() * 0.3) + label_w(false, (min, max, step)).min(area.width() * 0.3).max(base * 1.5) }; - let gutter_r = second.filter(|_| !c.y2_hidden).map(|sc| label_w(true, sc).clamp(base * 1.5, area.width() * 0.3)).unwrap_or(0.0); + let gutter_r = second.filter(|_| !c.y2_hidden).map(|sc| label_w(true, sc).min(area.width() * 0.3).max(base * 1.5)).unwrap_or(0.0); let plot = if horiz { Rect::new(area.x0 + gutter, area.y0, area.x1, area.y1 - base * 1.6) } else { @@ -378,7 +378,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h ); } } - let zero = to_v(0.0f64.clamp(min, max)); + let zero = to_v(0.0f64.max(min).min(max)); if let Some(bk) = bar_kind { let bars: Vec<(usize, &deckcraft_model::chart::Series)> = c.series.iter().enumerate().filter(|(_, s)| c.series_kind(s).is_bar_like()).collect(); diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 0de3d695..64e44670 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1711,3 +1711,51 @@ fn east_asian_vertical_text_stands_ideographs_upright() { let (w, h) = ink(deckcraft_model::text::TextDir::Vertical); assert!(h > w * 3, "vert: stroke on its side {w}x{h}"); } + +#[test] +fn charts_in_degenerate_frames_draw_without_panicking() { + // A chart frame squashed to no width (cx="0" in a generated file) made the value labels' + // gutter clamp panic (its upper bound, a share of the width, fell below its lower bound). + // Every chart type and legend position must survive any frame size. + let p = Presentation::default(); + let rctx = Ctx::for_slide(&p, &p.slides[0]).unwrap(); + let kinds = [ + ChartType::Column, + ChartType::StackedColumn, + ChartType::PercentColumn, + ChartType::Bar, + ChartType::StackedBar, + ChartType::PercentBar, + ChartType::Line, + ChartType::LineMarkers, + ChartType::StackedLine, + ChartType::Area, + ChartType::StackedArea, + ChartType::Pie, + ChartType::Doughnut, + ChartType::Scatter, + ChartType::Bubble, + ChartType::Radar, + ChartType::FilledRadar, + ChartType::Stock, + ChartType::Surface, + ChartType::Histogram, + ChartType::Pareto, + ChartType::BoxWhisker, + ChartType::Waterfall, + ChartType::Funnel, + ChartType::Treemap, + ChartType::Sunburst, + ChartType::Combo, + ]; + let mut ctx = vello_cpu::RenderContext::new(64, 64); + for k in kinds { + for (w, h) in [(0.0, 403.0), (403.0, 0.0), (0.0, 0.0), (2.0, 400.0), (400.0, 2.0), (12.0, 12.0)] { + for legend in [None, Some("b"), Some("t"), Some("r"), Some("l"), Some("tr")] { + let mut c = Chart::sample(k); + c.legend = legend.map(String::from); + crate::chart::draw(&mut ctx, &rctx, &c, kurbo::Affine::IDENTITY, w, h); + } + } + } +} From b5499d7157081452735e8a1d13dc253cf6469c40 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:41:50 -0400 Subject: [PATCH 20/27] Fix up merged PRs: waterfall axis scale uses the running totals main's axis_scale now reads plotted_extent rather than value_range, which #537 taught about waterfalls; route waterfall charts to value_range there too. Co-Authored-By: Claude Opus 5.5 --- crates/model/src/chart.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index 694d06cd..42a8378d 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -369,6 +369,9 @@ impl Chart { /// stacked series as running sums from zero, 100 % series as 0–1. `(0, 0)` without finite /// values; `None` when no series uses the axis. fn plotted_extent(&self, secondary: bool) -> Option<(f64, f64)> { + if self.kind == ChartType::Waterfall { + return (!secondary).then(|| self.value_range()); + } let ser: Vec<&Series> = self.series.iter().filter(|s| self.on_secondary(s) == secondary).collect(); if ser.is_empty() { return (!secondary && self.kind != ChartType::Combo).then_some((0.0, 0.0)); From 32a49e8bf66c7552432df5783b22f3d951c17026 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:47:08 -0400 Subject: [PATCH 21/27] Fix up merged PRs: a trendline caption in a tiny chart no longer panics f64::clamp panics when the plot is narrower than the caption's minimum width; use main's clamp_soft, as the rest of the chart code now does. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/chart.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index f90ab549..1e481510 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -860,7 +860,7 @@ fn draw_trendlines( let lines = caption.chars().filter(|ch| *ch == '\n').count() + 1; let box_h = base * 1.3 * lines as f64 + 2.0; let widest = caption.lines().map(|l| l.chars().count()).max().unwrap_or(1) as f64; - let box_w = (widest * base * 0.62).clamp(48.0, plot.width() * 0.72); + let box_w = clamp_soft(widest * base * 0.62, 48.0, plot.width() * 0.72); let (x, y) = caption_at(plot, box_w, box_h, &ink_pts, label_n); ctx.set_transform(m); ctx.set_paint(color(Rgba::WHITE, 1.0)); From 0f28ac96ed63a985bfc78d2072382712f788a4a8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:49:35 -0400 Subject: [PATCH 22/27] Fix up merged PRs: reversed category axes and trendline forecasts agree #640 orders category slots bottom-to-top on horizontal bars (or reversed via c:orientation) while #603 pads the axis for trendline forecasts. Slots now include the forecast padding on the right side, and trendlines map their category positions through the same slot order as the bars. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/chart.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index fe8df06a..4622e9c6 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -650,7 +650,9 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, } } } - draw_trendlines(ctx, rctx, c, m, plot, band, back, ncat, horiz, base, text_col, &to_axis); + // A category position (0-based, fractional) as a slot, the way `slot` places the bars. + let slot_at = |x: f64| if backwards { ncat as f64 - 1.0 - x + fwd } else { x + back }; + draw_trendlines(ctx, rctx, c, m, plot, band, &slot_at, ncat, horiz, base, text_col, &to_axis); // Axis line. let mut ax = BezPath::new(); if horiz { @@ -744,7 +746,7 @@ const TREND_INK: [Rgba; 4] = [Rgba::rgb(0x22, 0x22, 0x22), Rgba::rgb(0xB0, 0x30, fn map_xy( plot: Rect, band: f64, - back: f64, + slot: &impl Fn(f64) -> f64, horiz: bool, scatter: bool, sx0: f64, @@ -765,7 +767,7 @@ fn map_xy( plot.x0 + (x - sx0) / (sx1 - sx0) * plot.width() } else { let origin = if horiz { plot.y0 } else { plot.x0 }; - origin + band * (x - 1.0 + back + 0.5) + origin + band * (slot(x - 1.0) + 0.5) }; if !along.is_finite() { return None; @@ -811,7 +813,7 @@ fn draw_trendlines( m: Affine, plot: Rect, band: f64, - back: f64, + slot: &impl Fn(f64) -> f64, ncat: usize, horiz: bool, base: f64, @@ -861,8 +863,8 @@ fn draw_trendlines( let mut any = false; for w in samples.windows(2) { let (Some(a), Some(b)) = ( - map_xy(plot, band, back, horiz, scatter, sx0, sx1, sec, w[0].0, w[0].1, to_axis), - map_xy(plot, band, back, horiz, scatter, sx0, sx1, sec, w[1].0, w[1].1, to_axis), + map_xy(plot, band, slot, horiz, scatter, sx0, sx1, sec, w[0].0, w[0].1, to_axis), + map_xy(plot, band, slot, horiz, scatter, sx0, sx1, sec, w[1].0, w[1].1, to_axis), ) else { prev = None; continue; From 5089d615bfbee0c860c40fc5d99d428f264d1479 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:50:37 -0400 Subject: [PATCH 23/27] Fix up merged PRs: scatter trendlines follow the value x axis #662 gives scatter and bubble charts one x scale over every series; #603's trendlines still mapped x over each series' own extent, so a fit drifted off its points. Use the chart's x scale where there is one. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/chart.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index 822f1bfd..5b2017a3 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -690,7 +690,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, } // A category position (0-based, fractional) as a slot, the way `slot` places the bars. let slot_at = |x: f64| if backwards { ncat as f64 - 1.0 - x + fwd } else { x + back }; - draw_trendlines(ctx, rctx, c, m, plot, band, &slot_at, ncat, horiz, base, text_col, &to_axis); + draw_trendlines(ctx, rctx, c, m, plot, band, &slot_at, xscale, ncat, horiz, base, text_col, &to_axis); // Axis line. let mut ax = BezPath::new(); if horiz { @@ -852,6 +852,7 @@ fn draw_trendlines( plot: Rect, band: f64, slot: &impl Fn(f64) -> f64, + xscale: Option<(f64, f64, f64)>, ncat: usize, horiz: bool, base: f64, @@ -879,8 +880,11 @@ fn draw_trendlines( xs.push(x); ys.push(y); } - // The same x extent the scatter marks use, so the fit sits on the points. - let (sx0, sx1) = if scatter { + // The same x extent the scatter marks use, so the fit sits on the points: the chart's + // value x axis where it has one, else the series' own x values. + let (sx0, sx1) = if let (true, Some((lo, hi, _))) = (scatter, xscale) { + (lo, hi) + } else if scatter { let mut lo = f64::MAX; let mut hi = f64::MIN; for v in s.x_values.iter().copied().flatten().filter(|v| v.is_finite()) { From bb3627f9549130b3e0f1107b70f3f185046aa135 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:53:06 -0400 Subject: [PATCH 24/27] Fix up merged PRs: minor gridlines on a logarithmic axis #666's minor gridlines stepped in value units, but #669 scales a log axis in exponents of its base; map each minor step back through the base. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/chart.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index 54d989f8..c6f35fdc 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -411,7 +411,9 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, let n = (((max - min) / minor.max(1e-12)).round() as i64).clamp(1, 500); let mut g = BezPath::new(); for k in (0..=n).filter(|k| k % 5 != 0) { - let pos = to_v(min + k as f64 * minor); + // On a logarithmic axis `min`, `max` and `step` are exponents of the base. + let e = min + k as f64 * minor; + let pos = to_v(log.map_or(e, |(b, _)| b.powf(e))); if horiz { g.move_to((pos, plot.y0)); g.line_to((pos, plot.y1)); From 3450180f19bafec9cfe26a21e5d5d9d26c60e33e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:55:38 -0400 Subject: [PATCH 25/27] Fix up merged PRs: EMF+ clipping alongside GDI clip regions main added its own clip.rs (GDI rectangle regions carried on every drawn item); #582 added a clip.rs that cuts drawing geometrically to the EMF+ clip rectangle. Keep main's module, move #582's to cliprect.rs, keep the EMF+ rectangle on the canvas as plus_clip, and carry the region field through the geometric clipper. Co-Authored-By: Claude Opus 5.5 --- crates/metafile/src/canvas.rs | 4 ++-- crates/metafile/src/cliprect.rs | 20 ++++++++++---------- crates/metafile/src/tests.rs | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/metafile/src/canvas.rs b/crates/metafile/src/canvas.rs index 7a661fac..2745a465 100644 --- a/crates/metafile/src/canvas.rs +++ b/crates/metafile/src/canvas.rs @@ -536,7 +536,7 @@ impl Canvas { let dev: Vec = ops.iter().map(|o| self.map_op(*o)).collect(); if dev.len() >= 2 { let even_odd = self.even_odd; - self.push(Raw::Pattern { ops: dev, even_odd, tile }); + self.push(Raw::Pattern { ops: dev, even_odd, tile, region: None }); } } @@ -564,7 +564,7 @@ impl Canvas { } } let even_odd = self.even_odd; - self.push(Raw::Pattern { ops: dev, even_odd, tile: Arc::new(RawTile { w, h, px, cell }) }); + self.push(Raw::Pattern { ops: dev, even_odd, tile: Arc::new(RawTile { w, h, px, cell }), region: None }); } /// Pushes the drawing state. Past [`MAX_STACK`] levels the parse stops, since later restores diff --git a/crates/metafile/src/cliprect.rs b/crates/metafile/src/cliprect.rs index 882affbd..652ad35b 100644 --- a/crates/metafile/src/cliprect.rs +++ b/crates/metafile/src/cliprect.rs @@ -48,10 +48,10 @@ fn disjoint(b: [f64; 4], c: [f64; 4]) -> bool { /// Clips one drawn item; a shape that is both filled and stroked comes back as two items. pub(crate) fn clip_raw(raw: Raw, c: [f64; 4]) -> Vec { match raw { - Raw::Path { ops, fill, stroke, even_odd } => { + Raw::Path { ops, fill, stroke, even_odd, region } => { let Some(b) = ops_bbox(&ops) else { return Vec::new() }; if inside(b, c) { - return vec![Raw::Path { ops, fill, stroke, even_odd }]; + return vec![Raw::Path { ops, fill, stroke, even_odd, region }]; } if disjoint(b, c) { return Vec::new(); @@ -61,33 +61,33 @@ pub(crate) fn clip_raw(raw: Raw, c: [f64; 4]) -> Vec { if fill.is_some() { let f = clip_fill(&subs, c); if !f.is_empty() { - out.push(Raw::Path { ops: f, fill, stroke: None, even_odd }); + out.push(Raw::Path { ops: f, fill, stroke: None, even_odd, region: region.clone() }); } } if stroke.is_some() { let s = clip_stroke(&subs, c); if !s.is_empty() { - out.push(Raw::Path { ops: s, fill: None, stroke, even_odd }); + out.push(Raw::Path { ops: s, fill: None, stroke, even_odd, region }); } } out } - Raw::Pattern { ops, even_odd, tile } => { + Raw::Pattern { ops, even_odd, tile, region } => { let Some(b) = ops_bbox(&ops) else { return Vec::new() }; if inside(b, c) { - return vec![Raw::Pattern { ops, even_odd, tile }]; + return vec![Raw::Pattern { ops, even_odd, tile, region }]; } if disjoint(b, c) { return Vec::new(); } let f = clip_fill(&flatten(&ops), c); - if f.is_empty() { Vec::new() } else { vec![Raw::Pattern { ops: f, even_odd, tile }] } + if f.is_empty() { Vec::new() } else { vec![Raw::Pattern { ops: f, even_odd, tile, region }] } } - Raw::Bitmap { rect, img } => crop(rect, img, c).map(|(rect, img)| vec![Raw::Bitmap { rect, img }]).unwrap_or_default(), - Raw::Text(mut t) => { + Raw::Bitmap { rect, img, region } => crop(rect, img, c).map(|(rect, img)| vec![Raw::Bitmap { rect, img, region }]).unwrap_or_default(), + Raw::Text { text: mut t, region } => { let cur = t.clip.map_or(c, |[a, b, d, e]| intersect(c, [f64::from(a), f64::from(b), f64::from(d), f64::from(e)])); t.clip = Some(cur.map(|v| v as f32)); - vec![Raw::Text(t)] + vec![Raw::Text { text: t, region }] } } } diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 634ecd36..81993a9e 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -1733,7 +1733,7 @@ fn draw_image(id: u16, dst: [f32; 4]) -> Vec { fn emfplus_draw_image_places_the_bitmap() { let p = plus_pic(vec![prec(0x4008, 0x0500 | 4, &bitmap_2x2()), draw_image(4, [10.0, 10.0, 20.0, 40.0])]); match &p.items[0] { - Item::Bitmap { rect, width, height, rgba } => { + Item::Bitmap { rect, width, height, rgba, .. } => { assert_eq!((*width, *height), (2, 2)); assert_eq!(rect, &[0.0, 0.0, 20.0, 40.0]); assert_eq!(&rgba[..8], &[255, 0, 0, 255, 0, 255, 0, 255]); From 389c4dd5d0f9d865bd511104c45d5320f8731965 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 13:56:56 -0400 Subject: [PATCH 26/27] Fix up merged PRs: #588's polygon clip and nested metafiles with clip regions Move #588's clip.rs changes into cliprect.rs, carry main's per-item clip region through the polygon clipper, and build nested-metafile items with the region field main added (the nested picture's own regions are not carried over; each item takes the canvas's current clip). Co-Authored-By: Claude Opus 5.5 --- crates/metafile/src/cliprect.rs | 20 ++++++++++---------- crates/metafile/src/emfplus.rs | 21 ++++++++++++++------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/crates/metafile/src/cliprect.rs b/crates/metafile/src/cliprect.rs index 8290dd37..396f4402 100644 --- a/crates/metafile/src/cliprect.rs +++ b/crates/metafile/src/cliprect.rs @@ -143,10 +143,10 @@ fn bbox_inside(b: [f64; 4], clip: &[P]) -> bool { pub(crate) fn clip_raw(raw: Raw, clip: &[P]) -> Vec { let Some(cb) = pts_bbox(clip).filter(|_| clip.len() >= 3) else { return Vec::new() }; match raw { - Raw::Path { ops, fill, stroke, even_odd } => { + Raw::Path { ops, fill, stroke, even_odd, region } => { let Some(b) = ops_bbox(&ops) else { return Vec::new() }; if bbox_inside(b, clip) { - return vec![Raw::Path { ops, fill, stroke, even_odd }]; + return vec![Raw::Path { ops, fill, stroke, even_odd, region }]; } if disjoint(b, cb) { return Vec::new(); @@ -156,34 +156,34 @@ pub(crate) fn clip_raw(raw: Raw, clip: &[P]) -> Vec { if fill.is_some() { let f = clip_fill(&subs, clip); if !f.is_empty() { - out.push(Raw::Path { ops: f, fill, stroke: None, even_odd }); + out.push(Raw::Path { ops: f, fill, stroke: None, even_odd, region: region.clone() }); } } if stroke.is_some() { let s = clip_stroke(&subs, clip); if !s.is_empty() { - out.push(Raw::Path { ops: s, fill: None, stroke, even_odd }); + out.push(Raw::Path { ops: s, fill: None, stroke, even_odd, region }); } } out } - Raw::Pattern { ops, even_odd, tile } => { + Raw::Pattern { ops, even_odd, tile, region } => { let Some(b) = ops_bbox(&ops) else { return Vec::new() }; if bbox_inside(b, clip) { - return vec![Raw::Pattern { ops, even_odd, tile }]; + return vec![Raw::Pattern { ops, even_odd, tile, region }]; } if disjoint(b, cb) { return Vec::new(); } let f = clip_fill(&flatten(&ops), clip); - if f.is_empty() { Vec::new() } else { vec![Raw::Pattern { ops: f, even_odd, tile }] } + if f.is_empty() { Vec::new() } else { vec![Raw::Pattern { ops: f, even_odd, tile, region }] } } - Raw::Bitmap { rect, img } => crop(rect, img, clip).map(|(rect, img)| vec![Raw::Bitmap { rect, img }]).unwrap_or_default(), - Raw::Text(mut t) => { + Raw::Bitmap { rect, img, region } => crop(rect, img, clip).map(|(rect, img)| vec![Raw::Bitmap { rect, img, region }]).unwrap_or_default(), + Raw::Text { text: mut t, region } => { // Text clips to a rectangle: the one around the polygon. let cur = t.clip.map_or(cb, |[a, b, d, e]| intersect(cb, [f64::from(a), f64::from(b), f64::from(d), f64::from(e)])); t.clip = Some(cur.map(|v| v as f32)); - vec![Raw::Text(t)] + vec![Raw::Text { text: t, region }] } } } diff --git a/crates/metafile/src/emfplus.rs b/crates/metafile/src/emfplus.rs index a8c93775..fb757b63 100644 --- a/crates/metafile/src/emfplus.rs +++ b/crates/metafile/src/emfplus.rs @@ -737,15 +737,21 @@ impl Painter { }) .collect() }; + // The nested picture's own clip regions are not carried over: each item takes the + // canvas's current clip. for item in &pic.items { if cv.full() { break; } let raw = match item { - Item::Path { segs, fill, stroke, even_odd } => { - Raw::Path { ops: ops(segs), fill: *fill, stroke: stroke.map(|(c, w)| (c, f64::from(w) * scale)), even_odd: *even_odd } - } - Item::Pattern { segs, even_odd, tile } => Raw::Pattern { + Item::Path { segs, fill, stroke, even_odd, .. } => Raw::Path { + ops: ops(segs), + fill: *fill, + stroke: stroke.map(|(c, w)| (c, f64::from(w) * scale)), + even_odd: *even_odd, + region: None, + }, + Item::Pattern { segs, even_odd, tile, .. } => Raw::Pattern { ops: ops(segs), even_odd: *even_odd, tile: Arc::new(RawTile { @@ -754,12 +760,13 @@ impl Painter { px: tile.rgba.clone(), cell: f64::from(tile.cell[0]) * scale, }), + region: None, }, - Item::Bitmap { rect, width, height, rgba } => { + Item::Bitmap { rect, width, height, rgba, .. } => { let (a, b) = (pt(rect[0], rect[1]), pt(rect[0] + rect[2], rect[1] + rect[3])); let mut img = Rgba { w: *width as usize, h: *height as usize, bottom_up: false, px: rgba.clone() }; crate::dib::flip(&mut img, a.0 > b.0, a.1 > b.1); - Raw::Bitmap { rect: [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()], img } + Raw::Bitmap { rect: [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()], img, region: None } } Item::Text(t) => { let mut t = t.clone(); @@ -768,7 +775,7 @@ impl Painter { let (p, q) = (pt(x0, y0), pt(x1, y1)); t.clip = Some([p.0.min(q.0) as f32, p.1.min(q.1) as f32, p.0.max(q.0) as f32, p.1.max(q.1) as f32]); } - Raw::Text(t) + Raw::Text { text: t, region: None } } }; cv.push_device(raw); From b2b378a3f0b6d7bc6d4686ac6c8546846fabd0fa Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 14 Oct 2026 14:13:50 -0400 Subject: [PATCH 27/27] Fix up merged PRs: dedupe doc rows left by the union merges One EMF / WMF parity row naming both GDI clip regions and EMF+ playback; drop the stale Tables, Charts, chartex and SmartArt rows that came back with the chartex PRs; one SVG-gap sentence; one charts-gap paragraph. Co-Authored-By: Claude Opus 5.5 --- docs/file-format-parity.md | 9 ++------- docs/gaps.md | 22 +++++++--------------- 2 files changed, 9 insertions(+), 22 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6a3cde7a..b99afa0e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -50,8 +50,7 @@ PowerPoint opens and saves; Viewer = opens only). |---|---|---|---| | PNG, JPEG, GIF (animated), BMP, TIFF, WebP | yes | yes | `image` 0.25; animated GIFs play since #39 | | SVG | yes (with PNG fallback) | **no** | The PNG fallback shows; SVG files can't be inserted | -| EMF / WMF | yes | partial | Drawn as vectors (paths, pens, pattern and hatch brushes, bitmaps, text in the document's fonts with the file's character advances). Rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing; EMF+ records are not drawn. Legacy OLE objects (VML preview) and ActiveX controls show their pictures | -| EMF / WMF | yes | partial | Drawn as vectors (paths, pens, pattern and hatch brushes, bitmaps, text in the document's fonts with the file's character advances); clip regions; EMF+ only pictures play every EMF+ drawing record (brushes incl. gradients and textures, pens, paths, curves, images, strings, transforms, clipping; see `crates/metafile/src/emfplus.rs` for the few exceptions). Legacy OLE objects (VML preview) and ActiveX controls show their pictures | +| EMF / WMF | yes | partial | Drawn as vectors (paths, pens, pattern and hatch brushes, bitmaps, text in the document's fonts with the file's character advances). Rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing. EMF+ only pictures play every EMF+ drawing record (brushes incl. gradients and textures, pens, paths, curves, images, strings, transforms, clipping; see `crates/metafile/src/emfplus.rs` for the few exceptions). Legacy OLE objects (VML preview) and ActiveX controls show their pictures | | HEIC | yes (macOS) | no | | | Audio: MP3, AAC/M4A, ALAC, WAV, AIFF, CAF, FLAC, Ogg Vorbis, Opus | yes (Ogg/Opus partly) | yes | Pure-Rust decoders in `crates/media`, `crates/opus` | | WMA | yes (Windows) | embedded, not playable | | @@ -91,13 +90,9 @@ the round trip even though DeckCraft doesn't model it. | Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | | | Tables with styles | yes | yes | Merges and both diagonal borders round-trip | | Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw`. Trendlines (type, order or period, forecast, intercept, equation and R²) are read, written and drawn. A combo chart's secondary value axis is read and is not written back when the chart is rebuilt | -| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, box) | no | no | | +| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, Pareto, box) | yes | yes | Unchanged parts kept; edited and new ones written from the model with a plain-chart fallback. Treemap and sunburst hierarchy levels kept; region maps kept, drawn as tiles (no map geometry) | | SmartArt (`dgm`) | drawing part only, as a group; text laid out on its `dsp:txXfrm` rectangle | as shapes | Diagram data model lost on save | | SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save. A diagram with no relationship parts keeps its XML; the drawing and data parts are still dropped (#203) | -| Tables with styles | yes | yes | | -| Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw` | -| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, Pareto, box) | yes | yes | Unchanged parts kept; edited and new ones written from the model with a plain-chart fallback. Treemap and sunburst hierarchy levels kept; region maps kept, drawn as tiles (no map geometry) | -| SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save | | Equations (OMML, `a14:m`) | no | no | PR #55 open | | Audio / video (`p14:media`, trim, fades, bookmarks) | yes | partly | Playback options lost on save (#74) | | Ink (`contentPart`) | yes | as a group of freeform lines | PowerPoint sees shapes, not ink | diff --git a/docs/gaps.md b/docs/gaps.md index ebba81af..824f7cbe 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -138,10 +138,8 @@ Detail in [file-format-parity.md](file-format-parity.md). ### SVG pictures don't render - **Evidence:** `crates/render/src/images.rs` decodes through `image` 0.25 (PNG, JPEG, WebP, GIF, TIFF, BMP), and EMF/WMF through `deckcraft-metafile` (ported from WordCraft: paths, fills, pens, - pattern and hatch brushes, bitmaps and text; rectangle clip regions are applied, EMF+ records are - not drawn). - pattern and hatch brushes, bitmaps and text; EMF+ only pictures play their EMF+ records; - clip regions are not drawn). + pattern and hatch brushes, bitmaps and text; rectangle clip regions are applied, EMF+ only + pictures play their EMF+ records). Nothing reads SVG; PowerPoint's Insert Icons stores SVG with a PNG fallback. - **Impact:** SVG icons show only their raster fallback, and SVG files can't be inserted. - **Estimate:** 4–8 h (an SVG renderer through `resvg`-style crates). @@ -207,10 +205,6 @@ Detail in [file-format-parity.md](file-format-parity.md). ### Charts: data grid, elements and the newer chart types - **Evidence:** checklist rows "Edit chart data" (P0, partial), "Chart elements" (partial), "Other - chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, box & - whisker) aren't read. Trendlines round-trip (`corpus_charts.rs`) and are not drawn. A combo - chart's secondary value axis is read (`y2_*`, `Series.secondary_axis`) and is not written back - when the chart is rebuilt. chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, Pareto, box & whisker) are read, drawn, inserted, edited and saved as `chartEx` (kept unchanged, else written from the model, with a plain chart fallback). Waterfalls float and total, funnels, @@ -220,13 +214,11 @@ Detail in [file-format-parity.md](file-format-parity.md). (their cached geography too, also through edits) and drawn as value-coloured tiles with a colour scale, since DeckCraft ships no map boundaries. Radar charts are drawn polar (filled or lines), stock charts as high-low lines with close dashes or up/down bars and saved as - `c:stockChart`, and combo charts start as columns with a line. Secondary-axis writing and - trendlines are tracked separately. - whisker) aren't read. Trendlines round-trip (`corpus_charts.rs`) and are drawn: linear, - exponential, logarithmic, polynomial, power and moving average, with forecast and the equation - and R² label. A moving average is drawn with neither label. A combo chart's secondary value axis - is read (`y2_*`, `Series.secondary_axis`) and - is not written back when the chart is rebuilt. + `c:stockChart`, and combo charts start as columns with a line. Trendlines round-trip + (`corpus_charts.rs`) and are drawn: linear, exponential, logarithmic, polynomial, power and + moving average, with forecast and the equation and R² label. A moving average is drawn with + neither label. A combo chart's secondary value axis is read (`y2_*`, + `Series.secondary_axis`) and is not written back when the chart is rebuilt. - **Impact:** most business decks have charts; editing their data is routine. - **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h).