mirror of
https://github.com/aphrody-labs/wordcraft.git
synced 2026-10-13 19:26:53 +00:00
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:
1 parent
1a0db9ec65
commit
fd014ea9a1
39 files changed
+2200
-34
No files matched your search
+3
-1
@@ -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 |
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,6 +1,6 @@
|
||||
//! DOCX → [`Document`].
|
||||
|
||||
mod chart;
|
||||
pub(crate) mod chart;
|
||||
mod diagram;
|
||||
mod drawing_color;
|
||||
mod embed;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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)]);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user