Browse / github.com/storytold/deckcraft / patch
Charts: a written chart carries the text sizes it is drawn at
Charts: a written chart carries the text sizes it is drawn at ## What A chart written from the model (every chart made in DeckCraft, and an edited one) had no text sizes in its XML. PowerPoint then draws its axis labels and legend at its 10 pt default and its title at 18 pt, whatever the chart's size. DeckCraft draws them sized to the chart, so a DeckCraft-made chart looked quite different once opened in PowerPoint. ## Evidence A synthetic deck written by DeckCraft, with a 560×360 and a 300×200 column chart, each with a title and a legend, exported as PDF from PowerPoint for Mac 16.114 (sizes measured from the PDF's word boxes): | | DeckCraft draws |
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- pick2510 (imported from pull request #1296, open)
- Base
aaf6c1c38aa6c59beda8110bd014cc6c96c6ec74- Size
- +123 −31 in 6 files
- Review
- pending
- Shared
- 2026-10-28
- Id
bb2ceeb1bacf66e178915c33
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/model/src/chart.rs crates/pptx/src/write/chart.rs crates/pptx/src/write/mod.rs crates/pptx/src/write/shapes.rs crates/render/src/chart.rs
@@ -137,6 +137,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---|+| 2026-10-26 | A chart written to .pptx carries the text sizes DeckCraft draws it at (in the `c:txPr` that also carries its text colour, and in the title's and axis titles' rich text), so PowerPoint shows a DeckCraft-made chart as DeckCraft does instead of at its 10 pt / 18 pt defaults; automatic chart text sizes are whole points, as PowerPoint for Mac draws chart text (synthetic probe) | | 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI `<Icon>`, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once | | 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off | | 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table |@@ -312,6 +313,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged | | 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) | | 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) |+| 2026-10-26 | trivial | Progress log: written charts carry their text sizes | | 2026-10-24 | trivial | Progress log: picture and embedded object fills | | 2026-10-22 | trivial | Progress log: table text takes the master's other-text style | | 2026-10-16 | trivial | Progress log: responsive mouse-wheel navigation on the fitted canvas |@@ -491,6 +491,17 @@ impl Default for Chart { } impl Chart {+ /// The text sizes in points this chart's axis labels and legend, and its title, are drawn at+ /// in a `w` × `h` frame: the file's own (PowerPoint draws chart text at them, not scaled to the+ /// chart), else a size that suits the frame, in whole points as PowerPoint for Mac draws chart+ /// text (a 16.36 pt size shows at 16 pt). A .pptx is written with these sizes, so PowerPoint+ /// shows the chart as DeckCraft does.+ pub fn text_sizes(&self, w: f64, h: f64) -> (f64, f64) {+ let auto = h.min(w) / 22.0;+ let base = self.text_size.unwrap_or(if auto.is_finite() { auto.clamp(7.0, 18.0).round() } else { 10.0 });+ (base, self.title_size.unwrap_or((base * 1.4).round()))+ }+ fn sample_treemap_sunburst(kind: ChartType) -> Self { let mut c = Chart { kind,@@ -68,46 +68,59 @@ fn sp_pr(w: &mut W, fill: Option<&Fill>, line: Option<&deckcraft_model::Line>) { w.close("c:spPr"); } -/// `a:defRPr` carrying the chart's text colour, where PowerPoint keeps it: the paragraph-/// defaults of a title's rich text and of every `c:txPr`.-fn def_rpr(w: &mut W, color: Option<&deckcraft_model::ColorRef>) {+/// `a:defRPr sz` for a text size in points (hundredths, in the range the schema allows).+fn sz(pt: f64) -> i64 {+ if pt.is_finite() { (pt * 100.0).round().clamp(100.0, 400_000.0) as i64 } else { 1000 }+}++/// `a:defRPr` carrying the chart's text colour and size, where PowerPoint keeps them: the+/// paragraph defaults of a title's rich text and of every `c:txPr`.+fn def_rpr(w: &mut W, color: Option<&deckcraft_model::ColorRef>, size: Option<f64>) {+ let a = A::new().o("sz", size.map(sz)); match color { Some(c) => {- w.open0("a:defRPr");+ w.open("a:defRPr", a); super::dml::solid(w, c); w.close("a:defRPr"); }- None => w.empty0("a:defRPr"),+ None => w.empty("a:defRPr", a), } } -/// `c:txPr` with the chart's text colour (`Chart::text_color`) as the default run properties, on-/// the axes, the legend, the data labels and the chart itself, as PowerPoint writes it. Nothing-/// without a colour: the theme's default then applies on reading.+/// `c:txPr` with the chart's text colour (`Chart::text_color`) and size (`Chart::text_size`) as+/// the default run properties, on the axes, the legend, the data labels and the chart itself, as+/// PowerPoint writes it. Nothing without either: the theme's default then applies on reading. fn tx_pr(w: &mut W, c: &Chart) {- let Some(color) = &c.text_color else { return };+ tx_pr_sized(w, c, c.text_size);+}++/// [`tx_pr`] with another text size (data labels drawn smaller than the chart's text).+fn tx_pr_sized(w: &mut W, c: &Chart, size: Option<f64>) {+ if c.text_color.is_none() && size.is_none() {+ return;+ } w.open0("c:txPr"); w.empty0("a:bodyPr"); w.empty0("a:lstStyle"); w.open0("a:p"); w.open0("a:pPr");- def_rpr(w, Some(color));+ def_rpr(w, c.text_color.as_ref(), size); w.close("a:pPr"); w.empty("a:endParaRPr", A::new().a("lang", "en-US")); w.close("a:p"); w.close("c:txPr"); } -fn rich(w: &mut W, text: &str, color: Option<&deckcraft_model::ColorRef>) {+fn rich(w: &mut W, text: &str, color: Option<&deckcraft_model::ColorRef>, size: Option<f64>) { w.open0("c:tx"); w.open0("c:rich"); w.empty0("a:bodyPr"); w.empty0("a:lstStyle"); for line in text.split('\n') { w.open0("a:p");- if color.is_some() {+ if color.is_some() || size.is_some() { w.open0("a:pPr");- def_rpr(w, color);+ def_rpr(w, color, size); w.close("a:pPr"); } w.open0("a:r");@@ -124,10 +137,10 @@ fn rich(w: &mut W, text: &str, color: Option<&deckcraft_model::ColorRef>) { w.close("c:tx"); } -/// A title (the chart's or an axis's) in the chart's text colour.-fn title(w: &mut W, text: &str, c: &Chart) {+/// A title (the chart's or an axis's) in the chart's text colour, at `size` points when known.+fn title(w: &mut W, text: &str, c: &Chart, size: Option<f64>) { w.open0("c:title");- rich(w, text, c.text_color.as_ref());+ rich(w, text, c.text_color.as_ref(), size); w.val("c:overlay", 0); w.close("c:title"); }@@ -268,7 +281,7 @@ fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) } } if let Some(d) = s.data_labels {- data_labels(w, c, d);+ data_labels(w, c, g, d); } // After labels, before the category and value caches (ECMA-376 series content order). if matches!(g, Group::Bar | Group::Line | Group::Area | Group::Scatter) {@@ -376,9 +389,12 @@ fn trendline(w: &mut W, t: &Trendline) { w.close("c:trendline"); } -fn data_labels(w: &mut W, c: &Chart, show: bool) {+fn data_labels(w: &mut W, c: &Chart, g: Group, show: bool) { w.open0("c:dLbls");- tx_pr(w, c);+ // At the size they are drawn: a bar's or radar point's label at 0.9 of the chart's text, a+ // slice's at its full size.+ let size = c.text_size.map(|t| if matches!(g, Group::Bar | Group::Radar) { t * 0.9 } else { t });+ tx_pr_sized(w, c, size); for (n, v) in [ ("c:showLegendKey", false), ("c:showVal", show),@@ -407,7 +423,7 @@ fn axes(w: &mut W, c: &Chart, g: Group) { w.val("c:delete", 0); w.val("c:axPos", "b"); if let Some(t) = &c.axis_titles.0 {- title(w, t, c);+ title(w, t, c, c.text_size); } w.empty("c:numFmt", A::new().a("formatCode", "General").a("sourceLinked", 1)); w.val("c:majorTickMark", "out");@@ -427,7 +443,7 @@ fn axes(w: &mut W, c: &Chart, g: Group) { w.val("c:delete", 0); w.val("c:axPos", cat_pos); if let Some(t) = &c.axis_titles.0 {- title(w, t, c);+ title(w, t, c, c.text_size); } w.empty("c:numFmt", A::new().a("formatCode", "General").a("sourceLinked", 1)); w.val("c:majorTickMark", "none");@@ -465,7 +481,7 @@ fn axes(w: &mut W, c: &Chart, g: Group) { w.empty0("c:minorGridlines"); } if let Some(t) = &c.axis_titles.1 {- title(w, t, c);+ title(w, t, c, c.text_size); } match c.y_format.as_deref().filter(|f| !f.is_empty()) { Some(f) => w.empty("c:numFmt", A::new().a("formatCode", f).a("sourceLinked", 0)),@@ -523,7 +539,7 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)]) series(w, c, s, *i, kind, g); } if c.data_labels {- data_labels(w, c, true);+ data_labels(w, c, g, true); } match g { Group::Bar => {@@ -599,7 +615,15 @@ fn kept_chartex(c: &Chart) -> Option<Vec<u8>> { } /// The chart part XML, and whether it is the original part (so its relationships still apply).+#[cfg(test)] pub fn chart_xml(c: &Chart) -> (Vec<u8>, bool) {+ chart_xml_in(c, None)+}++/// [`chart_xml`] for a chart drawn in a `frame` (width, height in points). A chart written from+/// the model then carries the text sizes DeckCraft draws it at (`Chart::text_sizes`): without+/// them PowerPoint falls back to 10 pt text and an 18 pt title, whatever the chart's size.+pub fn chart_xml_in(c: &Chart, frame: Option<(f64, f64)>) -> (Vec<u8>, bool) { // Reuse the kept part when the chart is unchanged since it was read. if let Some(raw) = &c.raw && !raw.contains(NS_CX)@@ -623,6 +647,16 @@ pub fn chart_xml(c: &Chart) -> (Vec<u8>, bool) { } } }+ // Written from the model: with the frame's size, the sizes the chart is drawn at.+ let sized;+ let c = match frame {+ Some((fw, fh)) => {+ let (text, title) = c.text_sizes(fw, fh);+ sized = Chart { text_size: Some(text), title_size: Some(title), ..c.clone() };+ &sized+ }+ None => c,+ }; let mut w = W::new(); w.open("c:chartSpace", A::new().a("xmlns:c", NS_C).a("xmlns:a", NS_A).a("xmlns:r", NS_R)); w.val("c:date1904", 0);@@ -631,7 +665,7 @@ pub fn chart_xml(c: &Chart) -> (Vec<u8>, bool) { w.open0("c:chart"); match &c.title { Some(t) => {- title(&mut w, t, c);+ title(&mut w, t, c, c.title_size); w.val("c:autoTitleDeleted", 0); } None => w.val("c:autoTitleDeleted", 1),@@ -1099,4 +1133,51 @@ mod tests { c.cat_reversed = false; assert!(!crate::read::chart::read_chart_xml(&super::chart_xml(&c).0).unwrap().cat_reversed); }++ #[test]+ fn a_written_chart_carries_the_text_sizes_it_is_drawn_at() {+ // Without sizes PowerPoint draws 10 pt text and an 18 pt title at any chart size; with+ // them it draws what DeckCraft draws (whole points: a 560 x 360 chart at 16 and 22 pt).+ let mut c = Chart::sample(ChartType::Column);+ c.title = Some("Sales".into());+ c.axis_titles = (Some("Month".into()), None);+ c.raw = None;+ let read = |c: &Chart, frame| crate::read::chart::read_chart_xml(&super::chart_xml_in(c, frame).0).unwrap();+ let big = read(&c, Some((560.0, 360.0)));+ assert_eq!((big.text_size, big.title_size), (Some(16.0), Some(22.0)));+ assert_eq!(c.text_sizes(560.0, 360.0), (16.0, 22.0), "the sizes the renderer draws at");+ let xml = String::from_utf8(super::chart_xml_in(&c, Some((560.0, 360.0))).0).unwrap();+ assert!(xml.contains(r#"<c:txPr><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="1600"/>"#), "{xml}");+ assert_eq!(xml.matches(r#"<a:defRPr sz="2200"/>"#).count(), 1, "the chart title");+ assert!(xml.contains(r#"<c:title><c:tx><c:rich><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="1600"/></a:pPr><a:r><a:t>Month"#));+ // A bar's data labels at the 0.9 of the text they are drawn at, a slice's at all of it.+ c.series[0].data_labels = Some(true);+ let labelled = String::from_utf8(super::chart_xml_in(&c, Some((560.0, 360.0))).0).unwrap();+ assert!(labelled.contains(r#"<c:dLbls><c:txPr><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="1440"/>"#), "{labelled}");+ assert_eq!(read(&c, Some((560.0, 360.0))).text_size, Some(16.0), "labels don't set the chart's size");+ let mut pie = c.clone();+ pie.kind = ChartType::Pie;+ let pie = String::from_utf8(super::chart_xml_in(&pie, Some((560.0, 360.0))).0).unwrap();+ assert!(pie.contains(r#"<c:dLbls><c:txPr><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="1600"/>"#), "{pie}");+ c.series[0].data_labels = None;+ let small = read(&c, Some((300.0, 200.0)));+ assert_eq!((small.text_size, small.title_size), (Some(9.0), Some(13.0)));+ // The file's own sizes are written back as they are, with or without a frame.+ c.text_size = Some(11.97);+ c.title_size = Some(18.62);+ let own = read(&c, Some((560.0, 360.0)));+ assert_eq!((own.text_size, own.title_size), (Some(11.97), Some(18.62)));+ assert_eq!(read(&c, None).text_size, Some(11.97));+ // No frame and no sizes: none written (the chart keeps choosing its own).+ c.text_size = None;+ c.title_size = None;+ let bare = read(&c, None);+ assert_eq!((bare.text_size, bare.title_size), (None, None));+ // With a text colour as well, both go in the same default run properties.+ c.text_color = Some(ColorRef::rgb(Rgba::rgb(0x40, 0x40, 0x40)));+ let xml = String::from_utf8(super::chart_xml_in(&c, Some((560.0, 360.0))).0).unwrap();+ assert!(xml.contains(r#"<a:defRPr sz="1600"><a:solidFill><a:srgbClr val="404040"/></a:solidFill></a:defRPr>"#), "{xml}");+ let both = read(&c, Some((560.0, 360.0)));+ assert_eq!((both.text_size, both.title_size, both.text_color), (Some(16.0), Some(22.0), c.text_color.clone()));+ } }@@ -209,10 +209,11 @@ impl Exp<'_> { 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 {+ /// The chart part for `c`, drawn in a `frame` (width, height in points) when known.+ pub fn chart_rel(&mut self, o: &mut Out, c: &deckcraft_model::Chart, frame: Option<(f64, f64)>) -> String { self.n_chart += 1; let name = format!("ppt/charts/chart{}.xml", self.n_chart);- let (xml, preserved) = chart::chart_xml(c);+ let (xml, preserved) = chart::chart_xml_in(c, frame); self.pkg.xml_part(&name, CT_CHART, xml); if preserved { chart::write_kept(&mut self.pkg, &name, c, &mut self.n_kept);@@ -405,7 +405,7 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth // The kept workbook belongs to the `chartEx` part; the fallback has none. plain.kept = Default::default(); }- let rid = x.chart_rel(o, &plain);+ let rid = x.chart_rel(o, &plain, s.xfrm.map(|f| (f.w, f.h))); 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));@@ -283,10 +283,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, rctx.pres, ); }- // The file's text sizes when it has them (PowerPoint draws chart text at them); otherwise a size- // that suits the chart.- let base = c.text_size.unwrap_or_else(|| (h.min(w) / 22.0).clamp(7.0, 18.0));- let title_size = c.title_size.unwrap_or(base * 1.4);+ let (base, title_size) = c.text_sizes(w, h); let mut area = Rect::new(w * 0.04, h * 0.04, w * 0.96, h * 0.96); if let Some(t) = &c.title { label(ctx, rctx, m, t, Rect::new(0.0, area.y0, w, area.y0 + title_size * 1.3), title_size, Align::Center, Anchor::Top, text_col, false);