Files
wordcraft/crates/engine/src/cmd/chart.rs
T
Claude 90898f454b Gap 19: ODT charts keep their look; frame outlines; default header height
Charts: on save, WordCraft wrote each chart anew from its chart model
(type, data, title, legend, labels), losing the look it doesn't model:
fonts and sizes, axis and grid lines.
- The chart object as read is now kept with the chart (`Chart::source`
  in the flow model, `Graphic::source` in the document).
- `chart::redraw` keeps it when the chart is drawn again on open or
  resize.
- The ODT writer writes it back whole while it still reads as the same
  chart. An edited chart is written from the model, as before; DOCX
  still writes charts from their model.
- The chart documents now round-trip pixel-identical (chartcol: 5,917
  pixels to 0).

LibreOffice's serif business letter (4,223 pixels to 116):
- A text frame outlined by a drawn stroke (`draw:stroke`, a 0 width a
  hairline) and no `fo:border` lost its outline. It is now the frame's
  line; an explicit "none" border still wins.
- A header or footer whose style states no height, or that has no style,
  was 0 tall. LibreOffice makes it 0.5 cm (measured by varying the
  height), so the text had moved up 1.5 pt.

Across the corpus the round-trip difference falls from 60k to 47k
pixels, and 41 of 125 documents round-trip pixel-identical. No document
gets worse.

gaps.md row 19 and the ROADMAP log are updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
2026-11-02 17:11:20 +00:00

