Insert › Chart: create and edit column, bar, line, pie, area, scatter and doughnut charts (#409) (#415)

* Insert › Chart: create and edit column, bar, line, pie, area, scatter and doughnut charts (#409)

- Chart model (wordcraft_doc::chart::ChartSpec): type (clustered/stacked column and bar, line,
  area, pie, doughnut, scatter), categories, series, title, legend, data labels; sanitized
  (255 series, 4000 points, 100k cells, 255-char text, non-finite values dropped).
- DOCX: charts with a model are written as word/charts/chartN.xml (c:chartSpace, ECMA-376
  §21.2, data as c:strLit/c:numLit, no c:externalData) with relationship and content type.
  On read, a chart part becomes editable only when writing its model again gives the same
  markup; every other chart stays read-only and kept as before (#319).
- Drawing reuses read/chart.rs (the model is drawn from its generated part); data labels
  (c:dLbls showVal) are now drawn for bar, line, area, scatter, pie and doughnut charts.
- Commands: insert.chart, chart.type, chart.editData, chart.title, chart.legend,
  chart.dataLabels (one undo step each); picture.size redraws an editable chart at its size.
- UI: Insert › Chart gallery with previews drawn in code, Chart Design contextual tab, Edit Data
  grid dialog; strings in all 9 catalogs; catalog 408/439.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #409: refresh catalog count and parity checklist after rebase

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 16:26:44 -04:00
1 parent 1a0db9ec65
commit fd014ea9a1
39 files changed
+2200 -34

No files matched your search

+3 -1
View File
@@ -14,7 +14,7 @@ agent control. This page is the summary; the assessment is
| Number | Value | Kind |
|---|---|---|
| Ribbon/menu catalog coverage | **436 / 454 (96.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
| Ribbon/menu catalog coverage | **442 / 459 (96.3%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
| **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated |
| **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions |
| **Mainstream practitioner** | **~55%** | estimated: weekly-work depth × discounts for interaction (×0.93), stability (×0.90), file exchange (×0.90) |
@@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | What landed |
|---|---|
| 2026-10-11 | Insert › Chart (#409): column, bar, line, area, pie, doughnut and scatter charts with Chart Design (type, Edit Data, title, legend, data labels), saved as real chart parts; catalog 408/439 (92.9%) |
| 2026-10-11 | Line Numbers, Hyphenation (zone, CAPS, consecutive limit, Manual), Language, Envelopes and Labels dialogs (#407); hyphenation options kept in DOCX; 30 modal dialogs |
| 2026-10-11 | Accessibility (#386): Alt Text pane for every object and table, Mark as decorative, alt text round-trips in DOCX, deeper checker with fix actions |
| 2026-10-11 | References dialogs (#402): Caption (labels, chapter numbering), Index and Mark Entry, Custom Table of Contents, Source Manager and Create/Edit Source, with TOC/INDEX/XE/SEQ switches honoured on update |
@@ -166,6 +167,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | Progress log: Insert › Chart (#409); catalog 408/439 (92.9%) |
| 2026-10-11 | trivial | Progress log: Line Numbers, Hyphenation, Language, Envelopes and Labels dialogs (#407) |
| 2026-10-11 | trivial | Progress log: Alt Text pane, decorative objects, deeper accessibility checker (#386) |
| 2026-10-11 | trivial | Progress log: Draw lasso, Add Pen, Ink to Shape, Ink Replay (#395); catalog 406/434 |
+261
View File
@@ -0,0 +1,261 @@
//! Charts WordCraft makes and edits (Insert › Chart): the chart type, its data (categories and
//! series), title, legend and data labels.
//!
//! A chart object ([`crate::graphic::Graphic`] with `chart` set) keeps this model next to the
//! items it is drawn with; the DOCX writer turns it into a chart part (`c:chartSpace`) with the
//! data cached in the part itself, and the reader turns a chart part back into it when the part
//! holds nothing this model leaves out. Charts from other programs stay as they were read.
//!
//! Every value from outside (commands, files) goes through [`ChartSpec::sanitize`]: series,
//! points and text lengths are capped and values that aren't finite numbers become gaps.
use serde::{Deserialize, Serialize};
/// Most series in a chart.
pub const MAX_SERIES: usize = 255;
/// Most categories (points per series) in a chart.
pub const MAX_POINTS: usize = 4000;
/// Most cells (series × categories) in a chart: what the chart drawing reads at most.
pub const MAX_CELLS: usize = 100_000;
/// Longest title, series name or category label, characters.
pub const MAX_TEXT: usize = 255;
/// What a chart shows its data as.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ChartType {
/// Clustered columns.
#[default]
Column,
/// Stacked columns.
ColumnStacked,
/// Clustered horizontal bars.
Bar,
/// Stacked horizontal bars.
BarStacked,
Line,
Area,
Pie,
Doughnut,
/// XY scatter with markers: the categories are the x values.
Scatter,
}
impl ChartType {
pub const ALL: [ChartType; 9] = [
ChartType::Column,
ChartType::ColumnStacked,
ChartType::Bar,
ChartType::BarStacked,
ChartType::Line,
ChartType::Area,
ChartType::Pie,
ChartType::Doughnut,
ChartType::Scatter,
];
/// The id commands use (`column`, `barStacked`…).
pub fn id(self) -> &'static str {
match self {
ChartType::Column => "column",
ChartType::ColumnStacked => "columnStacked",
ChartType::Bar => "bar",
ChartType::BarStacked => "barStacked",
ChartType::Line => "line",
ChartType::Area => "area",
ChartType::Pie => "pie",
ChartType::Doughnut => "doughnut",
ChartType::Scatter => "scatter",
}
}
/// The type an id names (any ASCII case).
pub fn from_id(id: &str) -> Option<ChartType> {
ChartType::ALL.into_iter().find(|t| t.id().eq_ignore_ascii_case(id.trim()))
}
/// The name shown for it.
pub fn label(self) -> &'static str {
match self {
ChartType::Column => "Clustered Column",
ChartType::ColumnStacked => "Stacked Column",
ChartType::Bar => "Clustered Bar",
ChartType::BarStacked => "Stacked Bar",
ChartType::Line => "Line",
ChartType::Area => "Area",
ChartType::Pie => "Pie",
ChartType::Doughnut => "Doughnut",
ChartType::Scatter => "Scatter",
}
}
/// Pie and doughnut charts show one series, a slice per category.
pub fn is_round(self) -> bool {
matches!(self, ChartType::Pie | ChartType::Doughnut)
}
}
/// Where a chart's legend sits.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum LegendPos {
Right,
Left,
Top,
#[default]
Bottom,
}
impl LegendPos {
pub const ALL: [LegendPos; 4] = [LegendPos::Right, LegendPos::Top, LegendPos::Left, LegendPos::Bottom];
pub fn id(self) -> &'static str {
match self {
LegendPos::Right => "right",
LegendPos::Left => "left",
LegendPos::Top => "top",
LegendPos::Bottom => "bottom",
}
}
pub fn from_id(id: &str) -> Option<LegendPos> {
LegendPos::ALL.into_iter().find(|p| p.id().eq_ignore_ascii_case(id.trim()))
}
}
/// One series: its name and a value per category (`None` = no value, a gap).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChartSeries {
pub name: String,
#[serde(default)]
pub values: Vec<Option<f64>>,
}
/// A chart WordCraft can edit.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChartSpec {
#[serde(rename = "type")]
pub kind: ChartType,
/// Category labels (scatter charts: the x values).
#[serde(default)]
pub categories: Vec<String>,
#[serde(default)]
pub series: Vec<ChartSeries>,
#[serde(default)]
pub title: Option<String>,
/// `None`: no legend.
#[serde(default)]
pub legend: Option<LegendPos>,
/// Show each point's value next to it.
#[serde(default)]
pub data_labels: bool,
}
impl ChartSpec {
/// A new chart of type `kind` with WordCraft's sample data: four quarters and three series
/// (one for pie and doughnut charts, x values for scatter charts), legend at the bottom.
pub fn sample(kind: ChartType) -> ChartSpec {
let (categories, series): (Vec<&str>, Vec<(&str, [f64; 4])>) = match kind {
ChartType::Pie | ChartType::Doughnut => (vec!["North", "East", "South", "West"], vec![("Share", [38.0, 24.0, 21.0, 17.0])]),
ChartType::Scatter => (vec!["1", "2", "3", "4"], vec![("Series 1", [2.1, 3.4, 3.0, 4.6]), ("Series 2", [1.2, 1.9, 2.8, 3.1])]),
_ => (
vec!["Q1", "Q2", "Q3", "Q4"],
vec![("Series 1", [3.2, 4.1, 3.6, 5.0]), ("Series 2", [2.0, 2.7, 3.9, 3.3]), ("Series 3", [1.5, 2.2, 2.4, 3.8])],
),
};
ChartSpec {
kind,
categories: categories.into_iter().map(str::to_string).collect(),
series: series.into_iter().map(|(n, v)| ChartSeries { name: n.to_string(), values: v.into_iter().map(Some).collect() }).collect(),
title: None,
legend: Some(LegendPos::Bottom),
data_labels: false,
}
}
/// Number of categories: every series has as many values.
pub fn points(&self) -> usize {
self.categories.len()
}
/// Make the chart safe to store and draw: text cut to [`MAX_TEXT`] characters with control
/// characters turned into spaces and the ends trimmed (an empty title is none); at most
/// [`MAX_POINTS`] categories, [`MAX_SERIES`] series and [`MAX_CELLS`] cells; values that
/// aren't finite become gaps; every series as long as the categories (padded with gaps,
/// categories padded with empty labels to the longest series).
pub fn sanitize(&mut self) {
self.series.truncate(MAX_SERIES);
let longest = self.series.iter().map(|s| s.values.len()).max().unwrap_or(0);
let n = self.categories.len().max(longest).min(MAX_POINTS);
let keep = MAX_CELLS.checked_div(n).unwrap_or(MAX_SERIES).clamp(1, MAX_SERIES);
self.series.truncate(keep);
self.categories.truncate(n);
for c in &mut self.categories {
*c = clean_text(c);
}
self.categories.resize(n, String::new());
for s in &mut self.series {
s.name = clean_text(&s.name);
s.values.truncate(n);
for v in &mut s.values {
*v = v.filter(|x| x.is_finite());
}
s.values.resize(n, None);
}
self.title = self.title.as_deref().map(clean_text).filter(|t| !t.is_empty());
}
/// The chart after `sanitize`.
pub fn sanitized(mut self) -> ChartSpec {
self.sanitize();
self
}
}
/// `s` as chart text: control characters as spaces, ends trimmed, at most [`MAX_TEXT`] characters.
pub fn clean_text(s: &str) -> String {
let s: String = s.chars().take(MAX_TEXT * 4).map(|c| if c.is_control() { ' ' } else { c }).collect();
s.trim().chars().take(MAX_TEXT).collect::<String>().trim_end().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_caps_sizes_and_drops_non_finite_values() {
let mut c = ChartSpec {
categories: vec!["a".into(); MAX_POINTS + 10],
series: (0..MAX_SERIES + 5)
.map(|i| ChartSeries { name: format!("{}\n{}", "x".repeat(400), i), values: vec![Some(f64::NAN), Some(f64::INFINITY), Some(1.0)] })
.collect(),
title: Some(" ".into()),
..Default::default()
};
c.sanitize();
assert_eq!(c.points(), MAX_POINTS);
assert!(c.series.len() * c.points() <= MAX_CELLS);
assert!(!c.series.is_empty());
let s = &c.series[0];
assert_eq!(s.values.len(), MAX_POINTS);
assert_eq!(&s.values[..3], &[None, None, Some(1.0)]);
assert_eq!(s.name.chars().count(), MAX_TEXT);
assert!(!s.name.contains('\n'));
assert_eq!(c.title, None);
}
#[test]
fn short_categories_are_padded_to_the_longest_series() {
let c = ChartSpec {
categories: vec!["a".into()],
series: vec![ChartSeries { name: "s".into(), values: vec![Some(1.0), Some(2.0)] }],
..Default::default()
}
.sanitized();
assert_eq!(c.categories, vec!["a".to_string(), String::new()]);
assert_eq!(ChartType::from_id("BARSTACKED"), Some(ChartType::BarStacked));
assert_eq!(LegendPos::from_id("top"), Some(LegendPos::Top));
}
}
+7 -2
View File
@@ -1,11 +1,12 @@
//! Charts and SmartArt diagrams: drawings shown but not edited, kept as neutral items ready to draw.
//! Charts and SmartArt diagrams: drawings kept as neutral items ready to draw. Charts WordCraft can
//! edit also keep their [`crate::chart::ChartSpec`]; the rest are shown, not edited.
use serde::{Deserialize, Serialize};
use crate::para::ShapeKind;
use crate::props::Rgb;
/// A drawing shown but not edited: a chart or a SmartArt diagram, as items ready to draw.
/// A chart or a SmartArt diagram, as items ready to draw (and, for an editable chart, its model).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Graphic {
@@ -23,6 +24,10 @@ pub struct Graphic {
/// only these items). `None` for graphics made some other way.
#[serde(skip)]
pub source: Option<std::sync::Arc<Embedded>>,
/// A chart WordCraft can edit (one it made, or one read back that holds nothing more): saving
/// writes the chart part from it, and the items are drawn from it. See [`crate::chart`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chart: Option<std::sync::Arc<crate::chart::ChartSpec>>,
}
/// What an object read from a file needs to be written back as it was: its own markup in the
+1
View File
@@ -11,6 +11,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod bidi;
pub mod chart;
pub mod edit;
pub mod effects;
pub mod encoding;
+497
View File
@@ -0,0 +1,497 @@
//! Charts WordCraft makes ([`ChartSpec`]) as chart parts (ECMA-376 Part 1 §21.2) and back.
//!
//! [`chart_xml`] writes a `c:chartSpace` with the data as literals in the part itself
//! (`c:strLit`, `c:numLit`): no embedded workbook (`c:externalData`) and no external references.
//! [`chart_items`] draws a chart through the same code that draws chart parts read from files.
//! [`spec_of`] turns a chart part back into the model only when writing that model again gives
//! the same markup, so a chart with anything the model leaves out stays as it was read.
use wordcraft_doc::chart::{ChartSeries, ChartSpec, ChartType, LegendPos};
use wordcraft_doc::graphic::GraphicItem;
use wordcraft_doc::props::Rgb;
use crate::xml::{self, El, MAX_DEPTH, Node, W};
const NS_C: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart";
const NS_A: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
const NS_R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
/// Content type of a chart part.
pub(crate) const CHART_CT: &str = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml";
/// `a:graphicData` URI of a chart.
pub(crate) const CHART_URI: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart";
/// Axis ids (any pair of distinct numbers will do; Word keeps them).
const CAT_AX: &str = "500000001";
const VAL_AX: &str = "500000002";
/// The chart part for `spec`: a complete XML document. `spec` should be sanitized
/// ([`ChartSpec::sanitize`]); a value that isn't finite is written as a gap either way.
pub fn chart_xml(spec: &ChartSpec) -> String {
let mut w = W::new();
w.open("c:chartSpace", &[("xmlns:c", NS_C), ("xmlns:a", NS_A), ("xmlns:r", NS_R)]);
w.empty("c:roundedCorners", &[("val", "0")]);
w.open("c:chart", &[]);
if let Some(t) = &spec.title {
w.open("c:title", &[]);
w.open("c:tx", &[]);
w.open("c:rich", &[]);
w.empty("a:bodyPr", &[]);
w.empty("a:lstStyle", &[]);
w.open("a:p", &[]);
w.open("a:r", &[]);
w.leaf("a:t", &[], t);
w.close("a:r");
w.close("a:p");
w.close("c:rich");
w.close("c:tx");
w.empty("c:overlay", &[("val", "0")]);
w.close("c:title");
}
w.empty("c:autoTitleDeleted", &[("val", if spec.title.is_some() { "0" } else { "1" })]);
w.open("c:plotArea", &[]);
w.empty("c:layout", &[]);
plot(&mut w, spec);
w.close("c:plotArea");
if let Some(pos) = spec.legend {
w.open("c:legend", &[]);
let p = match pos {
LegendPos::Right => "r",
LegendPos::Left => "l",
LegendPos::Top => "t",
LegendPos::Bottom => "b",
};
w.empty("c:legendPos", &[("val", p)]);
w.empty("c:overlay", &[("val", "0")]);
w.close("c:legend");
}
w.empty("c:plotVisOnly", &[("val", "1")]);
w.empty("c:dispBlanksAs", &[("val", "gap")]);
w.close("c:chart");
// A white chart area without a border, in the document's own background colour.
w.open("c:spPr", &[]);
w.open("a:solidFill", &[]);
w.empty("a:schemeClr", &[("val", "bg1")]);
w.close("a:solidFill");
w.open("a:ln", &[]);
w.empty("a:noFill", &[]);
w.close("a:ln");
w.close("c:spPr");
w.close("c:chartSpace");
w.s
}
/// What `spec` draws as, `w` × `h` points, in the colours of `theme` (series take the accents in
/// turn). Empty when the chart has no series.
pub fn chart_items(spec: &ChartSpec, theme: &[Rgb], w: f32, h: f32) -> Vec<GraphicItem> {
match xml::parse(chart_xml(spec).as_bytes()) {
Ok(space) => crate::read::chart::chart_items(&space, theme, w, h),
Err(_) => Vec::new(),
}
}
/// The chart type element and the series in it, then the axes.
fn plot(w: &mut W, spec: &ChartSpec) {
let k = spec.kind;
let el = match k {
ChartType::Column | ChartType::ColumnStacked | ChartType::Bar | ChartType::BarStacked => "c:barChart",
ChartType::Line => "c:lineChart",
ChartType::Area => "c:areaChart",
ChartType::Pie => "c:pieChart",
ChartType::Doughnut => "c:doughnutChart",
ChartType::Scatter => "c:scatterChart",
};
let stacked = matches!(k, ChartType::ColumnStacked | ChartType::BarStacked);
let horizontal = matches!(k, ChartType::Bar | ChartType::BarStacked);
w.open(el, &[]);
match k {
ChartType::Column | ChartType::ColumnStacked | ChartType::Bar | ChartType::BarStacked => {
w.empty("c:barDir", &[("val", if horizontal { "bar" } else { "col" })]);
w.empty("c:grouping", &[("val", if stacked { "stacked" } else { "clustered" })]);
}
ChartType::Line | ChartType::Area => w.empty("c:grouping", &[("val", "standard")]),
ChartType::Scatter => w.empty("c:scatterStyle", &[("val", "lineMarker")]),
ChartType::Pie | ChartType::Doughnut => {}
}
w.empty("c:varyColors", &[("val", if k.is_round() { "1" } else { "0" })]);
// Pie and doughnut charts show the first series; the others are kept for other types.
for (i, s) in spec.series.iter().enumerate() {
series(w, spec, i, s);
}
if spec.data_labels {
w.open("c:dLbls", &[]);
for (name, on) in [
("c:showLegendKey", false),
("c:showVal", true),
("c:showCatName", false),
("c:showSerName", false),
("c:showPercent", false),
("c:showBubbleSize", false),
] {
w.empty(name, &[("val", if on { "1" } else { "0" })]);
}
w.close("c:dLbls");
}
match k {
ChartType::Column | ChartType::ColumnStacked | ChartType::Bar | ChartType::BarStacked => {
w.empty("c:gapWidth", &[("val", "150")]);
if stacked {
w.empty("c:overlap", &[("val", "100")]);
}
}
ChartType::Line => w.empty("c:marker", &[("val", "1")]),
ChartType::Pie => w.empty("c:firstSliceAng", &[("val", "0")]),
ChartType::Doughnut => {
w.empty("c:firstSliceAng", &[("val", "0")]);
w.empty("c:holeSize", &[("val", "50")]);
}
ChartType::Area | ChartType::Scatter => {}
}
if !k.is_round() {
w.empty("c:axId", &[("val", CAT_AX)]);
w.empty("c:axId", &[("val", VAL_AX)]);
}
w.close(el);
match k {
ChartType::Pie | ChartType::Doughnut => {}
ChartType::Scatter => {
value_axis(w, CAT_AX, VAL_AX, "b", false, "midCat");
value_axis(w, VAL_AX, CAT_AX, "l", true, "midCat");
}
_ => {
category_axis(w, if horizontal { "l" } else { "b" });
value_axis(w, VAL_AX, CAT_AX, if horizontal { "b" } else { "l" }, true, "between");
}
}
}
/// One `c:ser` (ECMA-376 element order: idx, order, tx, spPr, marker, cat/xVal, val/yVal, smooth).
fn series(w: &mut W, spec: &ChartSpec, i: usize, s: &ChartSeries) {
let k = spec.kind;
let i = i.to_string();
w.open("c:ser", &[]);
w.empty("c:idx", &[("val", &i)]);
w.empty("c:order", &[("val", &i)]);
if !s.name.is_empty() {
w.open("c:tx", &[]);
w.leaf("c:v", &[], &s.name);
w.close("c:tx");
}
match k {
// Markers only: the series has no line.
ChartType::Scatter => {
w.open("c:spPr", &[]);
w.open("a:ln", &[("w", "19050")]);
w.empty("a:noFill", &[]);
w.close("a:ln");
w.close("c:spPr");
}
ChartType::Column | ChartType::ColumnStacked | ChartType::Bar | ChartType::BarStacked => {
w.empty("c:invertIfNegative", &[("val", "0")]);
}
ChartType::Line => {
w.open("c:marker", &[]);
w.empty("c:symbol", &[("val", "none")]);
w.close("c:marker");
}
_ => {}
}
if k == ChartType::Scatter {
let xs: Option<Vec<Option<f64>>> = spec
.categories
.iter()
.map(|c| if c.is_empty() { Some(None) } else { c.parse::<f64>().ok().filter(|v| v.is_finite()).map(Some) })
.collect();
w.open("c:xVal", &[]);
match xs {
Some(xs) => num_lit(w, &xs),
None => str_lit(w, &spec.categories),
}
w.close("c:xVal");
w.open("c:yVal", &[]);
num_lit(w, &s.values);
w.close("c:yVal");
} else {
w.open("c:cat", &[]);
str_lit(w, &spec.categories);
w.close("c:cat");
w.open("c:val", &[]);
num_lit(w, &s.values);
w.close("c:val");
}
if matches!(k, ChartType::Line | ChartType::Scatter) {
w.empty("c:smooth", &[("val", "0")]);
}
w.close("c:ser");
}
fn str_lit(w: &mut W, items: &[String]) {
w.open("c:strLit", &[]);
w.empty("c:ptCount", &[("val", &items.len().to_string())]);
for (i, s) in items.iter().enumerate().filter(|(_, s)| !s.is_empty()) {
w.open("c:pt", &[("idx", &i.to_string())]);
w.leaf("c:v", &[], s);
w.close("c:pt");
}
w.close("c:strLit");
}
fn num_lit(w: &mut W, values: &[Option<f64>]) {
w.open("c:numLit", &[]);
w.leaf("c:formatCode", &[], "General");
w.empty("c:ptCount", &[("val", &values.len().to_string())]);
for (i, v) in values.iter().enumerate() {
if let Some(v) = v.filter(|v| v.is_finite()) {
w.open("c:pt", &[("idx", &i.to_string())]);
w.leaf("c:v", &[], &num(v));
w.close("c:pt");
}
}
w.close("c:numLit");
}
/// A value as the shortest text that reads back as the same number.
fn num(v: f64) -> String {
if v == 0.0 { "0".to_string() } else { format!("{v}") }
}
fn category_axis(w: &mut W, pos: &str) {
w.open("c:catAx", &[]);
w.empty("c:axId", &[("val", CAT_AX)]);
w.open("c:scaling", &[]);
w.empty("c:orientation", &[("val", "minMax")]);
w.close("c:scaling");
w.empty("c:delete", &[("val", "0")]);
w.empty("c:axPos", &[("val", pos)]);
w.empty("c:majorTickMark", &[("val", "none")]);
w.empty("c:minorTickMark", &[("val", "none")]);
w.empty("c:tickLblPos", &[("val", "nextTo")]);
w.empty("c:crossAx", &[("val", VAL_AX)]);
w.empty("c:crosses", &[("val", "autoZero")]);
w.empty("c:auto", &[("val", "1")]);
w.empty("c:lblAlgn", &[("val", "ctr")]);
w.empty("c:lblOffset", &[("val", "100")]);
w.empty("c:noMultiLvlLbl", &[("val", "0")]);
w.close("c:catAx");
}
/// A value axis: with gridlines and no axis line (`grid`), else with its line and no gridlines.
fn value_axis(w: &mut W, id: &str, cross: &str, pos: &str, grid: bool, between: &str) {
w.open("c:valAx", &[]);
w.empty("c:axId", &[("val", id)]);
w.open("c:scaling", &[]);
w.empty("c:orientation", &[("val", "minMax")]);
w.close("c:scaling");
w.empty("c:delete", &[("val", "0")]);
w.empty("c:axPos", &[("val", pos)]);
if grid {
w.empty("c:majorGridlines", &[]);
}
w.empty("c:majorTickMark", &[("val", "none")]);
w.empty("c:minorTickMark", &[("val", "none")]);
w.empty("c:tickLblPos", &[("val", "nextTo")]);
if grid {
w.open("c:spPr", &[]);
w.open("a:ln", &[]);
w.empty("a:noFill", &[]);
w.close("a:ln");
w.close("c:spPr");
}
w.empty("c:crossAx", &[("val", cross)]);
w.empty("c:crosses", &[("val", "autoZero")]);
w.empty("c:crossBetween", &[("val", between)]);
w.close("c:valAx");
}
/// The chart a chart part holds, when the part is exactly what [`chart_xml`] writes for it (so
/// editing and saving it loses nothing). `None` for any other chart.
pub(crate) fn spec_of(space: &El) -> Option<ChartSpec> {
let chart = space.child("c:chart")?;
let plot = chart.child("c:plotArea")?;
let ty = plot.els().find(|e| e.name.ends_with("Chart"))?;
let val = |e: &El, n: &str| e.child(n).and_then(|c| c.attr("val")).map(str::to_string);
let bar = |stacked_kind, clustered_kind| if val(ty, "c:grouping").as_deref() == Some("stacked") { stacked_kind } else { clustered_kind };
let kind = match ty.name.as_str() {
"c:barChart" if val(ty, "c:barDir").as_deref() == Some("bar") => bar(ChartType::BarStacked, ChartType::Bar),
"c:barChart" => bar(ChartType::ColumnStacked, ChartType::Column),
"c:lineChart" => ChartType::Line,
"c:areaChart" => ChartType::Area,
"c:pieChart" => ChartType::Pie,
"c:doughnutChart" => ChartType::Doughnut,
"c:scatterChart" => ChartType::Scatter,
_ => return None,
};
let scatter = kind == ChartType::Scatter;
let sers: Vec<&El> = ty.children("c:ser").take(wordcraft_doc::chart::MAX_SERIES + 1).collect();
// More than the model holds can't be the same chart: don't build it to find out.
let count = |s: &&El| s.find("c:ptCount").and_then(|c| c.attr("val")).and_then(|v| v.parse::<usize>().ok()).unwrap_or(0);
let longest = sers.iter().map(count).max().unwrap_or(0);
if sers.len() > wordcraft_doc::chart::MAX_SERIES
|| longest > wordcraft_doc::chart::MAX_POINTS
|| sers.len().saturating_mul(longest) > wordcraft_doc::chart::MAX_CELLS
{
return None;
}
let categories = sers.first().and_then(|s| s.child(if scatter { "c:xVal" } else { "c:cat" })).map(literal).unwrap_or_default();
let series = sers
.iter()
.map(|s| ChartSeries {
name: s.child("c:tx").and_then(|t| t.child("c:v")).map(|v| v.text()).unwrap_or_default(),
values: s
.child(if scatter { "c:yVal" } else { "c:val" })
.map(literal)
.unwrap_or_default()
.iter()
.map(|v| v.parse::<f64>().ok().filter(|v| v.is_finite()))
.collect(),
})
.collect();
let title = chart.child("c:title").and_then(|t| t.find("a:t")).map(|t| t.text());
let legend = chart.child("c:legend").map(|l| match val(l, "c:legendPos").as_deref() {
Some("l") => LegendPos::Left,
Some("t") => LegendPos::Top,
Some("b") => LegendPos::Bottom,
_ => LegendPos::Right,
});
let data_labels = ty.child("c:dLbls").and_then(|d| d.child("c:showVal")).and_then(|v| v.attr("val")) == Some("1");
let spec = ChartSpec { kind, categories, series, title, legend, data_labels }.sanitized();
// Editable only when nothing would be lost: writing the model again gives the same part.
let again = xml::parse(chart_xml(&spec).as_bytes()).ok()?;
same(space, &again, 0).then_some(spec)
}
/// The points of a `c:strLit` / `c:numLit` (or a cache) as texts by index, `ptCount` long.
fn literal(e: &El) -> Vec<String> {
let Some(lit) = e.els().find(|c| matches!(c.name.as_str(), "c:strLit" | "c:numLit")) else { return Vec::new() };
let n = lit
.child("c:ptCount")
.and_then(|c| c.attr("val"))
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(0)
.min(wordcraft_doc::chart::MAX_POINTS + 1);
let mut out = vec![String::new(); n];
for p in lit.children("c:pt") {
if let Some(slot) = p.attr("idx").and_then(|i| i.parse::<usize>().ok()).and_then(|i| out.get_mut(i)) {
*slot = p.child("c:v").map(|v| v.text()).unwrap_or_default();
}
}
out
}
/// Whether two elements are the same markup: names, attributes (in any order), child elements in
/// order and text (whitespace-only text between elements ignored).
fn same(a: &El, b: &El, depth: usize) -> bool {
if depth > MAX_DEPTH || a.name != b.name || a.attrs.len() != b.attrs.len() {
return false;
}
if !a.attrs.iter().all(|(k, v)| b.attr(k) == Some(v.as_str()) && b.attrs.iter().any(|(bk, _)| bk == k)) {
return false;
}
let text = |e: &El| -> String {
e.kids
.iter()
.filter_map(|n| match n {
Node::Text(t) if !t.trim().is_empty() => Some(t.as_str()),
_ => None,
})
.collect()
};
if text(a) != text(b) {
return false;
}
let (mut x, mut y) = (a.els(), b.els());
loop {
match (x.next(), y.next()) {
(None, None) => return true,
(Some(p), Some(q)) if same(p, q, depth + 1) => {}
_ => return false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use wordcraft_doc::THEME_COLORS;
fn parse(spec: &ChartSpec) -> El {
xml::parse(chart_xml(spec).as_bytes()).unwrap()
}
/// Children of `e` by name, in order.
fn names(e: &El) -> Vec<&str> {
e.els().map(|c| c.name.as_str()).collect()
}
#[test]
fn every_type_reads_back_as_itself() {
for kind in ChartType::ALL {
let mut spec = ChartSpec::sample(kind);
spec.title = Some("Sales & <costs>".into());
spec.data_labels = true;
spec.legend = Some(LegendPos::Right);
spec.series[0].values[1] = None;
spec.categories[2] = String::new();
let spec = spec.sanitized();
assert_eq!(spec_of(&parse(&spec)).as_ref(), Some(&spec), "{kind:?}");
assert!(!chart_items(&spec, &THEME_COLORS, 300.0, 200.0).is_empty(), "{kind:?} draws");
}
let plain = ChartSpec { legend: None, ..ChartSpec::sample(ChartType::Line) };
assert_eq!(spec_of(&parse(&plain)), Some(plain));
}
#[test]
fn a_chart_with_more_than_the_model_stays_read_only() {
let spec = ChartSpec::sample(ChartType::Column);
let x = chart_xml(&spec).replace("<c:roundedCorners val=\"0\"/>", "<c:roundedCorners val=\"0\"/><c:style val=\"2\"/>");
assert_eq!(spec_of(&xml::parse(x.as_bytes()).unwrap()), None);
let x = chart_xml(&spec).replace("<c:v>3.2</c:v>", "<c:v>3.20</c:v>");
assert_eq!(spec_of(&xml::parse(x.as_bytes()).unwrap()), None);
}
#[test]
fn elements_come_in_schema_order() {
let mut spec = ChartSpec::sample(ChartType::ColumnStacked);
spec.title = Some("T".into());
spec.data_labels = true;
let space = parse(&spec);
assert_eq!(names(&space), ["c:roundedCorners", "c:chart", "c:spPr"]);
let chart = space.child("c:chart").unwrap();
assert_eq!(names(chart), ["c:title", "c:autoTitleDeleted", "c:plotArea", "c:legend", "c:plotVisOnly", "c:dispBlanksAs"]);
let plot = chart.child("c:plotArea").unwrap();
assert_eq!(names(plot), ["c:layout", "c:barChart", "c:catAx", "c:valAx"]);
let bar = plot.child("c:barChart").unwrap();
assert_eq!(
names(bar),
["c:barDir", "c:grouping", "c:varyColors", "c:ser", "c:ser", "c:ser", "c:dLbls", "c:gapWidth", "c:overlap", "c:axId", "c:axId"]
);
assert_eq!(names(bar.child("c:ser").unwrap()), ["c:idx", "c:order", "c:tx", "c:invertIfNegative", "c:cat", "c:val"]);
let val_ax = plot.child("c:valAx").unwrap();
assert_eq!(
names(val_ax),
[
"c:axId",
"c:scaling",
"c:delete",
"c:axPos",
"c:majorGridlines",
"c:majorTickMark",
"c:minorTickMark",
"c:tickLblPos",
"c:spPr",
"c:crossAx",
"c:crosses",
"c:crossBetween"
]
);
let line = parse(&ChartSpec { data_labels: true, ..ChartSpec::sample(ChartType::Line) });
let lc = line.child("c:chart").unwrap().child("c:plotArea").unwrap().child("c:lineChart").unwrap();
assert_eq!(names(lc), ["c:grouping", "c:varyColors", "c:ser", "c:ser", "c:ser", "c:dLbls", "c:marker", "c:axId", "c:axId"]);
assert_eq!(names(lc.child("c:ser").unwrap()), ["c:idx", "c:order", "c:tx", "c:marker", "c:cat", "c:val", "c:smooth"]);
let sc = parse(&ChartSpec::sample(ChartType::Scatter));
let sc = sc.child("c:chart").unwrap().child("c:plotArea").unwrap().child("c:scatterChart").unwrap();
assert_eq!(names(sc.child("c:ser").unwrap()), ["c:idx", "c:order", "c:tx", "c:spPr", "c:xVal", "c:yVal", "c:smooth"]);
let dn = parse(&ChartSpec::sample(ChartType::Doughnut));
let dn = dn.child("c:chart").unwrap().child("c:plotArea").unwrap();
assert_eq!(names(dn), ["c:layout", "c:doughnutChart"]);
assert_eq!(names(dn.child("c:doughnutChart").unwrap()), ["c:varyColors", "c:ser", "c:firstSliceAng", "c:holeSize"]);
}
}
+2
View File
@@ -7,6 +7,7 @@
//! error or a best-effort document, never a panic.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod chart_spec;
mod crypt;
mod custom;
mod package;
@@ -15,6 +16,7 @@ mod units;
mod write;
mod xml;
pub use chart_spec::{chart_items, chart_xml};
pub use crypt::{DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, check_password, decrypt, encrypt, encrypt_with_spin_count, is_encrypted};
pub use read::read;
pub use write::{write, write_as};
+138 -11
View File
@@ -1,8 +1,8 @@
//! Charts (ECMA-376 Part 1 §21.2): the cached data of a `c:chartSpace` drawn as items in points.
//!
//! Bar and column (clustered, stacked or 100% stacked), line, area, pie, doughnut and scatter
//! charts are drawn from the first supported chart type of the plot area. Other chart types draw
//! nothing. Text widths are estimated ([`CHAR_W`] em a character), so label places are approximate.
//! charts are drawn from the first supported chart type of the plot area, with their values as
//! data labels where `c:dLbls` shows them (`c:showVal`). Other chart types draw nothing. Text widths are estimated ([`CHAR_W`] em a character), so label places are approximate.
use std::f64::consts::{FRAC_PI_2, TAU};
@@ -78,6 +78,15 @@ pub(crate) fn chart_items(space: &El, theme: &[Rgb], w: f32, h: f32) -> Vec<Grap
out
}
/// A data label to draw once the chart is: the value at (`x`, `y`), centred there (`inside`, in
/// white on the fill) or just beside it (above, or right of a horizontal bar's end).
struct DataLabel {
x: f32,
y: f32,
text: String,
inside: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Kind {
Bar,
@@ -145,6 +154,11 @@ struct Series {
fmt: Option<String>,
/// Format code of the scatter x values' cache.
x_fmt: Option<String>,
/// Data labels on or off for this series (`c:dLbls` of its own), when it says.
labels: Option<bool>,
/// The values as the file has them, for data labels (`values` become shares in a 100%
/// stacked chart); empty when the series shows no labels.
raw: Vec<Option<f64>>,
}
struct Chart {
@@ -206,9 +220,14 @@ impl Chart {
};
let mut cats = cats;
cats.resize(n, String::new());
let type_labels = show_values(ty.child("c:dLbls"));
for s in &mut series {
s.values.resize(n, None);
s.xs.resize(n, None);
s.labels = Some(s.labels.or(type_labels).unwrap_or(false));
if s.labels == Some(true) {
s.raw = s.values.clone();
}
}
if grouping == Grouping::Percent {
to_percent(&mut series, n);
@@ -329,6 +348,15 @@ fn title_of(chart: &El, series: &[Series]) -> Option<String> {
chart.child("c:title").and_then(|t| title_text(t).or(auto))
}
/// Whether a `c:dLbls` shows the values (`None` when there's none, or it doesn't say).
fn show_values(d: Option<&El>) -> Option<bool> {
let d = d?;
if d.child("c:delete").and_then(|e| e.attr("val")).is_some_and(truthy) {
return Some(false);
}
d.child("c:showVal").map(|v| v.attr("val").is_none_or(truthy))
}
fn legend_pos(chart: &El) -> Option<Pos> {
chart.child("c:legend").map(|l| match l.child("c:legendPos").and_then(|p| p.attr("val")).unwrap_or("r") {
"l" => Pos::Left,
@@ -370,7 +398,8 @@ impl Series {
*slot = c;
}
}
Series { name, color, line_w, line, values, xs, points, fmt, x_fmt }
let labels = show_values(ser.child("c:dLbls"));
Series { name, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
}
}
@@ -1240,14 +1269,16 @@ fn cartesian(out: &mut Vec<GraphicItem>, c: &Chart, area: Rect) {
if c.val.grid {
draw_grid(out, &g, &ax.vticks);
}
let mut labels = Vec::new();
match c.kind {
Kind::Bar => bars(out, c, &g),
Kind::Line => lines(out, c, &g),
Kind::Area => areas(out, c, &g),
Kind::Scatter => draw_scatter(out, c, &g),
Kind::Bar => bars(out, c, &g, &mut labels),
Kind::Line => lines(out, c, &g, &mut labels),
Kind::Area => areas(out, c, &g, &mut labels),
Kind::Scatter => draw_scatter(out, c, &g, &mut labels),
Kind::Pie | Kind::Doughnut => {}
}
draw_axes(out, c, &g);
draw_data_labels(out, &labels, c.text_size, g.horiz && c.kind == Kind::Bar);
if !c.val.deleted {
draw_value_labels(out, c, &g, &ax.vticks, Rect { w: m.left, ..area });
}
@@ -1367,7 +1398,7 @@ fn x_extent(c: &Chart) -> (f64, f64) {
if lo.is_finite() && hi.is_finite() { (lo, hi) } else { (0.0, 1.0) }
}
fn bars(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame) {
fn bars(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame, labels: &mut Vec<DataLabel>) {
let stacked = c.stacked();
let slots = if stacked { 1 } else { c.series.len().max(1) };
let ov = if stacked { 0.0 } else { c.overlap };
@@ -1400,6 +1431,17 @@ fn bars(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame) {
let (p0, p1) = (p0.min(p1), p0.max(p1));
let segs = if g.horiz { rect_segs(p0, a, p1, a + thick) } else { rect_segs(a, p0, a + thick, p1) };
fill(out, segs, c.point_colour(j, i));
if let Some(text) = label_of(s, i) {
let mid = a + thick / 2.0;
let (x, y) = match (g.horiz, stacked) {
(true, true) => ((p0 + p1) / 2.0, mid),
(false, true) => (mid, (p0 + p1) / 2.0),
// Beyond the bar's end: right of a positive bar, above a positive column.
(true, false) => (if v >= 0.0 { p1 } else { p0 }, mid),
(false, false) => (mid, if v >= 0.0 { p0 } else { p1 }),
};
labels.push(DataLabel { x, y, text, inside: stacked });
}
}
}
}
@@ -1410,7 +1452,7 @@ fn has_values(s: &Series) -> bool {
}
/// Line series; stacked ones are drawn at the running total of the series up to them.
fn lines(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame) {
fn lines(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame, labels: &mut Vec<DataLabel>) {
let mut total = vec![0.0f64; g.n];
for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) {
let pts: Vec<Option<(f32, f32)>> = (0..g.n)
@@ -1426,11 +1468,16 @@ fn lines(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame) {
Some((g.cat_x(i), g.val(y)))
})
.collect();
for (i, p) in pts.iter().enumerate() {
if let (Some((x, y)), Some(text)) = (p, label_of(s, i)) {
labels.push(DataLabel { x: *x, y: *y, text, inside: false });
}
}
stroke(out, runs(pts.into_iter()), c.series_colour(j), s.line_w);
}
}
fn areas(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame) {
fn areas(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame, labels: &mut Vec<DataLabel>) {
let mut base = vec![0.0f64; g.n];
for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) {
let vals = (0..g.n).map(|i| s.values.get(i).copied().flatten().unwrap_or(0.0));
@@ -1445,6 +1492,11 @@ fn areas(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame) {
}
segs.push(PathSeg::Close);
fill(out, segs, c.series_colour(j));
for (i, (t, b)) in top.iter().zip(&base).enumerate() {
if let Some(text) = label_of(s, i) {
labels.push(DataLabel { x: g.cat_x(i), y: g.val((t + b) / 2.0), text, inside: true });
}
}
if c.stacked() {
base = top;
}
@@ -1452,7 +1504,7 @@ fn areas(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame) {
}
/// Scatter series as lines, or as small markers when the series has no line.
fn draw_scatter(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame) {
fn draw_scatter(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame, labels: &mut Vec<DataLabel>) {
const MARKER: f32 = 6.0;
for (j, s) in c.series.iter().enumerate() {
let pts: Vec<Option<(f32, f32)>> =
@@ -1463,6 +1515,11 @@ fn draw_scatter(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame) {
_ => None,
})
.collect();
for (i, p) in pts.iter().enumerate() {
if let (Some((x, y)), Some(text)) = (p, label_of(s, i)) {
labels.push(DataLabel { x: *x, y: *y - MARKER / 2.0, text, inside: false });
}
}
if s.line {
stroke(out, runs(pts.iter().copied()), c.series_colour(j), s.line_w);
continue;
@@ -1485,6 +1542,7 @@ fn pie(out: &mut Vec<GraphicItem>, c: &Chart, area: Rect) {
let (cx, cy) = ((area.x + area.w / 2.0) as f64, (area.y + area.h / 2.0) as f64);
let inner = if c.kind == Kind::Doughnut { r * c.hole as f64 } else { 0.0 };
let mut a = c.first_angle.to_radians();
let mut labels = Vec::new();
for (i, v) in vals.iter().enumerate() {
if *v <= 0.0 {
continue;
@@ -1503,8 +1561,57 @@ fn pie(out: &mut Vec<GraphicItem>, c: &Chart, area: Rect) {
}
segs.push(PathSeg::Close);
fill(out, segs, c.point_colour(0, i));
if let Some(text) = label_of(s, i) {
// Halfway across the ring (pie: two thirds out from the centre).
let at = if inner > 0.0 { (r + inner) / 2.0 } else { r * 0.65 };
let (x, y) = polar(cx, cy, at, (a + b) / 2.0);
labels.push(DataLabel { x: x as f32, y: y as f32, text, inside: true });
}
a = b;
}
draw_data_labels(out, &labels, c.text_size, false);
}
/// The data label of point `i` of series `s`, when it shows one: the value as the file has it,
/// in the series' number format.
fn label_of(s: &Series, i: usize) -> Option<String> {
if s.labels != Some(true) {
return None;
}
let v = s.raw.get(i).copied().flatten()?;
let fmt = s.fmt.as_deref().filter(|f| !f.eq_ignore_ascii_case("General"));
Some(match fmt {
Some(f) => format_value(v, f, 1.0),
None => general_value(v),
})
}
/// A value in the `General` format: whole numbers without decimals, others with up to six.
fn general_value(v: f64) -> String {
if v.fract() == 0.0 && v.abs() < 1e15 {
return format!("{v:.0}");
}
let s = format!("{v:.6}");
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" { "0".to_string() } else { s.to_string() }
}
/// Data labels over the drawn chart. Beside labels sit above their point, or right of it for
/// horizontal bars (`beside_right`).
fn draw_data_labels(out: &mut Vec<GraphicItem>, labels: &[DataLabel], fs: f32, beside_right: bool) {
let lh = fs * LINE;
for l in labels {
let w = text_w(&l.text, fs) + 4.0;
let (rect, align) = if l.inside {
(Rect { x: l.x - w / 2.0, y: l.y - lh / 2.0, w, h: lh }, TextAlign::Center)
} else if beside_right {
(Rect { x: l.x + GAP / 2.0, y: l.y - lh / 2.0, w, h: lh }, TextAlign::Left)
} else {
(Rect { x: l.x - w / 2.0, y: l.y - lh, w, h: lh }, TextAlign::Center)
};
let color = if l.inside { Rgb::WHITE } else { TEXT };
out.push(GraphicItem::Text { rect: rect.arr(), text: l.text.clone(), size: fs, color, bold: false, align, font: None });
}
}
/// A point at clockwise angle `t` (radians from 12 o'clock) on a circle of radius `r`.
@@ -1607,6 +1714,26 @@ mod tests {
assert!(items.iter().any(|it| matches!(it, GraphicItem::Text { text, .. } if text == "Q2")));
}
#[test]
fn data_labels_show_the_values() {
let texts = |items: &[GraphicItem]| -> Vec<String> {
items.iter().filter_map(|it| if let GraphicItem::Text { text, .. } = it { Some(text.clone()) } else { None }).collect()
};
let labels = r#"<c:dLbls><c:showLegendKey val="0"/><c:showVal val="1"/></c:dLbls>"#;
let plot = format!(
r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/>{}{labels}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/></c:valAx>"#,
ser(0, "North", None, &["Q1", "Q2"], &["4.25", "17"]),
);
let shown = texts(&chart_items(&space(&plot), &theme(), 300.0, 200.0));
assert!(shown.contains(&"4.25".to_string()) && shown.contains(&"17".to_string()), "{shown:?}");
let pie = format!(r#"<c:pieChart><c:varyColors val="1"/>{}{labels}</c:pieChart>"#, ser(0, "S", None, &["A", "B"], &["1", "3"]));
let shown = texts(&chart_items(&space(&pie), &theme(), 200.0, 150.0));
assert!(shown.contains(&"3".to_string()), "{shown:?}");
// Without `c:dLbls`, no values are written next to the points.
let plain = plot.replace(labels, "");
assert!(!texts(&chart_items(&space(&plain), &theme(), 300.0, 200.0)).contains(&"4.25".to_string()));
}
#[test]
fn pie_draws_one_slice_per_point() {
let plot =
+1 -1
View File
@@ -1,6 +1,6 @@
//! DOCX → [`Document`].
mod chart;
pub(crate) mod chart;
mod diagram;
mod drawing_color;
mod embed;
+9 -2
View File
@@ -848,14 +848,21 @@ impl Reader<'_> {
GraphicKind::Chart => gd.child("c:chart").and_then(|c| c.attr("r:id")).and_then(|id| super::part_of(rels, id, rt::CHART)),
GraphicKind::Diagram => self.diagram_part(gd, rels),
};
let Some(path) = part else { return Arc::new(Graphic { kind, items: Vec::new(), w, h, source }) };
let Some(path) = part else { return Arc::new(Graphic { kind, items: Vec::new(), w, h, source, chart: None }) };
let key = (kind, path, w.to_bits(), h.to_bits());
if let Some(g) = self.graphics.get(&key) {
return g.clone();
}
let items = if self.graphic_budget == 0 { Vec::new() } else { self.graphic_items(kind, &key.1, w, h) };
self.graphic_budget = self.graphic_budget.saturating_sub(graphic_work(&items));
let g = Arc::new(Graphic { kind, items, w, h, source });
// A chart that is exactly what WordCraft writes for its model can be edited (and is
// written from the model); any other stays as it was read.
let chart = match kind {
GraphicKind::Chart if !items.is_empty() => self.graphic_part(&key.1).and_then(|space| crate::chart_spec::spec_of(&space)).map(Arc::new),
_ => None,
};
let source = if chart.is_some() { None } else { source };
let g = Arc::new(Graphic { kind, items, w, h, source, chart });
self.graphics.insert(key, g.clone());
g
}
+36
View File
@@ -121,6 +121,19 @@ impl<'d> EmbedWriter<'d> {
}
}
/// A free part name `{stem}{n}{ext}` for a new part, from n = 1 up.
fn fresh(&mut self, stem: &str, ext: &str) -> String {
let mut n: u32 = 1;
loop {
let name = format!("{stem}{n}{ext}");
let lower = name.to_ascii_lowercase();
if !Self::reserved(&lower) && self.taken.insert(lower) {
return name;
}
n = n.saturating_add(1);
}
}
/// Can part `name` be written (bytes and manifest entry both kept)?
fn available(&self, name: &str) -> bool {
self.manifest.parts.contains_key(name) && self.passthrough.contains_key(name)
@@ -249,6 +262,29 @@ impl super::Writer<'_> {
Some(root.to_xml())
}
/// The `a:graphic` of a chart written from its model: a new chart part (`word/charts/chartN.xml`)
/// related from the story part through `rels`.
pub(super) fn chart_graphic(&mut self, spec: &wordcraft_doc::chart::ChartSpec, rels: &mut PartRels) -> String {
let name = self.embeds.fresh("word/charts/chart", ".xml");
let bytes = crate::chart_spec::chart_xml(spec).into_bytes();
self.embeds.parts.push((name.clone(), bytes, crate::chart_spec::CHART_CT.to_string(), PartRels::default()));
let id = rels.add(crate::package::rt::CHART, &relative(STORY_PART, &name), false);
let mut w = xml::W::default();
w.open("a:graphic", &[("xmlns:a", "http://schemas.openxmlformats.org/drawingml/2006/main")]);
w.open("a:graphicData", &[("uri", crate::chart_spec::CHART_URI)]);
w.empty(
"c:chart",
&[
("xmlns:c", "http://schemas.openxmlformats.org/drawingml/2006/chart"),
("xmlns:r", "http://schemas.openxmlformats.org/officeDocument/2006/relationships"),
("r:id", &id),
],
);
w.close("a:graphicData");
w.close("a:graphic");
w.s
}
/// Write an OLE object's size (and position, when floating), rotation and flips into its VML
/// shape and DrawingML picture, with the effect extent covering the rotated bounds as for
/// other drawings, its alt text into both, and give its drawing a fresh `wp:docPr` id.
+11 -3
View File
@@ -476,10 +476,18 @@ impl Writer<'_> {
self.rev_close(w, props);
}
// A chart or diagram read from a file: its frame from the object's size and position,
// the graphic inside as read. Ones made some other way have nothing to write.
// the graphic inside as read. Ones made some other way have nothing to write, except
// charts with a model (made or edited in WordCraft).
InlineObject::Graphic { w: gw, h: gh, alt, float, graphic } => {
let Some(src) = graphic.source.as_deref() else { return };
let Some(inner) = self.embedded_xml(src, rels, None) else { return };
// A chart WordCraft can edit: a chart part written from its model.
let inner = match (graphic.chart.as_deref(), graphic.source.as_deref()) {
(Some(spec), _) => self.chart_graphic(spec, rels),
(None, Some(src)) => {
let Some(inner) = self.embedded_xml(src, rels, None) else { return };
inner
}
(None, None) => return,
};
self.rev_open(w, props);
w.open("w:r", &[]);
self.rpr(w, props);
+5
View File
@@ -407,6 +407,11 @@ Shape Format|Accessibility|Alt Text|object.altText
Picture Format|Size|Crop|picture.crop
Picture Format|Size|Size|picture.size
Picture Format|Size|Rotation|arrange.rotation
Chart Design|Type|Change Chart Type|chart.type
Chart Design|Data|Edit Data|chart.editData
Chart Design|Chart Layouts|Chart Title|chart.title
Chart Design|Chart Layouts|Legend|chart.legend
Chart Design|Chart Layouts|Data Labels|chart.dataLabels
Shape Format|Shape Styles|Shape Fill|shape.fill
Shape Format|Shape Styles|Shape Outline|shape.outline
Shape Format|Shape Styles|Shape Effects|shape.effects
+245
View File
@@ -0,0 +1,245 @@
//! 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)) }
}
/// `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.ui_requests.push(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), if it has a model.
pub fn redraw(o: &mut InlineObject, theme: &[Rgb]) {
if let InlineObject::Graphic { w, h, graphic, .. } = o
&& let Some(spec) = graphic.chart.as_deref()
{
*graphic = Arc::new(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())
}
+2
View File
@@ -3,6 +3,7 @@
pub mod accessibility;
pub mod canvas;
pub mod caret;
pub mod chart;
pub mod citations;
pub mod column;
pub mod design;
@@ -70,6 +71,7 @@ pub fn registry() -> Registry {
v.extend(citations::specs());
v.extend(index::specs());
v.extend(objects::specs());
v.extend(chart::specs());
v.extend(draw::specs());
v.extend(canvas::specs());
v.extend(tools::specs());
+15 -3
View File
@@ -31,6 +31,13 @@ fn has_floating(s: &Session) -> Option<&'static str> {
/// Charts and diagrams are not written back on save yet, so their size, position and wrapping stay
/// as imported: they can be selected and deleted, not moved, resized or arranged.
const FROZEN: &str = "charts and diagrams can't be moved or resized yet";
/// `has_movable` for Size: charts WordCraft can edit are drawn again at their new size.
fn has_sizable(s: &Session) -> Option<&'static str> {
match selected(s) {
Some((_, InlineObject::Graphic { graphic, .. })) if graphic.chart.is_some() => None,
_ => has_movable(s),
}
}
/// `has_object` for the geometry commands: a chart or diagram is frozen.
fn has_movable(s: &Session) -> Option<&'static str> {
match selected(s) {
@@ -64,7 +71,7 @@ fn has_shape(s: &Session) -> Option<&'static str> {
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("picture.size", "Size", "Picture Format › Size", size).params(r#"{"width"?: pt, "height"?: pt, "lockAspect"?: bool, "scale"?: percent}"#).when(has_movable),
CommandSpec::new("picture.size", "Size", "Picture Format › Size", size).params(r#"{"width"?: pt, "height"?: pt, "lockAspect"?: bool, "scale"?: percent}"#).when(has_sizable),
CommandSpec::new("picture.crop", "Crop", "Picture Format › Size", |s, v| {
let c = [p::f32(v, "left"), p::f32(v, "top"), p::f32(v, "right"), p::f32(v, "bottom")].map(|x| x.unwrap_or(0.0).clamp(0.0, 0.45));
with_obj(s, |o| {
@@ -877,8 +884,13 @@ fn size(s: &mut Session, v: &Value) -> CmdResult {
_ => {}
}
let (w, h) = (w.clamp(1.0, 4000.0), h.clamp(1.0, 4000.0));
// Any drawing: a group's members scale with its frame (its child space is kept).
with_obj(s, |o| o.set_size(w, h))
// Any drawing: a group's members scale with its frame (its child space is kept);
// a chart is redrawn at its new size.
let theme = s.doc.settings.theme_colors.clone();
with_obj(s, |o| {
o.set_size(w, h);
super::chart::redraw(o, &theme);
})
}
/// Apply an image operation to the selected picture's bitmap (stored as a new PNG).
+1 -1
View File
@@ -17,7 +17,7 @@ use crate::Registry;
/// isn't part of the customizable list.
pub const TABS: [&str; 12] = ["File", "Home", "Insert", "Draw", "Design", "Layout", "References", "Mailings", "Review", "View", "Zotero", "Help"];
/// Tabs that come up with the selection; custom tabs can't take their names.
pub const CONTEXTUAL_TABS: [&str; 5] = ["Table Design", "Table Layout", "Picture Format", "Shape Format", "Equation"];
pub const CONTEXTUAL_TABS: [&str; 6] = ["Table Design", "Table Layout", "Picture Format", "Shape Format", "Equation", "Chart Design"];
/// The Quick Access Toolbar's commands until the user changes them.
pub const DEFAULT_QAT: [&str; 3] = ["file.save", "edit.undo", "edit.redo"];
+101
View File
@@ -2453,6 +2453,107 @@ fn charts_and_diagrams_can_be_selected_and_deleted_but_not_moved() {
assert!(s.doc.para_at(&Pos::body(0, 0)).is_some_and(|p| p.objects.is_empty()), "the chart is deleted");
}
/// The chart model of the first object in the body's first paragraph.
fn first_chart(doc: &wordcraft_doc::Document) -> Option<wordcraft_doc::chart::ChartSpec> {
use wordcraft_doc::para::InlineObject;
let p = doc.para_at(&Pos::body(0, 0))?;
p.object_offsets().into_iter().find_map(|off| match p.object_at(off) {
Some(InlineObject::Graphic { graphic, .. }) => graphic.chart.as_deref().cloned(),
_ => None,
})
}
#[test]
fn inserted_chart_is_saved_and_reopened_with_its_data() {
let mut s = s();
let data =
json!({"categories": ["Jan", "Feb", "Mar"], "series": [{"name": "Rain", "values": [12.5, null, 3]}, {"name": "Sun", "values": [4, 5, 6]}]});
let r = run(&mut s, "insert.chart", json!({"type": "line", "data": data, "title": "Weather", "legend": "right"}));
assert_eq!(r["chart"]["type"], "line");
let spec = first_chart(&s.doc).expect("a chart with a model");
assert_eq!(spec.series[0].values, vec![Some(12.5), None, Some(3.0)]);
assert!(crate::cmd::chart::selected_chart(&s).is_some(), "the new chart is selected");
let bytes = wordcraft_docx::write(&s.doc).unwrap();
assert!(bytes.windows(22).any(|w| w == b"word/charts/chart1.xml"), "a chart part is written");
let back = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(first_chart(&back), Some(spec), "the chart reads back as the same editable chart");
// Sample data when none is given.
let mut s2 = self::s();
run(&mut s2, "insert.chart", json!({"type": "pie"}));
assert!(first_chart(&s2.doc).is_some_and(|c| c.kind == wordcraft_doc::chart::ChartType::Pie && !c.series.is_empty()));
// No params: the picker, nothing inserted.
let mut s3 = self::s();
run(&mut s3, "insert.chart", json!({}));
assert!(s3.ui_requests.iter().any(|r| r["open"] == "insertChart"));
assert!(first_chart(&s3.doc).is_none());
}
#[test]
fn chart_edits_are_one_undo_step_each() {
use wordcraft_doc::chart::{ChartType, LegendPos};
let mut s = s();
run(&mut s, "insert.chart", json!({"type": "column"}));
let before = first_chart(&s.doc).unwrap();
run(&mut s, "chart.editData", json!({"data": {"categories": ["A", "B"], "series": [{"name": "X", "values": [1, 2]}]}}));
run(&mut s, "chart.type", json!({"type": "barStacked"}));
run(&mut s, "chart.title", json!({"text": "Totals"}));
run(&mut s, "chart.legend", json!({"pos": "none"}));
run(&mut s, "chart.dataLabels", json!({"on": true}));
let c = first_chart(&s.doc).unwrap();
assert_eq!((c.kind, c.title.as_deref(), c.legend, c.data_labels), (ChartType::BarStacked, Some("Totals"), None, true));
assert_eq!((c.categories.len(), c.series.len()), (2, 1));
let steps: [&dyn Fn(&wordcraft_doc::chart::ChartSpec) -> bool; 5] = [
&|c| !c.data_labels,
&|c| c.legend == Some(LegendPos::Bottom),
&|c| c.title.is_none(),
&|c| c.kind == ChartType::Column && c.series.len() == 1,
&|c| *c == before,
];
for (k, check) in steps.iter().enumerate() {
run(&mut s, "edit.undo", json!({}));
assert!(check(&first_chart(&s.doc).unwrap()), "undo step {k}");
}
// Resizing draws the chart again at its new size.
run(&mut s, "edit.redo", json!({}));
run(&mut s, "picture.size", json!({"width": 300, "height": 150, "lockAspect": false}));
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let Some(wordcraft_doc::para::InlineObject::Graphic { graphic, w, .. }) = p.object_offsets().into_iter().find_map(|o| p.object_at(o)) else {
panic!()
};
assert_eq!((*w, graphic.w, graphic.h), (300.0, 300.0, 150.0));
}
#[test]
fn hostile_chart_data_is_capped() {
use wordcraft_doc::chart::{MAX_CELLS, MAX_POINTS, MAX_SERIES, MAX_TEXT};
let mut s = s();
let values: Vec<serde_json::Value> = (0..5000).map(|i| if i % 2 == 0 { json!("NaN") } else { json!(1e308) }).collect();
let series: Vec<serde_json::Value> = (0..300).map(|i| json!({"name": "n".repeat(10_000), "values": values, "x": i})).collect();
let cats: Vec<serde_json::Value> = (0..6000).map(|i| json!(i)).collect();
run(
&mut s,
"insert.chart",
json!({"type": "scatter", "data": {"categories": cats, "series": series}, "title": "\u{0}".repeat(9999), "width": 1e30}),
);
let c = first_chart(&s.doc).unwrap();
assert!(c.series.len() <= MAX_SERIES && c.points() <= MAX_POINTS && c.series.len() * c.points() <= MAX_CELLS);
assert!(c.series.iter().all(|x| x.name.chars().count() <= MAX_TEXT && x.values.len() == c.points()));
assert!(c.series[0].values.iter().all(|v| v.is_none_or(f64::is_finite)));
let _ = s.layout();
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
assert_eq!(first_chart(&back), Some(c));
for bad in [json!({"type": "radar"}), json!({"data": {"series": 5}}), json!({"data": {"series": []}})] {
assert!(s.run("insert.chart", &bad).is_err(), "{bad}");
}
// A chart without a model (from another program) is shown, not edited.
let mut s = self::s();
let foreign =
wordcraft_doc::para::InlineObject::Graphic { w: 200.0, h: 100.0, alt: String::new(), float: Default::default(), graphic: Default::default() };
s.doc.insert_object(&Pos::body(0, 0), foreign, &Default::default()).unwrap();
run(&mut s, "select.range", json!({"anchor": Pos::body(0, 0), "focus": Pos::body(0, wordcraft_doc::para::OBJ.len_utf8())}));
assert!(s.run("chart.type", &json!({"type": "pie"})).is_err());
}
#[test]
fn accessibility_reports_a_chart_without_alt_text() {
use std::sync::Arc;
+1 -1
View File
@@ -2625,7 +2625,7 @@ fn oversized_graphic_draws_shrunk_with_its_items() {
stroke: Some(Rgb::BLACK),
stroke_width: 4.0,
}];
let graphic = Graphic { kind: GraphicKind::Chart, items, w: 1000.0, h: 500.0, source: None };
let graphic = Graphic { kind: GraphicKind::Chart, items, w: 1000.0, h: 500.0, source: None, chart: None };
let obj = InlineObject::Graphic { w: 1000.0, h: 500.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) };
let mut d = Document::from_text("Chart");
d.insert_object(&Pos::body(0, 0), obj, &Default::default()).unwrap();
+359
View File
@@ -0,0 +1,359 @@
//! Insert Chart / Change Chart Type (a gallery of chart types with previews drawn in code) and
//! Edit Data (the chart's categories and series in an editable grid). Each ends by running one
//! command (`insert.chart`, `chart.type`, `chart.editData`), so agents get the same result
//! without the dialog. Shown through [`crate::dialogs_insert::InsertDialog`].
use egui::{Color32, Pos2, Rect, Sense, Shape, Stroke, Ui, pos2, vec2};
use serde::Serialize;
use serde_json::{Value, json};
use wordcraft_doc::chart::{ChartSpec, ChartType, MAX_POINTS, MAX_SERIES};
use crate::WordApp;
use crate::theme::{Tokens, c32};
/// Insert › Chart (`change` false) or Chart Design › Change Chart Type (`change` true).
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ChartTypeForm {
#[serde(rename = "type")]
pub kind: ChartType,
pub change: bool,
}
impl ChartTypeForm {
pub fn new(app: &WordApp, change: bool) -> Option<ChartTypeForm> {
let current = wordcraft_engine::cmd::chart::selected_chart(&app.session);
if change && current.is_none() {
return None;
}
Some(ChartTypeForm { kind: current.map(|c| c.kind).unwrap_or_default(), change })
}
}
/// Chart Design › Edit Data: the grid as text, a row per category and a column per series.
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ChartDataForm {
pub categories: Vec<String>,
pub names: Vec<String>,
/// `cells[row][series]`.
pub cells: Vec<Vec<String>>,
pub message: String,
}
impl ChartDataForm {
pub fn new(app: &WordApp) -> Option<ChartDataForm> {
let c = wordcraft_engine::cmd::chart::selected_chart(&app.session)?;
Some(Self::of(&c))
}
pub fn of(c: &ChartSpec) -> ChartDataForm {
let cells = (0..c.points())
.map(|i| c.series.iter().map(|s| s.values.get(i).copied().flatten().map(|v| format!("{v}")).unwrap_or_default()).collect())
.collect();
ChartDataForm { categories: c.categories.clone(), names: c.series.iter().map(|s| s.name.clone()).collect(), cells, message: String::new() }
}
/// The `chart.editData` params, or the first cell that isn't a number.
pub fn params(&self) -> Result<Value, String> {
let mut series = Vec::new();
for (j, name) in self.names.iter().enumerate() {
let mut values = Vec::new();
for (i, row) in self.cells.iter().enumerate() {
let t = row.get(j).map(|t| t.trim()).unwrap_or("");
if t.is_empty() {
values.push(Value::Null);
continue;
}
// A decimal comma is read as a point.
match t.parse::<f64>().or_else(|_| t.replace(',', ".").parse::<f64>()).ok().filter(|v| v.is_finite()) {
Some(v) => values.push(json!(v)),
None => {
let at = self.categories.get(i).filter(|c| !c.is_empty()).cloned().unwrap_or_else(|| (i + 1).to_string());
return Err(crate::i18n::fmt(
tl!("“{text}” ({row}, {series}) isn't a number."),
&[("text", t), ("row", &at), ("series", name)],
));
}
}
}
series.push(json!({"name": name, "values": values}));
}
Ok(json!({"data": {"categories": self.categories, "series": series}}))
}
fn add_row(&mut self) {
if self.categories.len() < MAX_POINTS {
self.categories.push(crate::i18n::fmt(tl!("Category {n}"), &[("n", &(self.categories.len() + 1).to_string())]));
self.cells.push(vec![String::new(); self.names.len()]);
}
}
fn add_series(&mut self) {
if self.names.len() < MAX_SERIES {
self.names.push(crate::i18n::fmt(tl!("Series {n}"), &[("n", &(self.names.len() + 1).to_string())]));
for r in &mut self.cells {
r.push(String::new());
}
}
}
}
/// The theme's first accents, for previews.
fn accents(app: &WordApp) -> [Color32; 3] {
let t = &app.session.doc.settings.theme_colors;
let at = |i: usize, d: Color32| t.get(i).map(|c| c32(*c)).unwrap_or(d);
[at(4, Color32::from_rgb(0x15, 0x60, 0x82)), at(5, Color32::from_rgb(0xE9, 0x71, 0x32)), at(6, Color32::from_rgb(0x19, 0x6B, 0x24))]
}
/// A small picture of a chart type in `r`, in the accents `col` with axis lines in `line`.
pub fn preview(p: &egui::Painter, r: Rect, kind: ChartType, col: [Color32; 3], line: Color32) {
let r = r.shrink(r.width().min(r.height()) * 0.12);
let at = |x: f32, y: f32| pos2(r.left() + x * r.width(), r.top() + y * r.height());
let bar = |x0: f32, y0: f32, x1: f32, y1: f32, c: Color32| p.rect_filled(Rect::from_min_max(at(x0, y0), at(x1, y1)), 0.0, c);
let axis = Stroke::new(1.0, line);
let data: [[f32; 4]; 3] = [[0.45, 0.6, 0.5, 0.8], [0.3, 0.4, 0.65, 0.5], [0.2, 0.3, 0.35, 0.6]];
match kind {
ChartType::Column | ChartType::ColumnStacked | ChartType::Bar | ChartType::BarStacked => {
let stacked = matches!(kind, ChartType::ColumnStacked | ChartType::BarStacked);
let horizontal = matches!(kind, ChartType::Bar | ChartType::BarStacked);
for i in 0..3 {
let band = (i as f32) / 3.0;
let mut base = 0.0;
for (j, c) in col.iter().enumerate() {
let v = data.get(j).and_then(|d| d.get(i)).copied().unwrap_or(0.5) * if stacked { 0.4 } else { 1.0 };
let (a0, a1) =
if stacked { (band + 0.06, band + 0.27) } else { (band + 0.04 + j as f32 * 0.085, band + 0.04 + (j + 1) as f32 * 0.085) };
if horizontal {
bar(base, 1.0 - a1, base + v, 1.0 - a0, *c);
} else {
bar(a0, 1.0 - base - v, a1, 1.0 - base, *c);
}
if stacked {
base += v;
}
}
}
if horizontal {
p.line_segment([at(0.0, 0.0), at(0.0, 1.0)], axis);
} else {
p.line_segment([at(0.0, 1.0), at(1.0, 1.0)], axis);
}
}
ChartType::Line | ChartType::Area => {
for (j, c) in col.iter().enumerate().take(2) {
let Some(d) = data.get(j) else { continue };
let pts: Vec<Pos2> = d.iter().enumerate().map(|(i, v)| at(i as f32 / 3.0, 1.0 - v)).collect();
if kind == ChartType::Area {
// Convex strips between neighbouring points.
for w in pts.windows(2) {
if let [a, b] = w {
p.add(Shape::convex_polygon(vec![*a, *b, pos2(b.x, r.bottom()), pos2(a.x, r.bottom())], *c, Stroke::NONE));
}
}
} else {
p.add(Shape::line(pts, Stroke::new(2.0, *c)));
}
}
p.line_segment([at(0.0, 1.0), at(1.0, 1.0)], axis);
}
ChartType::Pie | ChartType::Doughnut => {
let c = r.center();
let rad = r.width().min(r.height()) / 2.0;
let shares = [0.42, 0.33, 0.25];
let mut a = -std::f32::consts::FRAC_PI_2;
for (k, share) in shares.iter().enumerate() {
let b = a + share * std::f32::consts::TAU;
let steps = 12;
let mut pts = vec![c];
for s in 0..=steps {
let t = a + (b - a) * s as f32 / steps as f32;
pts.push(c + vec2(t.cos(), t.sin()) * rad);
}
// Each slice is under half a turn, so its fan is convex.
p.add(Shape::convex_polygon(pts, col.get(k).copied().unwrap_or(line), Stroke::NONE));
a = b;
}
if kind == ChartType::Doughnut {
p.circle_filled(c, rad * 0.5, Tokens::get(p.ctx()).input);
}
}
ChartType::Scatter => {
for (j, c) in col.iter().enumerate().take(2) {
for i in 0..5 {
let x = 0.08 + i as f32 * 0.2;
let y = 0.85 - (i as f32 * 0.15) - j as f32 * 0.2 + if i % 2 == 0 { 0.05 } else { -0.05 };
p.circle_filled(at(x, y.clamp(0.05, 0.95)), 2.5, *c);
}
}
p.line_segment([at(0.0, 1.0), at(1.0, 1.0)], axis);
p.line_segment([at(0.0, 0.0), at(0.0, 1.0)], axis);
}
}
}
/// The chart type gallery; returns true to close.
pub fn type_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ChartTypeForm) -> bool {
let t = Tokens::get(ui.ctx());
let col = accents(app);
let mut chosen = None;
egui::Grid::new("chart_types").spacing(vec2(8.0, 8.0)).show(ui, |ui| {
for (k, kind) in ChartType::ALL.into_iter().enumerate() {
let (rect, resp) = ui.allocate_exact_size(vec2(112.0, 92.0), Sense::click());
let on = f.kind == kind;
ui.painter().rect(
rect,
4.0,
if on {
t.checked
} else if resp.hovered() {
t.hover
} else {
t.input
},
Stroke::new(1.0, if on { t.accent } else { t.border }),
egui::StrokeKind::Inside,
);
let pic = Rect::from_min_size(rect.min + vec2(18.0, 6.0), vec2(76.0, 60.0));
preview(ui.painter(), pic, kind, col, t.text_dim);
ui.painter().text(
pos2(rect.center().x, rect.bottom() - 13.0),
egui::Align2::CENTER_CENTER,
tl!(kind.label()),
crate::theme::regular(11.0),
t.text,
);
let resp = resp.on_hover_text(tl!(kind.label()));
if resp.clicked() {
f.kind = kind;
}
if resp.double_clicked() {
chosen = Some(kind);
}
if k % 3 == 2 {
ui.end_row();
}
}
});
let (ok, cancel) = crate::dialogs::buttons(ui, tl!("OK"));
let Some(kind) = chosen.or(ok.then_some(f.kind)) else { return cancel };
let r = if f.change { app.run("chart.type", json!({"type": kind.id()})) } else { app.run("insert.chart", json!({"type": kind.id()})) };
r.is_ok() || cancel
}
/// The Edit Data grid; returns true to close.
pub fn data_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ChartDataForm) -> bool {
const CELL_W: f32 = 96.0;
const ROW_H: f32 = 24.0;
const X_W: f32 = 22.0;
let gap = ui.spacing().item_spacing.x;
let table_w = (f.names.len() as f32 + 1.0) * (CELL_W + X_W + 2.0 * gap) + 16.0;
ui.set_min_width(table_w.clamp(360.0, 760.0));
ui.label(tl!("Type the categories down the side and a column of values per series."));
ui.add_space(6.0);
let mut remove_series = None;
let mut remove_row = None;
let (can_remove_series, can_remove_row) = (f.names.len() > 1, f.categories.len() > 1);
egui::ScrollArea::horizontal().id_salt("chart_data_h").max_width(760.0).show(ui, |ui| {
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.add_sized([CELL_W, ROW_H - 4.0], egui::Label::new(egui::RichText::new(tl!("Categories")).strong()));
ui.allocate_exact_size(vec2(X_W, ROW_H - 4.0), Sense::hover());
for (j, n) in f.names.iter_mut().enumerate() {
ui.add_sized([CELL_W, ROW_H - 4.0], egui::TextEdit::singleline(n).hint_text(tl!("Series name")));
let x = ui
.add_enabled_ui(can_remove_series, |ui| ui.add_sized([X_W, ROW_H - 4.0], egui::Button::new("×")))
.inner
.on_hover_text(tl!("Remove series"));
if x.clicked() {
remove_series = Some(j);
}
}
});
ui.separator();
let rows = f.categories.len();
egui::ScrollArea::vertical().id_salt("chart_data_v").max_height(280.0).show_rows(ui, ROW_H, rows, |ui, range| {
for i in range {
ui.horizontal(|ui| {
if let Some(c) = f.categories.get_mut(i) {
ui.add_sized([CELL_W, ROW_H - 4.0], egui::TextEdit::singleline(c));
}
let x = ui
.add_enabled_ui(can_remove_row, |ui| ui.add_sized([X_W, ROW_H - 4.0], egui::Button::new("×")))
.inner
.on_hover_text(tl!("Remove row"));
if x.clicked() {
remove_row = Some(i);
}
if let Some(row) = f.cells.get_mut(i) {
for cell in row.iter_mut() {
ui.add_sized([CELL_W, ROW_H - 4.0], egui::TextEdit::singleline(cell).horizontal_align(egui::Align::Max));
ui.allocate_exact_size(vec2(X_W, ROW_H - 4.0), Sense::hover());
}
}
});
}
});
});
});
if let Some(j) = remove_series.filter(|_| f.names.len() > 1) {
if j < f.names.len() {
f.names.remove(j);
}
for r in &mut f.cells {
if j < r.len() {
r.remove(j);
}
}
}
if let Some(i) = remove_row.filter(|i| *i < f.categories.len() && f.categories.len() > 1) {
f.categories.remove(i);
if i < f.cells.len() {
f.cells.remove(i);
}
}
ui.horizontal(|ui| {
if ui.add_enabled(f.categories.len() < MAX_POINTS, egui::Button::new(tl!("Add Row"))).clicked() {
f.add_row();
}
if ui.add_enabled(f.names.len() < MAX_SERIES, egui::Button::new(tl!("Add Series"))).clicked() {
f.add_series();
}
});
if !f.message.is_empty() {
ui.label(egui::RichText::new(f.message.as_str()).color(Tokens::get(ui.ctx()).red));
}
let (ok, cancel) = crate::dialogs::buttons(ui, tl!("OK"));
if !ok {
return cancel;
}
match f.params().and_then(|p| app.run("chart.editData", p)) {
Ok(_) => true,
Err(e) => {
f.message = e;
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn data_grid_round_trips_the_chart() {
let c = ChartSpec::sample(ChartType::Column);
let mut f = ChartDataForm::of(&c);
let p = f.params().unwrap();
assert_eq!(p["data"]["categories"][3], "Q4");
assert_eq!(p["data"]["series"][1]["values"][2], 3.9);
f.add_series();
f.add_row();
f.cells[0][3] = "1,5".into();
let p = f.params().unwrap();
assert_eq!(p["data"]["series"][3]["values"][0], 1.5);
assert!(p["data"]["series"][3]["values"][1].is_null());
f.cells[1][0] = "abc".into();
assert!(f.params().is_err());
}
}
+15 -1
View File
@@ -16,6 +16,8 @@ pub enum InsertDialog {
Columns(ColumnsForm),
Symbol(SymbolForm),
Field(FieldForm),
ChartType(crate::dialogs_chart::ChartTypeForm),
ChartData(crate::dialogs_chart::ChartDataForm),
}
impl InsertDialog {
@@ -24,6 +26,9 @@ impl InsertDialog {
InsertDialog::Columns(_) => "columns",
InsertDialog::Symbol(_) => "symbol",
InsertDialog::Field(_) => "field",
InsertDialog::ChartType(f) if f.change => "changeChartType",
InsertDialog::ChartType(_) => "insertChart",
InsertDialog::ChartData(_) => "chartData",
}
}
@@ -33,17 +38,24 @@ impl InsertDialog {
InsertDialog::Columns(_) => "Columns",
InsertDialog::Symbol(_) => "Symbol",
InsertDialog::Field(_) => "Field",
InsertDialog::ChartType(f) if f.change => "Change Chart Type",
InsertDialog::ChartType(_) => "Insert Chart",
InsertDialog::ChartData(_) => "Edit Data",
}
}
}
/// The dialog `ui.dialog` opens by `name`: `columns`, `symbol`, `specialCharacters` or `field`.
/// The dialog `ui.dialog` opens by `name`: `columns`, `symbol`, `specialCharacters`, `field`,
/// `insertChart`, `changeChartType` or `chartData` (the last two for the selected chart).
pub fn open(name: &str, app: &WordApp) -> Option<InsertDialog> {
Some(match name {
"columns" => InsertDialog::Columns(ColumnsForm::read(app)),
"symbol" => InsertDialog::Symbol(SymbolForm::new(app, 0)),
"specialCharacters" => InsertDialog::Symbol(SymbolForm::new(app, 1)),
"field" => InsertDialog::Field(FieldForm::new(app)),
"insertChart" => InsertDialog::ChartType(crate::dialogs_chart::ChartTypeForm::new(app, false)?),
"changeChartType" => InsertDialog::ChartType(crate::dialogs_chart::ChartTypeForm::new(app, true)?),
"chartData" => InsertDialog::ChartData(crate::dialogs_chart::ChartDataForm::new(app)?),
_ => return None,
})
}
@@ -54,6 +66,8 @@ pub fn body(app: &mut WordApp, ui: &mut Ui, d: &mut InsertDialog) -> bool {
InsertDialog::Columns(f) => columns_ui(app, ui, f),
InsertDialog::Symbol(f) => symbol_ui(app, ui, f),
InsertDialog::Field(f) => field_ui(app, ui, f),
InsertDialog::ChartType(f) => crate::dialogs_chart::type_ui(app, ui, f),
InsertDialog::ChartData(f) => crate::dialogs_chart::data_ui(app, ui, f),
}
}
+17 -1
View File
@@ -117,6 +117,11 @@ impl Exempt {
for i in wordcraft_engine::cmd::accessibility::check(&app.session.doc) {
names.extend(["issue", "fix"].iter().filter_map(|k| i[*k].as_str()).map(str::to_string));
}
// The selected chart's data (Edit Data's grid): document content.
if let Some(c) = wordcraft_engine::cmd::chart::selected_chart(&app.session) {
names.extend(c.categories.iter().cloned());
names.extend(c.series.iter().map(|s| s.name.clone()));
}
for m in crate::credits::MODELS {
names.extend([m.company, m.model, m.version].map(str::to_string));
}
@@ -233,6 +238,16 @@ fn untranslated_interface_text() {
let drawn = frame(&ctx, &mut app);
record(&app, &format!("tab {tab}"), drawn);
}
let _ = app.run("insert.chart", json!({"type": "column"}));
let _ = app.run("ui.tab", json!({"tab": "Chart Design"}));
let drawn = frame(&ctx, &mut app);
record(&app, "tab Chart Design", drawn);
for name in ["chartData", "changeChartType", "insertChart"] {
let _ = app.run("ui.dialog", json!({"name": name}));
let drawn = frame(&ctx, &mut app);
record(&app, &format!("dialog {name}"), drawn);
app.dialog = None;
}
let _ = app.run("insert.equation", json!({}));
let _ = app.run("ui.tab", json!({"tab": "Equation"}));
let drawn = frame(&ctx, &mut app);
@@ -241,13 +256,14 @@ fn untranslated_interface_text() {
// tab keeps one tab's commands from changing the next.
// A fresh app per control, set up the same way, so no click changes what the next one hits
// (Escape, for one, leaves an equation and takes the Equation tab with it).
let contextual = ["Table Design", "Table Layout", "Equation"];
let contextual = ["Table Design", "Table Layout", "Equation", "Chart Design"];
let open = |tab: &str| {
let ctx = egui::Context::default();
let mut app = fresh_app(&ctx);
match tab {
"Table Design" | "Table Layout" => drop(app.run("insert.table", json!({"rows": 2, "cols": 2}))),
"Equation" => drop(app.run("insert.equation", json!({}))),
"Chart Design" => drop(app.run("insert.chart", json!({"type": "column"}))),
_ => {}
}
let _ = app.run("ui.tab", json!({"tab": tab}));
+31
View File
@@ -1462,6 +1462,37 @@
Enlarge symbol Agrandar símbolo
Enclosure Contorno
Circle Círculo
Change Chart Type Cambiar tipo de gráfico
Chart Design Diseño de gráfico
Type Tipo
Edit Data Editar datos
Chart Title Título del gráfico
Chart Layouts Diseños de gráfico
Add Chart Element Agregar elemento de gráfico
Legend Leyenda
Data Labels Etiquetas de datos
Above Chart Encima del gráfico
Insert Chart Insertar gráfico
Clustered Column Columnas agrupadas
Stacked Column Columnas apiladas
Clustered Bar Barras agrupadas
Stacked Bar Barras apiladas
Area Área
Pie Circular
Doughnut Anillo
Scatter Dispersión
“{text}” ({row}, {series}) isn't a number. «{text}» ({row}, {series}) no es un número.
Category {n} Categoría {n}
Series {n} Serie {n}
Type the categories down the side and a column of values per series. Escribe las categorías en el lateral y una columna de valores por serie.
Categories Categorías
Series name Nombre de la serie
Remove series Quitar serie
Remove row Quitar fila
Add Row Agregar fila
Add Series Agregar serie
Edit\nData Editar\ndatos
Change\nChart Type Cambiar tipo\nde gráfico
Align Text Alinear texto
Create Link Crear vínculo
Break Link Romper vínculo
Can't render this file because it contains an unexpected character in line 6 and column 25.
+31
View File
@@ -1435,6 +1435,37 @@
Size (pt): Suurus (pt):
Moved from Teisaldatud kohast
Moved to Teisaldatud kohta
Change Chart Type Muuda diagrammi tüüpi
Chart Design Diagrammi kujundus
Type Tüüp
Edit Data Redigeeri andmeid
Chart Title Diagrammi pealkiri
Chart Layouts Diagrammi paigutused
Add Chart Element Lisa diagrammi element
Legend Legend
Data Labels Andmesildid
Above Chart Diagrammi kohal
Insert Chart Lisa diagramm
Clustered Column Rühmitatud tulbad
Stacked Column Virnastatud tulbad
Clustered Bar Rühmitatud ribad
Stacked Bar Virnastatud ribad
Area Pind
Pie Sektor
Doughnut Rõngas
Scatter Hajuvus
“{text}” ({row}, {series}) isn't a number. „{text}” ({row}, {series}) ei ole arv.
Category {n} Kategooria {n}
Series {n} Sari {n}
Type the categories down the side and a column of values per series. Sisesta kategooriad külje peale ja iga sarja jaoks väärtuste veerg.
Categories Kategooriad
Series name Sarja nimi
Remove series Eemalda sari
Remove row Eemalda rida
Add Row Lisa rida
Add Series Lisa sari
Edit\nData Redigeeri\nandmeid
Change\nChart Type Muuda\ndiagrammi tüüpi
Align Text Joonda tekst
Break Link Katkesta link
Click the empty text box the text should continue in (Esc cancels) Klõpsa tühjal tekstiväljal, kus tekst peaks jätkuma (Esc tühistab)
Can't render this file because it contains an unexpected character in line 421 and column 13.
+31
View File
@@ -1446,6 +1446,37 @@
Enlarge symbol 記号を拡大
Enclosure 囲い
Circle 円
Change Chart Type グラフの種類の変更
Chart Design グラフのデザイン
Type 種類
Edit Data データの編集
Chart Title グラフ タイトル
Chart Layouts グラフのレイアウト
Add Chart Element グラフ要素の追加
Legend 凡例
Data Labels データ ラベル
Above Chart グラフの上
Insert Chart グラフの挿入
Clustered Column 集合縦棒
Stacked Column 積み上げ縦棒
Clustered Bar 集合横棒
Stacked Bar 積み上げ横棒
Area 面
Pie 円
Doughnut ドーナツ
Scatter 散布図
“{text}” ({row}, {series}) isn't a number. 「{text}」({row}、{series})は数値ではありません。
Category {n} カテゴリ {n}
Series {n} 系列 {n}
Type the categories down the side and a column of values per series. 左端の列にカテゴリを、系列ごとに 1 列ずつ値を入力します。
Categories カテゴリ
Series name 系列名
Remove series 系列を削除
Remove row 行を削除
Add Row 行の追加
Add Series 系列の追加
Edit\nData データの\n編集
Change\nChart Type グラフの種類\nの変更
Align Text 文字の配置
Create Link リンクの作成
Break Link リンクの解除
Can't render this file because it contains an unexpected character in line 504 and column 13.
+31
View File
@@ -1458,6 +1458,37 @@
Enlarge symbol Ampliar símbolo
Enclosure Contorno
Circle Círculo
Change Chart Type Alterar tipo de gráfico
Chart Design Design do gráfico
Type Tipo
Edit Data Editar dados
Chart Title Título do gráfico
Chart Layouts Layouts do gráfico
Add Chart Element Adicionar elemento ao gráfico
Legend Legenda
Data Labels Rótulos de dados
Above Chart Acima do gráfico
Insert Chart Inserir gráfico
Clustered Column Colunas agrupadas
Stacked Column Colunas empilhadas
Clustered Bar Barras agrupadas
Stacked Bar Barras empilhadas
Area Área
Pie Pizza
Doughnut Rosca
Scatter Dispersão
“{text}” ({row}, {series}) isn't a number. “{text}” ({row}, {series}) não é um número.
Category {n} Categoria {n}
Series {n} Série {n}
Type the categories down the side and a column of values per series. Digite as categorias na lateral e uma coluna de valores por série.
Categories Categorias
Series name Nome da série
Remove series Remover série
Remove row Remover linha
Add Row Adicionar linha
Add Series Adicionar série
Edit\nData Editar\ndados
Change\nChart Type Alterar tipo\nde gráfico
Align Text Alinhar texto
Create Link Criar link
Break Link Quebrar link
Can't render this file because it contains an unexpected character in line 6 and column 25.
+31
View File
@@ -1944,6 +1944,37 @@ preview Title Пример
Search icons Поиск значков
No icons match your search. Нет значков, соответствующих поиску.
Recolor a one-color picture, such as an icon Перекрасить одноцветный рисунок, например значок
Change Chart Type Изменить тип диаграммы
Chart Design Конструктор диаграмм
Type Тип
Edit Data Изменить данные
Chart Title Название диаграммы
Chart Layouts Макеты диаграмм
Add Chart Element Добавить элемент диаграммы
Legend Легенда
Data Labels Подписи данных
Above Chart Над диаграммой
Insert Chart Вставка диаграммы
Clustered Column Гистограмма с группировкой
Stacked Column Гистограмма с накоплением
Clustered Bar Линейчатая с группировкой
Stacked Bar Линейчатая с накоплением
Area С областями
Pie Круговая
Doughnut Кольцевая
Scatter Точечная
“{text}” ({row}, {series}) isn't a number. «{text}» ({row}, {series}) — не число.
Category {n} Категория {n}
Series {n} Ряд {n}
Type the categories down the side and a column of values per series. Введите категории по вертикали и столбец значений для каждого ряда.
Categories Категории
Series name Имя ряда
Remove series Удалить ряд
Remove row Удалить строку
Add Row Добавить строку
Add Series Добавить ряд
Edit\nData Изменить\nданные
Change\nChart Type Изменить\nтип диаграммы
Align Text Выравнивание текста
Create Link Создать связь
Break Link Разорвать связь
Can't render this file because it contains an unexpected character in line 7 and column 25.
+31
View File
@@ -1456,6 +1456,37 @@
Enlarge symbol Uvećaj simbol
Enclosure Okvir
Circle Krug
Change Chart Type Promeni tip grafikona
Chart Design Dizajn grafikona
Type Tip
Edit Data Uredi podatke
Chart Title Naslov grafikona
Chart Layouts Rasporedi grafikona
Add Chart Element Dodaj element grafikona
Legend Legenda
Data Labels Oznake podataka
Above Chart Iznad grafikona
Insert Chart Umetni grafikon
Clustered Column Grupisane kolone
Stacked Column Naslagane kolone
Clustered Bar Grupisane trake
Stacked Bar Naslagane trake
Area Površinski
Pie Kružni
Doughnut Prstenasti
Scatter Raspršeni
“{text}” ({row}, {series}) isn't a number. „{text}” ({row}, {series}) nije broj.
Category {n} Kategorija {n}
Series {n} Serija {n}
Type the categories down the side and a column of values per series. Unesite kategorije sa strane i po jednu kolonu vrednosti za svaku seriju.
Categories Kategorije
Series name Naziv serije
Remove series Ukloni seriju
Remove row Ukloni red
Add Row Dodaj red
Add Series Dodaj seriju
Edit\nData Uredi\npodatke
Change\nChart Type Promeni tip\ngrafikona
Align Text Poravnaj tekst
Create Link Napravi vezu
Break Link Prekini vezu
Can't render this file because it contains an unexpected character in line 6 and column 25.
+31
View File
@@ -1460,6 +1460,37 @@
Enlarge symbol Увећај симбол
Enclosure Оквир
Circle Круг
Change Chart Type Промени тип графикона
Chart Design Дизајн графикона
Type Тип
Edit Data Уреди податке
Chart Title Наслов графикона
Chart Layouts Распореди графикона
Add Chart Element Додај елемент графикона
Legend Легенда
Data Labels Ознаке података
Above Chart Изнад графикона
Insert Chart Уметни графикон
Clustered Column Груписане колоне
Stacked Column Наслагане колоне
Clustered Bar Груписане траке
Stacked Bar Наслагане траке
Area Површински
Pie Кружни
Doughnut Прстенасти
Scatter Распршени
“{text}” ({row}, {series}) isn't a number. „{text}” ({row}, {series}) није број.
Category {n} Категорија {n}
Series {n} Серија {n}
Type the categories down the side and a column of values per series. Унесите категорије са стране и по једну колону вредности за сваку серију.
Categories Категорије
Series name Назив серије
Remove series Уклони серију
Remove row Уклони ред
Add Row Додај ред
Add Series Додај серију
Edit\nData Уреди\nподатке
Change\nChart Type Промени тип\nграфикона
Align Text Поравнај текст
Create Link Направи везу
Break Link Прекини везу
Can't render this file because it contains an unexpected character in line 11 and column 25.
+31
View File
@@ -1566,6 +1566,37 @@
Search icons Simgelerde ara
No icons match your search. Aramanızla eşleşen simge yok.
Recolor a one-color picture, such as an icon Simge gibi tek renkli bir resmi yeniden renklendir
Change Chart Type Grafik Türünü Değiştir
Chart Design Grafik Tasarımı
Type Tür
Edit Data Verileri Düzenle
Chart Title Grafik Başlığı
Chart Layouts Grafik Düzenleri
Add Chart Element Grafik Öğesi Ekle
Legend Gösterge
Data Labels Veri Etiketleri
Above Chart Grafiğin Üstünde
Insert Chart Grafik Ekle
Clustered Column Kümelenmiş Sütun
Stacked Column Yığılmış Sütun
Clustered Bar Kümelenmiş Çubuk
Stacked Bar Yığılmış Çubuk
Area Alan
Pie Pasta
Doughnut Halka
Scatter Dağılım
“{text}” ({row}, {series}) isn't a number. “{text}” ({row}, {series}) bir sayı değil.
Category {n} Kategori {n}
Series {n} Seri {n}
Type the categories down the side and a column of values per series. Kategorileri yan tarafa, her seri için bir değer sütunu yazın.
Categories Kategoriler
Series name Seri adı
Remove series Seriyi kaldır
Remove row Satırı kaldır
Add Row Satır Ekle
Add Series Seri Ekle
Edit\nData Verileri\nDüzenle
Change\nChart Type Grafik Türünü\nDeğiştir
Align Text Metni Hizala
Create Link Bağlantı Oluştur
Break Link Bağlantıyı Kes
Can't render this file because it contains an unexpected character in line 430 and column 13.
+31
View File
@@ -1458,6 +1458,37 @@
Enlarge symbol Збільшити символ
Enclosure Обведення
Circle Коло
Change Chart Type Змінити тип діаграми
Chart Design Конструктор діаграми
Type Тип
Edit Data Редагувати дані
Chart Title Назва діаграми
Chart Layouts Макети діаграми
Add Chart Element Додати елемент діаграми
Legend Легенда
Data Labels Підписи даних
Above Chart Над діаграмою
Insert Chart Вставити діаграму
Clustered Column Стовпці з групуванням
Stacked Column Стовпці з накопиченням
Clustered Bar Смуги з групуванням
Stacked Bar Смуги з накопиченням
Area Області
Pie Кругова
Doughnut Кільцева
Scatter Точкова
“{text}” ({row}, {series}) isn't a number. «{text}» ({row}, {series}) — не число.
Category {n} Категорія {n}
Series {n} Ряд {n}
Type the categories down the side and a column of values per series. Введіть категорії збоку та стовпець значень для кожного ряду.
Categories Категорії
Series name Назва ряду
Remove series Видалити ряд
Remove row Видалити рядок
Add Row Додати рядок
Add Series Додати ряд
Edit\nData Редагувати\nдані
Change\nChart Type Змінити тип\nдіаграми
Align Text Вирівняти текст
Create Link Створити зв'язок
Break Link Розірвати зв'язок
Can't render this file because it contains an unexpected character in line 6 and column 31.
+31
View File
@@ -1458,6 +1458,37 @@
Enlarge symbol 增大圈号
Enclosure 圈号
Circle 圆形
Change Chart Type 更改图表类型
Chart Design 图表设计
Type 类型
Edit Data 编辑数据
Chart Title 图表标题
Chart Layouts 图表布局
Add Chart Element 添加图表元素
Legend 图例
Data Labels 数据标签
Above Chart 图表上方
Insert Chart 插入图表
Clustered Column 簇状柱形图
Stacked Column 堆积柱形图
Clustered Bar 簇状条形图
Stacked Bar 堆积条形图
Area 面积图
Pie 饼图
Doughnut 圆环图
Scatter 散点图
“{text}” ({row}, {series}) isn't a number. “{text}”({row},{series})不是数字。
Category {n} 类别 {n}
Series {n} 系列 {n}
Type the categories down the side and a column of values per series. 在左侧输入类别,每个系列输入一列数值。
Categories 类别
Series name 系列名称
Remove series 删除系列
Remove row 删除行
Add Row 添加行
Add Series 添加系列
Edit\nData 编辑\n数据
Change\nChart Type 更改\n图表类型
Align Text 对齐文本
Create Link 创建链接
Break Link 断开链接
Can't render this file because it contains an unexpected character in line 7 and column 25.
+31
View File
@@ -1447,6 +1447,37 @@
Enlarge symbol 放大符號
Enclosure 圍繞符號
Circle 圓形
Change Chart Type 變更圖表類型
Chart Design 圖表設計
Type 類型
Edit Data 編輯資料
Chart Title 圖表標題
Chart Layouts 圖表版面配置
Add Chart Element 新增圖表項目
Legend 圖例
Data Labels 資料標籤
Above Chart 圖表上方
Insert Chart 插入圖表
Clustered Column 群組直條圖
Stacked Column 堆疊直條圖
Clustered Bar 群組橫條圖
Stacked Bar 堆疊橫條圖
Area 區域圖
Pie 圓形圖
Doughnut 環圈圖
Scatter 散佈圖
“{text}” ({row}, {series}) isn't a number. 「{text}」({row},{series})不是數字。
Category {n} 類別 {n}
Series {n} 數列 {n}
Type the categories down the side and a column of values per series. 在左側輸入類別,每個數列輸入一欄數值。
Categories 類別
Series name 數列名稱
Remove series 移除數列
Remove row 移除列
Add Row 新增列
Add Series 新增數列
Edit\nData 編輯\n資料
Change\nChart Type 變更\n圖表類型
Align Text 對齊文字
Create Link 建立連結
Break Link 中斷連結
Can't render this file because it contains an unexpected character in line 505 and column 13.
+37
View File
@@ -360,6 +360,43 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
pen.frect(13.5, 9.0, 16.5, 17.0, green);
pen.line(&[(2.0, 17.5), (18.0, 17.5)]);
}
"changeChartType" => {
pen.frect(3.0, 11.0, 5.5, 17.0, a);
pen.frect(7.0, 7.0, 9.5, 17.0, orange);
pen.line(&[(2.0, 17.5), (11.0, 17.5)]);
pen.fill(&[(15.0, 10.0), (15.0, 5.0), (19.0, 8.5)], green);
pen.circle(15.0, 10.0, 4.0, c);
pen.line_c(&[(4.0, 5.0), (9.0, 2.5), (12.0, 4.5)], a);
}
"chartData" => {
pen.rect(2.5, 3.0, 17.5, 17.0, c);
pen.frect(2.5, 3.0, 17.5, 6.5, a);
pen.line(&[(2.5, 10.0), (17.5, 10.0)]);
pen.line(&[(2.5, 13.5), (17.5, 13.5)]);
pen.line(&[(7.5, 3.0), (7.5, 17.0)]);
pen.line(&[(12.5, 3.0), (12.5, 17.0)]);
pen.frect(9.0, 14.5, 11.0, 16.0, orange);
}
"chartTitle" => {
pen.frect(5.0, 2.5, 15.0, 4.5, a);
pen.frect(4.0, 11.0, 7.0, 17.0, c);
pen.frect(8.5, 8.0, 11.5, 17.0, c);
pen.frect(13.0, 12.5, 16.0, 17.0, c);
pen.line(&[(2.5, 17.5), (17.5, 17.5)]);
}
"legend" => {
for (k, col) in [a, orange, green].into_iter().enumerate() {
let y = 5.0 + 5.0 * k as f32;
pen.frect(3.0, y - 1.5, 6.0, y + 1.5, col);
pen.line(&[(8.0, y), (17.0, y)]);
}
}
"dataLabels" => {
pen.frect(3.5, 11.0, 7.0, 17.5, a);
pen.frect(12.0, 8.0, 15.5, 17.5, orange);
pen.rect(2.5, 5.5, 8.0, 9.0, c);
pen.rect(11.0, 2.5, 16.5, 6.0, c);
}
"screenshot" => {
pen.line(&[(3.0, 7.0), (3.0, 3.0), (7.0, 3.0)]);
pen.line(&[(13.0, 3.0), (17.0, 3.0), (17.0, 7.0)]);
+5
View File
@@ -20,6 +20,7 @@ pub mod chrome;
pub mod control;
pub mod credits;
pub mod dialogs;
pub mod dialogs_chart;
pub mod dialogs_insert;
pub mod dialogs_keyboard;
pub mod dialogs_layout;
@@ -1193,6 +1194,10 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> {
"insert.field" if !has("instr") => Some("field"),
// Edit Field Code shows the selected field's code (#339).
"fields.setCode" if !has("code") => Some("editFieldCode"),
// Chart without a type shows the gallery; Change Chart Type and Edit Data their dialogs.
"insert.chart" if !has("type") && !has("data") => Some("insertChart"),
"chart.type" if !has("type") => Some("changeChartType"),
"chart.editData" if !["data", "categories", "series"].into_iter().any(has) => Some("chartData"),
// Table Properties without settings shows the dialog (with settings it applies them).
"table.properties" if params.as_object().is_none_or(|m| m.is_empty()) => Some("tableProperties"),
// Tabs, Borders and Shading, and Page Borders without settings show their dialogs (#320).
+80
View File
@@ -22,6 +22,11 @@ pub fn has_shape_selected(s: &wordcraft_engine::Session) -> bool {
}
}
/// Whether the selection is a chart WordCraft can edit (so Chart Design is shown).
pub fn has_chart_selected(s: &wordcraft_engine::Session) -> bool {
wordcraft_engine::cmd::chart::selected_chart(s).is_some()
}
/// Contextual tabs for the current selection (pure, tested).
pub fn contextual_tabs(s: &wordcraft_engine::Session) -> Vec<&'static str> {
let mut tabs = Vec::new();
@@ -35,6 +40,9 @@ pub fn contextual_tabs(s: &wordcraft_engine::Session) -> Vec<&'static str> {
if has_shape_selected(s) {
tabs.push("Shape Format");
}
if has_chart_selected(s) {
tabs.push("Chart Design");
}
// Editing an equation.
if s.math.is_some() {
tabs.push("Equation");
@@ -201,6 +209,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
"Equation" if app.session.math.is_some() => crate::equation_tab::show(app, ui),
"Picture Format" => picture_format(app, ui),
"Shape Format" => shape_format(app, ui),
"Chart Design" => chart_design(app, ui),
_ => home(app, ui),
}
if let Some(e) = entry {
@@ -1406,6 +1415,59 @@ fn help(app: &mut WordApp, ui: &mut Ui) {
});
}
/// Chart Design: the selected chart's elements (title, legend, data labels), its data and type,
/// and its size.
fn chart_design(app: &mut WordApp, ui: &mut Ui) {
let Some(chart) = wordcraft_engine::cmd::chart::selected_chart(&app.session) else { return };
group(ui, "Chart Layouts", None, app, |ui, app| {
menu_button(ui, app, "chartTitle", Some("Chart Title"), "Chart Title", true, |ui, app| {
mi_check(ui, app, "None", chart.title.is_none(), "chart.title", json!({"text": null}));
let text = chart.title.clone().unwrap_or_else(|| tl!("Chart Title").to_string());
mi_check(ui, app, "Above Chart", chart.title.is_some(), "chart.title", json!({"text": text}));
});
menu_button(ui, app, "legend", Some("Legend"), "Legend", true, |ui, app| {
mi_check(ui, app, "None", chart.legend.is_none(), "chart.legend", json!({"pos": "none"}));
for (label, pos) in [
("Right", wordcraft_doc::chart::LegendPos::Right),
("Top", wordcraft_doc::chart::LegendPos::Top),
("Left", wordcraft_doc::chart::LegendPos::Left),
("Bottom", wordcraft_doc::chart::LegendPos::Bottom),
] {
mi_check(ui, app, label, chart.legend == Some(pos), "chart.legend", json!({"pos": pos.id()}));
}
});
menu_button(ui, app, "dataLabels", Some("Data Labels"), "Data Labels", true, |ui, app| {
mi_check(ui, app, "None", !chart.data_labels, "chart.dataLabels", json!({"on": false}));
mi_check(ui, app, "Show", chart.data_labels, "chart.dataLabels", json!({"on": true}));
});
});
group(ui, "Data", None, app, |ui, app| {
big(ui, app, "chartData", "Edit\nData", "chart.editData", json!({}), false);
});
group(ui, "Type", None, app, |ui, app| {
big(ui, app, "changeChartType", "Change\nChart Type", "chart.type", json!({}), false);
});
group(ui, "Size", None, app, |ui, app| {
let (w0, h0) =
wordcraft_engine::cmd::objects::selected(&app.session).and_then(|(_, o)| o.frame().map(|(w, h, _)| (w, h))).unwrap_or((0.0, 0.0));
stack(ui, |ui| {
for (label, key, v0) in [("H:", "height", h0), ("W:", "width", w0)] {
crate::widgets::row(ui, |ui| {
ui.label(egui::RichText::new(tl!(label)).small());
let mut v = v0;
let r = ui.add(egui::DragValue::new(&mut v).speed(1.0).range(36.0..=2000.0).suffix(" pt"));
if r.changed() {
if r.dragged() && !r.drag_started() {
app.session.join_next_undo();
}
let _ = app.run("picture.size", json!({key: v, "lockAspect": false}));
}
});
}
});
});
}
/// Shape Format: fill, outline and effects of the selected shape, and its arrangement.
fn shape_format(app: &mut WordApp, ui: &mut Ui) {
let (stroke, glow_size) = match wordcraft_engine::cmd::objects::selected(&app.session) {
@@ -1962,6 +2024,24 @@ mod tests {
assert!(!contextual_tabs(&s).contains(&"Shape Format"));
}
#[test]
fn chart_design_tab_appears_when_chart_selected_and_buttons_open_dialogs() {
let mut app = crate::WordApp::new(Session::new(wordcraft_doc::Document::new()), Default::default());
// Insert › Chart without a type opens the gallery; choosing inserts.
let _ = app.run("insert.chart", json!({}));
assert_eq!(app.dialog.as_ref().map(crate::dialogs::Dialog::name), Some("insertChart"));
app.dialog = None;
app.run("insert.chart", json!({"type": "doughnut"})).unwrap();
assert!(contextual_tabs(&app.session).contains(&"Chart Design"));
let _ = app.run("chart.editData", json!({}));
assert_eq!(app.dialog.as_ref().map(crate::dialogs::Dialog::name), Some("chartData"));
let _ = app.run("chart.type", json!({}));
assert_eq!(app.dialog.as_ref().map(crate::dialogs::Dialog::name), Some("changeChartType"));
app.session.run("select.collapse", &json!({"end": true})).unwrap();
app.session.run("text.insert", &json!({"text": "x"})).unwrap();
assert!(!contextual_tabs(&app.session).contains(&"Chart Design"));
}
#[test]
fn resolve_tab_falls_back_when_contextual_tab_expires() {
let all = ["Home", "Table Design", "Picture Format"];
+2 -1
View File
@@ -63,7 +63,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| Group shapes (`wpg:`), drawing canvas (`wpc:`) | ✅ | ✅ | Groups (#267) and canvases (#344) with pictures, shapes and text boxes; nested groups flattened. The canvas schema has no transform, so a turned or flipped canvas keeps its turn in our own `wpc:extLst` extension (`urn:wordcraft:canvas-xfrm`; Word shows it unturned). Legacy VML canvases (`v:group editas="canvas"`) aren't read as canvases |
| VML (`w:pict`, `v:shape`, `v:textbox`) | 🟡 | ❌ | Pictures and text boxes, best effort (#242) |
| Alt text of drawings (`wp:docPr`/`cNvPr` `descr` and `title`, decorative mark `adec:decorative` from [MS-ODRAWXML]) and tables (`w:tblCaption`, `w:tblDescription`) | ✅ | ✅ | Pictures, shapes, text boxes, groups and their members, charts and SmartArt; OLE objects get theirs written into the kept XML (#386) |
| Charts (`c:chart`), SmartArt (`dgm`) | ✅ | ✅ | Drawn from the chart's cached data and SmartArt's stored drawing (#292); not editable. Saved back as read, with every part they reach (chart, embedded workbook, colours and style; diagram data, layout, quick style, colours and drawing) and their content types; a moved or resized one keeps its new frame, and a pasted copy gets its own parts (#319) |
| Charts (`c:chart`), SmartArt (`dgm`) | ✅ | ✅ | Drawn from the chart's cached data and SmartArt's stored drawing (#292), with data labels (`c:showVal`). Charts made in WordCraft (Insert › Chart, #409) are written as a `c:chartSpace` part with their data as literals (`c:strLit`/`c:numLit`, no embedded workbook, so Word shows them and offers to create a workbook on Edit Data) and read back as editable; a chart part holding anything more (any chart from Word, or one re-saved by Word) stays read-only. Saved back as read, with every part they reach (chart, embedded workbook, colours and style; diagram data, layout, quick style, colours and drawing) and their content types; a moved or resized one keeps its new frame, and a pasted copy gets its own parts (#319) |
| OLE objects (`w:object`: embedded or linked files, ActiveX) | 🟡 | ✅ | Shown as their picture (VML or DrawingML); saved back whole with the embedding and picture parts, at the size, rotation and flips set in WordCraft (#319, #332). Can't be opened or edited |
| Word ink (`w14:contentPart`, InkML), 3D models | ❌ | ❌ | Dropped on read, not preserved on save |
| Equations (OMML `m:oMath`) | ✅ | ✅ | #191; manual breaks (`m:brk`) and the settings' `m:mathPr` wrapping options (#326) |
@@ -88,6 +88,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | Charts made in WordCraft written as chart parts and read back as editable (#409) |
| 2026-10-11 | trivial | Hyphenation zone, words in capitals and consecutive-hyphen limit read and written (#407) |
| 2026-10-11 | trivial | Alt text, titles and the decorative mark for every drawing, table captions and descriptions round-trip (#386) |
| 2026-10-11 | trivial | Document grid (`w:docGrid`) and paragraph `w:snapToGrid` read and written (#391) |
+2 -1
View File
@@ -23,7 +23,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes.
| 10 | **Objects**: no group (#267 open), text in a freely rotated text box (free rotation and flips landed, #332; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt, connectors, dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 6/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) |
| 11 | **Interface languages**: 8 of the 12 key languages missing (hi, ar, fr, id, de, ko, vi, pt-PT); no mirrored RTL interface; ~5% of strings still English in the six translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [localization-parity.md](localization-parity.md) |
| 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395); missing: Ink to Math, arrows in Ink to Shape, pressure, Word's own ink (`w14:contentPart`/InkML) | | [parity-checklist.md](parity-checklist.md) (Draw 9/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) |
| 13 | **Charts and SmartArt editing** (own renderer, chart data editing, SmartArt layouts) | | `insert.chart`, `insert.smartArt` missing | Reports and business documents | 45–70 | [target-app-parity.md](target-app-parity.md) |
| 13 | **Charts and SmartArt editing** (editing charts from Word, more chart types and formatting, SmartArt layouts) | | `insert.chart` and Chart Design basics landed (#409): WordCraft's own charts are editable; charts from Word stay read-only (no embedded-workbook editing), no combo/3D/radar/stock charts, axis or series formatting; `insert.smartArt` missing | Reports and business documents | 35–55 | [target-app-parity.md](target-app-parity.md) |
| 14 | **`.doc` can't be written; metafile pictures and Word 6/95 files refused** | | `crates/docbin` is read-only | Users exchanging files with old Word or legacy systems | 25–40 | [file-format-parity.md](file-format-parity.md) |
| 15 | **References depth**: sources not saved in DOCX (#169 open); no EndNote/Mendeley desktop integration | | Sources live only in the session (#169 open) | Students and researchers | 6–10 | [target-app-parity.md](target-app-parity.md) |
| 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; eight older RTL bug reports to re-verify after #207 (#215, #211, #199, #66, #63, #48, #19). RTL tables and sections landed (#362) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) |
@@ -64,6 +64,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | Insert › Chart landed (#409); gap #13 narrowed to editing Word's charts, more types and SmartArt, 35–55 h |
| 2026-10-11 | trivial | Line Numbers, Hyphenation, Language, Envelopes and Labels dialogs landed (#407); gap #7 at 30 dialogs, 16–24 h; duplicated #7 rows merged |
| 2026-10-11 | trivial | Caption, Index, Mark Index Entry, Custom Table of Contents, Source Manager and Create/Edit Source dialogs landed (#402); removed from the dialog-depth list |
| 2026-10-11 | trivial | Document grid landed (#391); removed from the East Asian gap |
+3 -3
View File
@@ -2,14 +2,14 @@
Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.
**436 of 454 catalog features have commands (96.0%).**
**442 of 459 catalog features have commands (96.3%).**
| Tab | Live | Total |
|---|---|---|
| File | 20 | 20 |
| Quick Access | 4 | 4 |
| Home | 101 | 102 |
| Insert | 45 | 53 |
| Insert | 46 | 53 |
| Draw | 10 | 11 |
| Design | 10 | 10 |
| Layout | 21 | 21 |
@@ -22,6 +22,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
| Equation | 21 | 22 |
| Picture Format | 14 | 14 |
| Shape Format | 7 | 7 |
| Chart Design | 5 | 5 |
| Header & Footer | 5 | 5 |
| Editing | 38 | 38 |
| Tools | 5 | 6 |
@@ -37,7 +38,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
- Illustrations › Online Pictures (`insert.onlinePicture`)
- Illustrations › 3D Models (`insert.model3d`)
- Illustrations › SmartArt (`insert.smartArt`)
- Illustrations › Chart (`insert.chart`)
- Illustrations › Screenshot (`insert.screenshot`)
- Add-ins › Get Add-ins (`addins.get`)
- Media › Online Video (`insert.video`)
+3 -2
View File
@@ -13,7 +13,7 @@ The generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md).
| Number | Value | Kind |
|---|---|---|
| Ribbon/menu catalog coverage | **436 / 454 (96.0%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) |
| Ribbon/menu catalog coverage | **442 / 459 (96.3%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) |
| Feature breadth (weighted, beyond the ribbon: dialog options, citation styles, proofing languages, chart types, UI languages) | **~80%** | estimated |
| Feature depth (weighted by use, table below) | **~68%** | estimated |
| **Ready for real work** (full target) | **~60%** (range 55–63%) | estimated: weighted sum over the dimension table ([By dimension](#by-dimension)) |
@@ -70,7 +70,7 @@ full parity for the area.
| Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list, CSV (#247) or a sheet of an Excel (.xlsx/.xlsm) or OpenDocument (.ods) workbook, with Select Table to pick the sheet (#334); no Outlook/Contacts/Access data sources, no email merge. |
| Pictures, shapes, text boxes, WordArt | 6 | 60% | 60% | 20–30 | Insert, crop, recolour, styles, floating placement like Word (#136), wrap (square/top-bottom/behind/front), text boxes edit and overflow (#46), VML text boxes (#242), free rotation and flips with a rotation handle (#332), text-box text direction (90°, 270°, stacked), Align Text and linked text boxes (#369), Icons: an original icon library drawn in code with a searchable picker, inserted as pictures and recoloured with Icon Color (#309), Drawing Canvas with Fit/Expand/Scale and DOCX `wpc:wpc` (#344; members edited by command, not yet dragged inside the canvas; the canvas rotates and flips as a whole). Missing: tight/through contour wrap, text in a freely rotated text box, group (#267 open), shape effects, WordArt, connectors, online pictures, screenshot. |
| Equations | 1 | 75% | 20% (with Draw) | 4–8 | OMML read/write, OpenType MATH layout, in-place editor with an Equation tab (#191). Missing: line breaking of long display equations, ink equations. |
| Charts, SmartArt, Draw/ink, 3D models, icons | 4 | 3% | (20% with equations) | 70–110 | Icons landed (#309; an original library drawn in code). Draw: pens, pencil, highlighter, stroke eraser and Hide Ink (#307), ink saved as DrawingML freeforms; Lasso Select (move, recolour, delete), Add Pen gallery, Ink to Shape (line, rectangle, ellipse, triangle, polygon) and Ink Replay (#395); Ink to Math, pressure and Word's own InkML missing; charts and SmartArt are dropped on DOCX read (no `c:chart`/`dgm` handling in `crates/docx`). |
| Charts, SmartArt, Draw/ink, 3D models, icons | 4 | 3% | (20% with equations) | 70–110 | Icons landed (#309; an original library drawn in code). Draw: pens, pencil, highlighter, stroke eraser and Hide Ink (#307), ink saved as DrawingML freeforms; Lasso Select (move, recolour, delete), Add Pen gallery, Ink to Shape (line, rectangle, ellipse, triangle, polygon) and Ink Replay (#395); Ink to Math, pressure and Word's own InkML missing; charts: Insert › Chart makes column, bar (clustered and stacked), line, area, pie, doughnut and scatter charts with Chart Design (change type, Edit Data grid, title, legend, data labels) and saves them as real chart parts (#409); charts from Word are drawn (#292) and kept on save (#319) but stay read-only; SmartArt is drawn and kept, not editable. |
| View (modes, zoom, panes, windows) | 3 | 72% | 70% | 6–10 | Print/web/draft/read/focus, dark page (#194), pages side by side when zoomed out (#251), navigation pane. Missing: View Side by Side, Synchronous Scrolling, Arrange All, Switch Windows, Outline view depth, Immersive Reader depth. |
| Backstage, printing, options | 3 | 58% | 55% | 12–20 | New/Home pages (#254), save prompts (#151), web printing via the browser (#209). Desktop printing still goes through PDF; Options has a fraction of Word's panes. |
| Right-to-left and complex scripts | 1 | 50% | 55% | 15–25 | UAX #9, Arabic shaping, RTL paragraphs (#207). No RTL sections/tables, kashida justification or mirrored UI; open bug reports (#215, #211, #199, #66, #63, #48, #19) need re-testing against #207. |
@@ -249,6 +249,7 @@ can be used without committing Word output.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | Insert › Chart (#409): charts row updated, catalog 408/439; no percentage change |
| 2026-10-11 | trivial | Accessibility 35% → 50%: Alt Text pane for every object and tables, decorative flag, deeper checker with an Accessibility pane (#386); weight 1, headline unchanged |
| 2026-10-11 | trivial | References: Caption, Index, Mark Entry, Custom Table of Contents and Create/Edit Source dialogs, with the field switches they write honoured on update (#402); no percentage change |
| 2026-10-11 | trivial | Move tracking (#400): Review row updated; no percentage change |