249 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Insert › Chart and the Chart Design tab: insert a chart, change its type, data, title, legend
//! and data labels.
//!
//! A chart is an inline [`InlineObject::Graphic`] whose graphic keeps its model
//! ([`ChartSpec`]); it is drawn by the same code that draws charts read from files, and saved as
//! a chart part with its data in it (see `wordcraft_docx::chart_xml`). Charts made by other
//! programs are shown as they are; the chart commands only edit charts with a model.
use std::sync::Arc;
use serde_json::{Value, json};
use wordcraft_doc::chart::{ChartSeries, ChartSpec, ChartType, LegendPos, MAX_POINTS, MAX_SERIES};
use wordcraft_doc::graphic::{Graphic, GraphicKind};
use wordcraft_doc::para::{Float, InlineObject};
use wordcraft_doc::props::Rgb;
use super::objects::selected;
use super::{delete_selection, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
/// A new chart's size: 15 × 7.5 cm, points.
pub const DEFAULT_W: f32 = 15.0 / 2.54 * 72.0;
pub const DEFAULT_H: f32 = 7.5 / 2.54 * 72.0;
const TYPES: &str = "column|columnStacked|bar|barStacked|line|area|pie|doughnut|scatter";
const DATA: &str = r#"{"categories": [string], "series": [{"name": string, "values": [number|null]}]}"#;
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("insert.chart", "Chart", "Insert › Illustrations", insert).params(
r#"{"type"?: "column|columnStacked|bar|barStacked|line|area|pie|doughnut|scatter", "data"?: {"categories": [string], "series": [{"name": string, "values": [number|null]}]}, "title"?: string, "legend"?: "right|left|top|bottom|none", "dataLabels"?: bool, "width"?: pt, "height"?: pt} (no params: the chart type picker)"#,
),
CommandSpec::new("chart.type", "Change Chart Type", "Chart Design › Type", |s, v| {
let kind = chart_type(v)?.ok_or_else(|| CmdError::Params(format!("`type` is required: {TYPES}")))?;
edit(s, |c| {
c.kind = kind;
Ok(())
})
})
.params(r#"{"type": "column|columnStacked|bar|barStacked|line|area|pie|doughnut|scatter"}"#)
.when(has_chart),
CommandSpec::new("chart.editData", "Edit Data", "Chart Design › Data", |s, v| {
let d = v.get("data").unwrap_or(v);
let (cats, series) = (d.get("categories"), d.get("series"));
if cats.is_none() && series.is_none() {
return Err(CmdError::Params(format!("`data` is required: {DATA}")));
}
let cats = cats.map(categories).transpose()?;
let series = series.map(series_list).transpose()?;
edit(s, move |c| {
if let Some(cats) = &cats {
c.categories = cats.clone();
}
if let Some(series) = &series {
c.series = series.clone();
}
Ok(())
})
})
.params(r#"{"data": {"categories"?: [string], "series"?: [{"name": string, "values": [number|null]}]}}"#)
.when(has_chart),
CommandSpec::new("chart.title", "Chart Title", "Chart Design › Chart Layouts › Add Chart Element", |s, v| {
let text = match v.get("text") {
Some(Value::String(t)) => Some(Some(t.clone())),
Some(Value::Null) => Some(None),
None => None,
Some(_) => return Err(CmdError::Params("`text`: a string, or null for no title".into())),
};
edit(s, move |c| {
c.title = match &text {
Some(t) => t.clone(),
// No text given: switch the title on (placeholder) or off.
None if c.title.is_some() => None,
None => Some("Chart Title".into()),
};
Ok(())
})
})
.params(r#"{"text"?: string|null} (no text: switch the title on or off)"#)
.when(has_chart),
CommandSpec::new("chart.legend", "Legend", "Chart Design › Chart Layouts › Add Chart Element", |s, v| {
let pos = match p::str(v, "pos").or_else(|| p::str(v, "position")) {
Some(id) if id.eq_ignore_ascii_case("none") => Some(None),
Some(id) => Some(Some(LegendPos::from_id(id).ok_or_else(|| CmdError::Params("`pos`: right|left|top|bottom|none".into()))?)),
None => None,
};
edit(s, move |c| {
c.legend = match pos {
Some(p) => p,
None if c.legend.is_some() => None,
None => Some(LegendPos::Bottom),
};
Ok(())
})
})
.params(r#"{"pos"?: "right|left|top|bottom|none"} (no pos: switch the legend on or off)"#)
.when(has_chart),
CommandSpec::new("chart.dataLabels", "Data Labels", "Chart Design › Chart Layouts › Add Chart Element", |s, v| {
let on = p::bool(v, "on").or_else(|| p::bool(v, "value"));
edit(s, move |c| {
c.data_labels = on.unwrap_or(!c.data_labels);
Ok(())
})
})
.params(r#"{"on"?: bool} (no value: switch the data labels on or off)"#)
.when(has_chart),
]
}
/// The selected chart's model, when it has one.
pub fn selected_chart(s: &Session) -> Option<Arc<ChartSpec>> {
match selected(s) {
Some((_, InlineObject::Graphic { graphic, .. })) => graphic.chart.clone(),
_ => None,
}
}
fn has_chart(s: &Session) -> Option<&'static str> {
match selected(s) {
Some((_, InlineObject::Graphic { graphic, .. })) if graphic.chart.is_some() => None,
Some((_, InlineObject::Graphic { graphic, .. })) if graphic.kind == GraphicKind::Chart => {
Some("this chart was made in another program: WordCraft shows it but can't edit it")
}
_ => Some("select a chart first"),
}
}
/// The graphic for `spec` drawn `w` × `h` points in the theme's colours.
pub fn chart_graphic(spec: ChartSpec, theme: &[Rgb], w: f32, h: f32) -> Graphic {
let spec = spec.sanitized();
let items = wordcraft_docx::chart_items(&spec, theme, w, h);
Graphic { kind: GraphicKind::Chart, items, w, h, source: None, chart: Some(Arc::new(spec)), smart_art: None }
}
/// `type` of the params, if given.
fn chart_type(v: &Value) -> Result<Option<ChartType>, CmdError> {
match p::str(v, "type") {
Some(id) => ChartType::from_id(id).map(Some).ok_or_else(|| CmdError::Params(format!("unknown chart type `{id}`: {TYPES}"))),
None if v.get("type").is_some_and(|t| !t.is_null()) => Err(CmdError::Params(format!("`type`: {TYPES}"))),
None => Ok(None),
}
}
fn insert(s: &mut Session, v: &Value) -> CmdResult {
let kind = chart_type(v)?;
// No type and no data: Insert › Chart's picker, as the ribbon button does.
if kind.is_none() && v.get("data").is_none() {
s.push_ui_request(json!({"open": "insertChart"}));
return sel_result(s);
}
let kind = kind.unwrap_or_default();
let mut spec = ChartSpec::sample(kind);
if let Some(d) = v.get("data") {
spec.categories = d.get("categories").map(categories).transpose()?.unwrap_or_default();
spec.series = d.get("series").map(series_list).transpose()?.unwrap_or_default();
if spec.series.is_empty() {
return Err(CmdError::Params(format!("`data.series` needs at least one series: {DATA}")));
}
}
spec.title = p::str(v, "title").map(str::to_string);
match p::str(v, "legend") {
Some(id) if id.eq_ignore_ascii_case("none") => spec.legend = None,
Some(id) => spec.legend = Some(LegendPos::from_id(id).ok_or_else(|| CmdError::Params("`legend`: right|left|top|bottom|none".into()))?),
None => {}
}
spec.data_labels = p::bool(v, "dataLabels").unwrap_or(false);
// Fitted to the text width, as Word sizes a new chart.
let max_w = s.doc.sections().first().map(|(_, sp)| sp.text_width()).unwrap_or(468.0).max(36.0);
let w = p::f32(v, "width").unwrap_or(DEFAULT_W).clamp(36.0, 2000.0).min(max_w);
let h = p::f32(v, "height").unwrap_or(DEFAULT_H).clamp(36.0, 2000.0);
let graphic = chart_graphic(spec, &s.doc.settings.theme_colors, w, h);
let out = json!({"chart": graphic.chart.as_deref(), "width": w, "height": h});
let props = s.typing_props();
let at = delete_selection(s)?;
let obj = InlineObject::Graphic { w, h, alt: String::new(), float: Float::default(), graphic: Arc::new(graphic) };
let end = s.doc.insert_object(&at, obj, &props)?;
s.sel = Selection { anchor: at, focus: end };
Ok(out)
}
/// Change the selected chart's model with `f`, then draw it again at its size.
fn edit(s: &mut Session, f: impl Fn(&mut ChartSpec) -> Result<(), CmdError>) -> CmdResult {
let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("select a chart first".into()))?;
let InlineObject::Graphic { w, h, graphic, .. } = obj else { return Err(CmdError::Disabled("select a chart first".into())) };
let mut spec = graphic.chart.as_deref().cloned().ok_or_else(|| CmdError::Disabled("this chart can't be edited".into()))?;
f(&mut spec)?;
let next = Arc::new(chart_graphic(spec, &s.doc.settings.theme_colors, w, h));
let out = json!({"chart": next.chart.as_deref()});
let para = s.doc.para_mut(pos.story, &pos.path)?;
if let Some(InlineObject::Graphic { graphic, .. }) = para.object_at_mut(pos.off) {
*graphic = next;
}
para.touch();
Ok(out)
}
/// Draw the chart of `o` again at its current size (after a resize, or when it opens), if it
/// has a model. It keeps the chart as read (its `source`): the chart itself is unchanged, and
/// saving writes that back with the look WordCraft doesn't model.
pub fn redraw(o: &mut InlineObject, theme: &[Rgb]) {
if let InlineObject::Graphic { w, h, graphic, .. } = o
&& let Some(spec) = graphic.chart.as_deref()
{
let source = graphic.source.clone();
*graphic = Arc::new(Graphic { source, ..chart_graphic(spec.clone(), theme, *w, *h) });
}
}
/// Category labels from JSON: strings, or numbers written as text.
fn categories(v: &Value) -> Result<Vec<String>, CmdError> {
let a = v.as_array().ok_or_else(|| CmdError::Params("`categories`: an array of strings".into()))?;
Ok(a.iter().take(MAX_POINTS).map(cell_text).collect())
}
fn cell_text(v: &Value) -> String {
match v {
Value::String(t) => wordcraft_doc::chart::clean_text(t),
Value::Null => String::new(),
other => wordcraft_doc::chart::clean_text(&other.to_string()),
}
}
/// Series from JSON: `[{"name", "values": [number|null|numeric string]}]`.
fn series_list(v: &Value) -> Result<Vec<ChartSeries>, CmdError> {
let a = v.as_array().ok_or_else(|| CmdError::Params(format!("`series`: {DATA}")))?;
a.iter()
.take(MAX_SERIES)
.map(|s| {
let name = s.get("name").map(cell_text).unwrap_or_default();
let values = match s.get("values") {
Some(Value::Array(vals)) => vals.iter().take(MAX_POINTS).map(number).collect(),
None | Some(Value::Null) => Vec::new(),
Some(_) => return Err(CmdError::Params("`values`: an array of numbers (null for none)".into())),
};
Ok(ChartSeries { name, values })
})
.collect()
}
/// A value: a number, or text that reads as one; anything else (or not finite) is no value.
fn number(v: &Value) -> Option<f64> {
match v {
Value::Number(n) => n.as_f64(),
Value::String(t) => t.trim().parse::<f64>().ok(),
_ => None,
}
.filter(|x| x.is_finite())
}