diff --git a/crates/doc/src/graphic.rs b/crates/doc/src/graphic.rs new file mode 100644 index 0000000000..a2bfae1953 --- /dev/null +++ b/crates/doc/src/graphic.rs @@ -0,0 +1,95 @@ +//! Charts and SmartArt diagrams: drawings shown but not edited, kept as neutral items ready to draw. + +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. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Graphic { + pub kind: GraphicKind, + /// What to draw, in points from the object's top-left corner. + #[serde(default)] + pub items: Vec, + /// The size (points) the items were built for. Drawn scaled to the object's rectangle when + /// that differs (layout shrinks oversized inline objects); 0 means the rectangle's own size. + #[serde(default)] + pub w: f32, + #[serde(default)] + pub h: f32, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum GraphicKind { + #[default] + Chart, + Diagram, +} + +impl GraphicKind { + /// What the graphic is, in words: the alt text of one that has none of its own. + pub fn noun(self) -> &'static str { + match self { + GraphicKind::Chart => "chart", + GraphicKind::Diagram => "diagram", + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum GraphicItem { + /// A preset shape filling `rect` ([x, y, w, h]). + Shape { + rect: [f32; 4], + kind: ShapeKind, + fill: Option, + stroke: Option, + #[serde(default)] + stroke_width: f32, + }, + /// A path: polygons, pie slices, line series. + Path { + segs: Vec, + fill: Option, + stroke: Option, + #[serde(default)] + stroke_width: f32, + }, + /// One line of text in `rect`, aligned horizontally by `align`, centred vertically. + Text { + rect: [f32; 4], + text: String, + size: f32, + color: Rgb, + #[serde(default)] + bold: bool, + #[serde(default)] + align: TextAlign, + #[serde(default)] + font: Option, + }, + /// A picture (`Document::media` key) stretched into `rect`. + Image { rect: [f32; 4], media: String }, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TextAlign { + Left, + #[default] + Center, + Right, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PathSeg { + Move(f32, f32), + Line(f32, f32), + Cubic(f32, f32, f32, f32, f32, f32), + Close, +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 2935794ca1..409475e03f 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -14,6 +14,7 @@ pub mod bidi; pub mod edit; pub mod encoding; pub mod fields; +pub mod graphic; pub mod math; pub mod math_edit; pub mod math_latex; diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 10129085d8..04bd5a800a 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -200,6 +200,16 @@ pub enum InlineObject { #[serde(default)] crop: [f32; 4], }, + /// A chart or SmartArt diagram (see [`crate::graphic`]). + Graphic { + w: f32, + h: f32, + #[serde(default)] + alt: String, + #[serde(default)] + float: Float, + graphic: std::sync::Arc, + }, Shape { kind: ShapeKind, w: f32, @@ -288,7 +298,7 @@ impl InlineObject { } pub fn is_floating(&self) -> bool { match self { - InlineObject::Image { float, .. } | InlineObject::Shape { float, .. } => float.wrap != Wrap::Inline, + InlineObject::Image { float, .. } | InlineObject::Graphic { float, .. } | InlineObject::Shape { float, .. } => float.wrap != Wrap::Inline, _ => false, } } diff --git a/crates/docx/src/package.rs b/crates/docx/src/package.rs index 1ea07b35a3..6cfe5de38c 100644 --- a/crates/docx/src/package.rs +++ b/crates/docx/src/package.rs @@ -32,6 +32,9 @@ pub mod rt { pub const IMAGE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"; pub const HYPERLINK: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"; pub const VBA_PROJECT: &str = "http://schemas.microsoft.com/office/2006/relationships/vbaProject"; + pub const CHART: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart"; + pub const DIAGRAM_DATA: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramData"; + pub const DIAGRAM_DRAWING: &str = "http://schemas.microsoft.com/office/2007/relationships/diagramDrawing"; } /// `Document::passthrough` key for a macro project, kept as opaque bytes (never parsed or run). diff --git a/crates/docx/src/read/chart.rs b/crates/docx/src/read/chart.rs new file mode 100644 index 0000000000..004949f6a6 --- /dev/null +++ b/crates/docx/src/read/chart.rs @@ -0,0 +1,1911 @@ +//! 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. + +use std::f64::consts::{FRAC_PI_2, TAU}; + +use wordcraft_doc::graphic::{GraphicItem, PathSeg, TextAlign}; +use wordcraft_doc::para::ShapeKind; +use wordcraft_doc::props::Rgb; + +use super::drawing_color::{fill_colour, slot}; +use crate::units::{int, measure}; +use crate::xml::El; + +/// Most series read from one chart. +const MAX_SERIES: usize = 256; +/// Most points read from one series. +const MAX_POINTS: usize = 10_000; +/// Most points read from one chart, all series together. +const MAX_CELLS: usize = 100_000; +/// Longest series name or category label kept. +const MAX_LABEL: usize = 200; +/// Most axis ticks drawn. +const MAX_TICKS: usize = 20; +/// Default text size, points. +const FONT: f32 = 9.0; +/// Average character width estimate, em (charts and diagrams measure text before shaping). +pub(super) const CHAR_W: f32 = 0.55; +/// Widest share of the chart a legend entry's text takes. +const LEGEND_SHARE: f32 = 0.4; +/// Most rows of a legend above or below the plot. +const MAX_LEGEND_ROWS: usize = 3; +const TITLE_SIZE: f32 = 14.0; +/// Space between the frame and the content, points. +const PAD: f32 = 6.0; +/// Line height as a multiple of the font size. +const LINE: f32 = 1.3; +/// Gap between a label and its axis, points. +const GAP: f32 = 4.0; +/// Default width of a line series, points (Word's 2.25 pt). +const LINE_W: f32 = 2.25; +const TEXT: Rgb = Rgb(0x59, 0x59, 0x59); +const GRID: Rgb = Rgb(0xD9, 0xD9, 0xD9); +const AXIS: Rgb = Rgb(0xBF, 0xBF, 0xBF); + +/// Items for a chart part, sized `w` x `h` points. Empty when nothing supported is in it. +pub(crate) fn chart_items(space: &El, theme: &[Rgb], w: f32, h: f32) -> Vec { + let mut out = Vec::new(); + if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) { + return out; + } + let Some(chart) = Chart::parse(space, theme) else { return out }; + backdrop(&mut out, space, theme, w, h); + let mut area = Rect { x: PAD, y: PAD, w: (w - 2.0 * PAD).max(1.0), h: (h - 2.0 * PAD).max(1.0) }; + if let Some(t) = &chart.title { + let lh = TITLE_SIZE * LINE; + text(&mut out, Rect { x: area.x, y: area.y, w: area.w, h: lh }, t, TITLE_SIZE, TextAlign::Center, true); + area.y += lh + PAD; + area.h = (area.h - lh - PAD).max(1.0); + } + if let Some(pos) = chart.legend { + // Word lists bar series in the order they are drawn: reversed for clustered horizontal bars + // and for stacked columns with a vertical legend. + let vertical = matches!(pos, Pos::Left | Pos::Right); + let reverse = chart.kind == Kind::Bar && if chart.horizontal { !chart.stacked() } else { chart.stacked() && vertical }; + let mut entries = chart.legend_entries(); + if reverse { + entries.reverse(); + } + area = legend(&mut out, &entries, pos, area, chart.text_size); + } + match chart.kind { + Kind::Pie | Kind::Doughnut => pie(&mut out, &chart, area), + _ => cartesian(&mut out, &chart, area), + } + out +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + Bar, + Line, + Area, + Pie, + Doughnut, + Scatter, +} + +impl Kind { + fn of(name: &str) -> Option { + Some(match name { + "c:barChart" => Kind::Bar, + "c:lineChart" => Kind::Line, + "c:areaChart" => Kind::Area, + "c:pieChart" => Kind::Pie, + "c:doughnutChart" => Kind::Doughnut, + "c:scatterChart" => Kind::Scatter, + _ => return None, + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Grouping { + Clustered, + Stacked, + Percent, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Pos { + Right, + Left, + Top, + Bottom, +} + +/// One value axis (or category axis) as the file states it. +#[derive(Default)] +struct Axis { + deleted: bool, + grid: bool, + line: bool, + /// `c:crossBetween val="midCat"`: categories sit on the tick marks, not between them. + mid: bool, + min: Option, + max: Option, + /// Number format of the tick labels, when not linked to the source data. + fmt: Option, +} + +struct Series { + name: String, + color: Option, + line_w: f32, + /// Whether the series is drawn as a line (scatter series without one get markers). + line: bool, + values: Vec>, + /// Scatter x values (index + 1 when the file has none). + xs: Vec>, + /// Per-point colours from `c:dPt`. + points: Vec>, + fmt: Option, + /// Format code of the scatter x values' cache. + x_fmt: Option, +} + +struct Chart { + kind: Kind, + horizontal: bool, + grouping: Grouping, + /// Gap between groups as a fraction of a bar's width. + gap: f32, + /// Overlap of clustered bars, -1..1 (1 = all on top of each other). + overlap: f32, + /// Doughnut hole, fraction of the outer radius. + hole: f32, + first_angle: f64, + vary: bool, + /// Number of categories (scatter: points per series at most). + n: usize, + cats: Vec, + series: Vec, + accents: Vec, + title: Option, + legend: Option, + /// Category axis (scatter: the x axis). + cat: Axis, + val: Axis, + text_size: f32, + val_fmt: String, + /// Serial days count from 1904 (`c:date1904`), not 1900. + date1904: bool, + /// Outline of the plot area, when the file draws one. + plot_border: Option, +} + +impl Chart { + fn parse(space: &El, theme: &[Rgb]) -> Option { + let chart = space.child("c:chart")?; + let plot = chart.child("c:plotArea")?; + let ty = plot.els().find(|e| Kind::of(&e.name).is_some())?; + let kind = Kind::of(&ty.name)?; + let scatter = kind == Kind::Scatter; + let date1904 = space.child("c:date1904").is_some_and(|d| d.attr("val").is_none_or(truthy)); + let mut series = read_series(ty, scatter, theme); + if series.is_empty() { + return None; + } + let cats = ty.children("c:ser").find_map(|s| s.child("c:cat")).map(|c| labels(Some(c), MAX_POINTS, date1904)).unwrap_or_default(); + let n = if matches!(kind, Kind::Pie | Kind::Doughnut) { + series.first().map_or(0, |s| s.values.len()).max(cats.len()) + } else { + series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(cats.len()) + }; + // Every series is padded to `n` points, so keep only as many as the cell budget holds. + if let Some(keep) = MAX_CELLS.checked_div(n) { + series.truncate(keep.max(1)); + } + let grouping = match ty.child("c:grouping").and_then(|g| g.attr("val")) { + Some("stacked") if matches!(kind, Kind::Bar | Kind::Area | Kind::Line) => Grouping::Stacked, + Some("percentStacked") if matches!(kind, Kind::Bar | Kind::Area | Kind::Line) => Grouping::Percent, + _ => Grouping::Clustered, + }; + let mut cats = cats; + cats.resize(n, String::new()); + for s in &mut series { + s.values.resize(n, None); + s.xs.resize(n, None); + } + if grouping == Grouping::Percent { + to_percent(&mut series, n); + } + let (cat, val) = chart_axes(plot, scatter); + let val_fmt = if grouping == Grouping::Percent { + "0%".to_string() + } else { + val.fmt.clone().or_else(|| series.iter().find_map(|s| s.fmt.clone())).unwrap_or_else(|| "General".to_string()) + }; + let num = |n: &str| ty.child(n).and_then(|g| g.attr("val")).and_then(int); + Some(Chart { + kind, + horizontal: kind == Kind::Bar && ty.child("c:barDir").and_then(|d| d.attr("val")) == Some("bar"), + grouping, + gap: num("c:gapWidth").unwrap_or(150).clamp(0, 500) as f32 / 100.0, + overlap: num("c:overlap").unwrap_or(0).clamp(-100, 100) as f32 / 100.0, + hole: num("c:holeSize").unwrap_or(50).clamp(10, 90) as f32 / 100.0, + first_angle: num("c:firstSliceAng").unwrap_or(0).clamp(0, 360) as f64, + vary: matches!(kind, Kind::Pie | Kind::Doughnut) && ty.child("c:varyColors").and_then(|v| v.attr("val")).is_none_or(truthy), + n, + cats, + title: title_of(chart, &series), + series, + accents: (4..10).map(|i| slot(theme, i)).collect(), + legend: legend_pos(chart), + cat, + val, + text_size: text_size(space), + val_fmt, + date1904, + plot_border: plot.child("c:spPr").and_then(|p| p.child("a:ln")).and_then(|l| fill_colour(l, theme)), + }) + } + + fn stacked(&self) -> bool { + self.grouping != Grouping::Clustered + } + + fn accent(&self, i: usize) -> Rgb { + self.accents.get(i % 6).copied().unwrap_or(Rgb::BLACK) + } + + fn series_colour(&self, j: usize) -> Rgb { + self.series.get(j).and_then(|s| s.color).unwrap_or_else(|| self.accent(j)) + } + + /// Colour of point `i` of series `j`: its `c:dPt` override, else the varied accent (pie) or + /// the series colour. + fn point_colour(&self, j: usize, i: usize) -> Rgb { + if let Some(Some(c)) = self.series.get(j).and_then(|s| s.points.get(i)) { + return *c; + } + if self.vary { self.accent(i) } else { self.series_colour(j) } + } + + fn legend_entries(&self) -> Vec<(String, Rgb)> { + if matches!(self.kind, Kind::Pie | Kind::Doughnut) { + return self + .cats + .iter() + .enumerate() + .map(|(i, c)| (if c.is_empty() { (i + 1).to_string() } else { c.clone() }, self.point_colour(0, i))) + .collect(); + } + self.series + .iter() + .enumerate() + .map(|(j, s)| (if s.name.is_empty() { format!("Series {}", j + 1) } else { s.name.clone() }, self.series_colour(j))) + .collect() + } +} + +/// The series of a chart type element, within the chart's cell budget. +fn read_series(ty: &El, scatter: bool, theme: &[Rgb]) -> Vec { + let mut budget = MAX_CELLS; + let mut series = Vec::new(); + for ser in ty.children("c:ser").take(MAX_SERIES) { + let s = Series::parse(ser, scatter, theme, budget.min(MAX_POINTS)); + budget = budget.saturating_sub(s.values.len().max(s.xs.len())); + series.push(s); + } + series +} + +/// Values as their share of each category's total (100% stacked charts). +fn to_percent(series: &mut [Series], n: usize) { + for i in 0..n { + let total: f64 = series.iter().map(|s| s.values.get(i).copied().flatten().map_or(0.0, f64::abs)).sum(); + for s in series.iter_mut() { + if let Some(Some(v)) = s.values.get_mut(i) { + *v = if total > 0.0 { *v / total } else { 0.0 }; + } + } + } +} + +/// The category axis (scatter: the horizontal value axis) and the value axis of a plot area. +fn chart_axes(plot: &El, scatter: bool) -> (Axis, Axis) { + let vals_ax: Vec<&El> = plot.children("c:valAx").collect(); + let horiz_pos = |a: &&El| matches!(a.child("c:axPos").and_then(|p| p.attr("val")), Some("b" | "t")); + let (val_el, x_el) = if scatter { + let x = vals_ax.iter().copied().find(horiz_pos).or_else(|| vals_ax.first().copied()); + let y = vals_ax.iter().copied().find(|a| !horiz_pos(a)).or_else(|| vals_ax.iter().copied().find(|a| Some(*a) != x)); + (y, x) + } else { + (vals_ax.first().copied(), None) + }; + let cat_el = plot.els().find(|e| matches!(e.name.as_str(), "c:catAx" | "c:dateAx")); + (axis(if scatter { x_el } else { cat_el }), axis(val_el)) +} + +/// The chart's title. An empty `c:title` shows the series name (Word's automatic title of a +/// one-series chart) unless the automatic title is deleted. +fn title_of(chart: &El, series: &[Series]) -> Option { + let auto_deleted = chart.child("c:autoTitleDeleted").and_then(|a| a.attr("val")).is_some_and(truthy); + let auto = if !auto_deleted && series.len() == 1 { series.first().map(|s| s.name.clone()).filter(|s| !s.is_empty()) } else { None }; + chart.child("c:title").and_then(|t| title_text(t).or(auto)) +} + +fn legend_pos(chart: &El) -> Option { + chart.child("c:legend").map(|l| match l.child("c:legendPos").and_then(|p| p.attr("val")).unwrap_or("r") { + "l" => Pos::Left, + "t" | "tr" => Pos::Top, + "b" => Pos::Bottom, + _ => Pos::Right, + }) +} + +/// The chart's text size (its `c:txPr` default run size), points. +fn text_size(space: &El) -> f32 { + space + .child("c:txPr") + .and_then(|t| t.find("a:defRPr")) + .and_then(|d| d.attr("sz")) + .and_then(int) + .map_or(FONT, |v| (v as f32 / 100.0).clamp(6.0, 24.0)) +} + +impl Series { + fn parse(ser: &El, scatter: bool, theme: &[Rgb], cap: usize) -> Series { + let name = ser.child("c:tx").and_then(|t| t.find("c:v")).map(|v| label(v.text().trim())).unwrap_or_default(); + let sp = ser.child("c:spPr"); + let ln = sp.and_then(|p| p.child("a:ln")); + let marker = ser.child("c:marker").and_then(|m| m.child("c:spPr")).and_then(|p| fill_colour(p, theme)); + let color = sp.and_then(|p| fill_colour(p, theme)).or_else(|| ln.and_then(|l| fill_colour(l, theme))).or(marker); + let line_w = ln.and_then(|l| l.attr("w")).and_then(|v| measure(v, 12_700.0)).map_or(LINE_W, |w| w.clamp(0.0, 20.0)); + let line = ln.is_none_or(|l| l.child("a:noFill").is_none()); + let (values, fmt) = numbers(ser.child(if scatter { "c:yVal" } else { "c:val" }), cap); + let (mut xs, x_fmt) = if scatter { numbers(ser.child("c:xVal"), cap) } else { (Vec::new(), None) }; + if scatter && xs.is_empty() { + xs = (1..=values.len()).map(|i| Some(i as f64)).collect(); + } + let mut points = vec![None; values.len()]; + for dpt in ser.children("c:dPt") { + let i = dpt.child("c:idx").and_then(|e| e.attr("val")).and_then(int).and_then(|i| usize::try_from(i).ok()); + let c = dpt.child("c:spPr").and_then(|p| fill_colour(p, theme)); + if let Some(slot) = i.and_then(|i| points.get_mut(i)) { + *slot = c; + } + } + Series { name, color, line_w, line, values, xs, points, fmt, x_fmt } + } +} + +/// Category labels of a `c:cat` (string or number cache), capped. Numbers show in the cache's +/// format code (dates included); text and `General` numbers as they are stored. +fn labels(el: Option<&El>, cap: usize, date1904: bool) -> Vec { + let Some(el) = el else { return Vec::new() }; + let Some(cache) = el.find("c:strCache").or_else(|| el.find("c:numCache")).or_else(|| el.find("c:strLit")).or_else(|| el.find("c:numLit")) else { + return Vec::new(); + }; + let code = cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty() && !f.eq_ignore_ascii_case("General")); + pts(cache, cap) + .into_iter() + .map(|p| { + let text = p.unwrap_or_default(); + match (code.as_deref(), text.trim().parse::()) { + (Some(code), Ok(v)) if v.is_finite() => label(&category_text(v, code, 1.0, date1904)), + _ => label(&text), + } + }) + .collect() +} + +/// A number in its format code: a date or time for date codes (serial days, from 1904 when +/// `date1904`), else as [`format_value`] with tick step `step`. +fn category_text(v: f64, code: &str, step: f64, date1904: bool) -> String { + match excel_stamp(v, date1904) { + Some(t) if is_date_code(code) => date_text(t, code), + _ => format_value(v, code, step), + } +} + +/// Values of a `c:val`-style element (number cache or literal), capped; with its format code. +fn numbers(el: Option<&El>, cap: usize) -> (Vec>, Option) { + let Some(cache) = el.and_then(|e| e.find("c:numCache").or_else(|| e.find("c:numLit"))) else { + return (Vec::new(), None); + }; + let fmt = cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty()); + let vals = pts(cache, cap).into_iter().map(|p| p.and_then(|t| t.trim().parse::().ok()).filter(|v| v.is_finite())).collect(); + (vals, fmt) +} + +/// The `c:pt` texts of a cache by index. Indexes and counts from the file are capped. +fn pts(cache: &El, cap: usize) -> Vec> { + let declared = cache.child("c:ptCount").and_then(|c| c.attr("val")).and_then(int).unwrap_or(0).clamp(0, cap as i64) as usize; + let mut out: Vec> = vec![None; declared]; + for p in cache.children("c:pt") { + let Some(i) = p.attr("idx").and_then(int).and_then(|i| usize::try_from(i).ok()) else { continue }; + if i >= cap { + continue; + } + if out.len() <= i { + out.resize(i + 1, None); + } + if let Some(slot) = out.get_mut(i) { + *slot = p.child("c:v").map(|v| v.text()); + } + } + out +} + +fn axis(a: Option<&El>) -> Axis { + let Some(a) = a else { return Axis { deleted: true, ..Default::default() } }; + let scaling = a.child("c:scaling"); + let num = + |n: &str| scaling.and_then(|s| s.child(n)).and_then(|e| e.attr("val")).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()); + Axis { + deleted: a.child("c:delete").and_then(|d| d.attr("val")).is_some_and(truthy), + grid: a.child("c:majorGridlines").is_some(), + line: a.child("c:spPr").and_then(|p| p.child("a:ln")).and_then(|l| l.child("a:noFill")).is_none(), + mid: a.child("c:crossBetween").and_then(|c| c.attr("val")) == Some("midCat"), + min: num("c:min"), + max: num("c:max"), + fmt: a.child("c:numFmt").filter(|f| f.attr("sourceLinked") != Some("1")).and_then(|f| f.attr("formatCode")).map(str::to_string), + } +} + +fn title_text(t: &El) -> Option { + let tx = t.child("c:tx")?; + let s = match tx.child("c:rich") { + Some(rich) => { + let mut s = String::new(); + rich_into(rich, &mut s); + s + } + None => tx.find("c:v").map(|v| v.text()).unwrap_or_default(), + }; + let s = label(s.trim()); + (!s.is_empty()).then_some(s) +} + +/// The `a:t` runs under an element, in order. +fn rich_into(e: &El, s: &mut String) { + for c in e.els() { + if c.name == "a:t" { + s.push_str(&c.text()); + } else { + rich_into(c, s); + } + } +} + +fn truthy(v: &str) -> bool { + matches!(v.trim(), "1" | "true") +} + +fn label(s: &str) -> String { + s.chars().take(MAX_LABEL).collect() +} + +/// Chart area: the chart space's fill and border (white and borderless by default). +fn backdrop(out: &mut Vec, space: &El, theme: &[Rgb], w: f32, h: f32) { + let sp = space.child("c:spPr"); + let fill = match sp { + Some(p) if p.child("a:noFill").is_some() => None, + Some(p) => fill_colour(p, theme).or(Some(Rgb::WHITE)), + None => Some(Rgb::WHITE), + }; + let ln = sp.and_then(|p| p.child("a:ln")); + let stroke = ln.and_then(|l| fill_colour(l, theme)); + let stroke_width = ln.and_then(|l| l.attr("w")).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.75).clamp(0.0, 20.0); + if fill.is_some() || stroke.is_some() { + out.push(GraphicItem::Shape { rect: [0.0, 0.0, w, h], kind: ShapeKind::Rectangle, fill, stroke, stroke_width }); + } +} + +/// An axis scale: `lo` to `hi` in steps of `step`. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Scale { + lo: f64, + hi: f64, + step: f64, +} + +impl Scale { + fn ticks(&self) -> Vec { + let n = (((self.hi - self.lo) / self.step + 1e-9).floor().max(0.0) as usize).min(MAX_TICKS); + (0..=n).map(|k| self.lo + k as f64 * self.step).filter(|t| *t <= self.hi + 1e-9).collect() + } +} + +/// A scale for data `dlo..dhi` (zero included when `zero`), as Excel and Word pick it: an end +/// away from zero sits 5% of the data span beyond the data (an end at zero stays there), rounded +/// out to a step of 1, 2, 2.5, 5 or 10 times a power of ten (about seven steps). `min`/`max` +/// from the file replace the ends. +fn nice_scale(dlo: f64, dhi: f64, zero: bool, min: Option, max: Option) -> Scale { + let (dlo, dhi) = if dhi > dlo { (dlo, dhi) } else { (dlo, dlo + 1.0) }; + let span = dhi - dlo; + // With zero, the ends reach it: all-negative data still starts its bars at zero. + let (ends_lo, ends_hi) = if zero { (dlo.min(0.0), dhi.max(0.0)) } else { (dlo, dhi) }; + let lo = min.unwrap_or(if ends_lo < 0.0 { ends_lo - 0.05 * span } else { ends_lo }); + let hi = max.unwrap_or(if ends_hi > 0.0 { ends_hi + 0.05 * span } else { ends_hi }); + if !(hi > lo && hi.is_finite() && lo.is_finite()) { + return Scale { lo: 0.0, hi: 1.0, step: 0.2 }; + } + let raw = (hi - lo) / 7.5; + let mag = 10f64.powf(raw.log10().floor()); + let step = [1.0, 2.0, 2.5, 5.0, 10.0].iter().map(|m| m * mag).find(|s| *s >= raw).unwrap_or(10.0 * mag); + let lo_r = if min.is_some() { lo } else { ((lo / step) + 1e-9).floor() * step }; + let hi_r = if max.is_some() { hi } else { ((hi / step) - 1e-9).ceil() * step }; + if !(step.is_finite() && step > 0.0 && lo_r.is_finite() && hi_r.is_finite() && hi_r > lo_r) { + return Scale { lo: 0.0, hi: 1.0, step: 0.2 }; + } + Scale { lo: lo_r, hi: hi_r, step } +} + +/// A piece of a format section: literal text, a number character (`#`, `0`, `,`, `.`), or the +/// exponent of a scientific format (`E+` when true, `E-` when false). +enum Tok { + Lit(String), + Num(char), + Exp(bool), +} + +/// The sections of a format code (positive; negative; zero; text), split outside quotes. +fn sections(code: &str) -> Vec<&str> { + let mut out = Vec::new(); + let (mut start, mut quoted, mut escaped) = (0, false, false); + for (i, c) in code.char_indices() { + match c { + _ if escaped => escaped = false, + '\\' if !quoted => escaped = true, + '"' => quoted = !quoted, + ';' if !quoted => { + out.push(code.get(start..i).unwrap_or("")); + start = i + 1; + } + _ => {} + } + } + out.push(code.get(start..).unwrap_or("")); + out +} + +/// The first section of a format code. +fn first_section(code: &str) -> &str { + sections(code).first().copied().unwrap_or("") +} + +/// The pieces of one format section. Quoted text, escapes and `[$€-407]` currency symbols are +/// literal; other brackets (`[Red]`) are dropped; `_x` (room for `x`) is a space; `*x` is dropped. +fn format_toks(section: &str) -> Vec { + let mut toks = Vec::new(); + let mut chars = section.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '"' => toks.push(Tok::Lit(chars.by_ref().take_while(|q| *q != '"').collect())), + '[' => { + let inner: String = chars.by_ref().take_while(|q| *q != ']').collect(); + if let Some(sym) = currency(&inner) { + toks.push(Tok::Lit(sym.to_string())); + } + } + '\\' => toks.extend(chars.next().map(|n| Tok::Lit(n.to_string()))), + '_' => { + chars.next(); + toks.push(Tok::Lit(" ".to_string())); + } + '*' => { + chars.next(); + } + 'E' | 'e' if matches!(chars.peek(), Some('+' | '-')) => toks.push(Tok::Exp(chars.next() == Some('+'))), + '#' | '0' | ',' | '.' => toks.push(Tok::Num(c)), + c => toks.push(Tok::Lit(c.to_string())), + } + } + toks +} + +/// The symbol of a `$€-407` bracket content (`[$€-407]`), if it is one. +fn currency(inner: &str) -> Option<&str> { + inner.strip_prefix('$').map(|rest| rest.split('-').next().unwrap_or("")) +} + +/// Tick text for `v` in a number format code (`#,##0`, `0.0`, `0%`, `0.00E+00`, and literal text +/// such as `"€"0.00` or `#,##0.00 [$€-407]`). A negative number takes the second section when +/// there is one, which shows it without a minus (`#,##0;(#,##0)`). `General` shows as many +/// decimals as the step needs. +fn format_value(v: f64, code: &str, step: f64) -> String { + let v = if v.abs() < step * 1e-9 { 0.0 } else { v }; + let secs = sections(code); + match secs.get(1) { + Some(neg) if v < 0.0 => format_section(-v, neg, step), + _ => format_section(v, secs.first().copied().unwrap_or(""), step), + } +} + +/// `v` in one section of a format code. +fn format_section(v: f64, section: &str, step: f64) -> String { + let toks = format_toks(section); + let is_num = |t: &Tok| matches!(t, Tok::Num(_) | Tok::Exp(_)); + let lit = |r: &[Tok]| -> String { + r.iter() + .filter_map(|t| match t { + Tok::Lit(l) => Some(l.as_str()), + _ => None, + }) + .collect() + }; + let (Some(s), Some(e)) = (toks.iter().position(is_num), toks.iter().rposition(is_num)) else { return general(v, step) }; + let body = toks.get(s..=e).unwrap_or(&[]); + let (pre, suf) = (lit(toks.get(..s).unwrap_or(&[])), lit(toks.get(e + 1..).unwrap_or(&[]))); + let digits = |r: &[Tok]| -> String { r.iter().filter_map(|t| if let Tok::Num(c) = t { Some(*c) } else { None }).collect() }; + if let Some(k) = body.iter().position(|t| matches!(t, Tok::Exp(_))) { + let plus = matches!(body.get(k), Some(Tok::Exp(true))); + let mantissa = digits(body.get(..k).unwrap_or(&[])); + let exp_digits = digits(body.get(k + 1..).unwrap_or(&[])).len(); + return format!("{pre}{}{suf}", scientific(v, decimals(&mantissa), exp_digits, plus)); + } + let body = digits(body); + let pct = pre.contains('%') || suf.contains('%'); + let v = if pct { v * 100.0 } else { v }; + let digits = format!("{:.*}", decimals(&body), v.abs()); + let (ip, fp) = match digits.split_once('.') { + Some((a, b)) => (a.to_string(), format!(".{b}")), + None => (digits.clone(), String::new()), + }; + let mut grouped = String::new(); + for (k, ch) in ip.chars().enumerate() { + if body.contains(',') && k > 0 && (ip.len() - k) % 3 == 0 { + grouped.push(','); + } + grouped.push(ch); + } + let sign = if v < 0.0 && digits.chars().any(|c| c != '0' && c != '.') { "-" } else { "" }; + format!("{sign}{pre}{grouped}{fp}{suf}") +} + +/// Decimal places of a number body (`#,##0.00` has 2). +fn decimals(body: &str) -> usize { + body.split_once('.').map_or(0, |(_, d)| d.chars().filter(|c| matches!(c, '0' | '#')).count()).min(12) +} + +/// `v` with no number format (`General`): as many decimals as the tick step needs. +fn general(v: f64, step: f64) -> String { + let mut d = 0; + while d < 6 && (step * 10f64.powi(d)).fract().abs() > 1e-9 { + d += 1; + } + let s = format!("{:.*}", d as usize, v); + if s.trim_start_matches(['-', '0', '.']).is_empty() { s.trim_start_matches('-').to_string() } else { s } +} + +/// `v` in scientific notation: `decimals` mantissa decimals, at least `exp_digits` exponent +/// digits, and a `+` on positive exponents when `plus` (`0.00E+00`: 1.23E+04). +fn scientific(v: f64, decimals: usize, exp_digits: usize, plus: bool) -> String { + let s = format!("{:.*e}", decimals, v.abs()); + let (mantissa, exp) = s.split_once('e').unwrap_or((s.as_str(), "0")); + let exp: i32 = if v == 0.0 { 0 } else { exp.parse().unwrap_or(0) }; + let sign = if exp < 0 { + "-" + } else if plus { + "+" + } else { + "" + }; + let minus = if v < 0.0 { "-" } else { "" }; + format!("{minus}{mantissa}E{sign}{:0w$}", exp.unsigned_abs(), w = exp_digits.clamp(1, 4)) +} + +/// A moment as Excel shows a serial day number. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Stamp { + y: i64, + m: u32, + d: u32, + /// Day of the week, 0 = Sunday. + weekday: u32, + /// Seconds into the day. + secs: u32, +} + +/// The moment of Excel serial day `serial` as Excel shows it, from 0 ("January 0, 1900") to +/// 31 December 9999. In the 1900 system serial 60 is Excel's phantom 29 February 1900, and +/// serials before it count from 31 December 1899; in the 1904 system (`date1904`) serial 0 is +/// 1 January 1904. `None` outside that range. +fn excel_stamp(serial: f64, date1904: bool) -> Option { + let serial = if date1904 { serial + 1462.0 } else { serial }; + if !(0.0..2_958_466.0).contains(&serial) { + return None; + } + let whole = serial.floor(); + let secs = (((serial - whole) * 86_400.0).round() as u32).min(86_399); + let day = whole as i64; + let (y, m, d) = match day { + 0 => (1900, 1, 0), + 60 => (1900, 2, 29), + 1..=59 => civil(day + 1), + _ => civil(day), + }; + // Excel's weekdays count serial 1 as a Sunday. + Some(Stamp { y, m, d, weekday: (day - 1).rem_euclid(7) as u32, secs }) +} + +/// Calendar date (year, month, day) of day `n` counted from 30 December 1899 (Howard Hinnant's +/// civil-from-days algorithm). +fn civil(n: i64) -> (i64, u32, u32) { + let z = n - 25_569 + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32; + (yoe + era * 400 + i64::from(m <= 2), m, d) +} + +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; +const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + +/// Whether a format code (outside its quoted and bracketed parts) has date or time fields. +fn is_date_code(code: &str) -> bool { + format_toks_raw(code).any(|c| matches!(c.to_ascii_lowercase(), 'y' | 'd' | 'm' | 'h' | 's')) +} + +/// The characters of a format code's first section outside quotes and brackets. +fn format_toks_raw(code: &str) -> impl Iterator + '_ { + let mut quoted = false; + let mut bracket = false; + first_section(code).chars().filter(move |c| match c { + '"' => { + quoted = !quoted; + false + } + '[' if !quoted => { + bracket = true; + false + } + ']' if bracket => { + bracket = false; + false + } + _ => !quoted && !bracket, + }) +} + +/// A piece of a date format: literal text, a field (`y`, `m`, `d`, `h`, `s`, or `n` for minutes) +/// with its length, or AM/PM (`AM/PM` when true, `A/P` when false). +enum DTok { + Lit(String), + Field(char, usize), + AmPm(bool), +} + +/// The pieces of a date format's first section. `m` or `mm` right after an hour or right before +/// a seconds field is minutes. +fn date_toks(code: &str) -> Vec { + let section: Vec = first_section(code).chars().collect(); + let mut toks = Vec::new(); + let mut i = 0; + while let Some(&c) = section.get(i) { + let at = i; + i += 1; + match c { + '"' => { + let lit: String = section.get(i..).unwrap_or(&[]).iter().take_while(|q| **q != '"').collect(); + i += lit.chars().count() + 1; + toks.push(DTok::Lit(lit)); + } + '[' => { + let inner: String = section.get(i..).unwrap_or(&[]).iter().take_while(|q| **q != ']').collect(); + i += inner.chars().count() + 1; + toks.extend(currency(&inner).map(|s| DTok::Lit(s.to_string()))); + } + '\\' | '_' | '*' => { + let next = section.get(i).copied(); + i += 1; + match (c, next) { + ('\\', Some(n)) => toks.push(DTok::Lit(n.to_string())), + ('_', _) => toks.push(DTok::Lit(" ".to_string())), + _ => {} + } + } + 'a' | 'A' if starts_ci(§ion, at, "AM/PM") => { + i += 4; + toks.push(DTok::AmPm(true)); + } + 'a' | 'A' if starts_ci(§ion, at, "A/P") => { + i += 2; + toks.push(DTok::AmPm(false)); + } + _ if matches!(c.to_ascii_lowercase(), 'y' | 'm' | 'd' | 'h' | 's') => { + let mut n = 1; + while section.get(i).is_some_and(|q| q.eq_ignore_ascii_case(&c)) { + i += 1; + n += 1; + } + toks.push(DTok::Field(c.to_ascii_lowercase(), n)); + } + c => toks.push(DTok::Lit(c.to_string())), + } + } + mark_minutes(&mut toks); + toks +} + +/// Whether `chars` from `at` on start with `pat`, ignoring ASCII case. +fn starts_ci(chars: &[char], at: usize, pat: &str) -> bool { + pat.chars().enumerate().all(|(k, p)| chars.get(at + k).is_some_and(|c| c.eq_ignore_ascii_case(&p))) +} + +/// Turns the `m`/`mm` fields that mean minutes (after an hour, or before seconds) into `n`. +fn mark_minutes(toks: &mut [DTok]) { + let fields: Vec = toks.iter().enumerate().filter(|(_, t)| matches!(t, DTok::Field(..))).map(|(k, _)| k).collect(); + let kind = |k: Option<&usize>, toks: &[DTok]| match k.and_then(|k| toks.get(*k)) { + Some(DTok::Field(c, _)) => Some(*c), + _ => None, + }; + for (j, k) in fields.iter().enumerate() { + let after_hour = j.checked_sub(1).and_then(|p| kind(fields.get(p), toks)) == Some('h'); + let before_secs = kind(fields.get(j + 1), toks) == Some('s'); + if let Some(DTok::Field(c @ 'm', n)) = toks.get_mut(*k) + && *n <= 2 + && (after_hour || before_secs) + { + *c = 'n'; + } + } +} + +/// A moment in a date format: day, month, year, weekday (`ddd`, `dddd`), hour, minute and +/// second fields replaced (12-hour with AM/PM); quoted text and currency kept, other brackets +/// dropped. +fn date_text(t: Stamp, code: &str) -> String { + let toks = date_toks(code); + let twelve = toks.iter().any(|k| matches!(k, DTok::AmPm(_))); + let hour = t.secs / 3600; + let mut out = String::new(); + for tok in &toks { + match tok { + DTok::Lit(s) => out.push_str(s), + DTok::AmPm(long) => out.push_str(match (hour < 12, long) { + (true, true) => "AM", + (false, true) => "PM", + (true, false) => "A", + (false, false) => "P", + }), + DTok::Field(c, n) => out.push_str(&field_text(t, *c, *n, twelve)), + } + } + out +} + +/// One date or time field (`c` as in [`DTok::Field`], `n` letters long). +fn field_text(t: Stamp, c: char, n: usize, twelve: bool) -> String { + let month = MONTHS.get((t.m as usize).clamp(1, 12) - 1).copied().unwrap_or(""); + let weekday = WEEKDAYS.get(t.weekday as usize).copied().unwrap_or(""); + let pad = |v: u32| if n >= 2 { format!("{v:02}") } else { v.to_string() }; + let hour = t.secs / 3600; + match c { + 'y' if n <= 2 => format!("{:02}", t.y.rem_euclid(100)), + 'y' => format!("{:04}", t.y), + 'd' if n <= 2 => pad(t.d), + 'd' if n == 3 => weekday.get(..3).unwrap_or(weekday).to_string(), + 'd' => weekday.to_string(), + 'm' if n <= 2 => pad(t.m), + 'm' if n == 3 => month.get(..3).unwrap_or(month).to_string(), + 'm' if n == 4 => month.to_string(), + 'm' => month.get(..1).unwrap_or(month).to_string(), + 'h' if twelve => pad(if hour.is_multiple_of(12) { 12 } else { hour % 12 }), + 'h' => pad(hour), + 'n' => pad(t.secs / 60 % 60), + _ => pad(t.secs % 60), + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct Rect { + x: f32, + y: f32, + w: f32, + h: f32, +} + +impl Rect { + fn right(self) -> f32 { + self.x + self.w + } + fn bottom(self) -> f32 { + self.y + self.h + } + fn arr(self) -> [f32; 4] { + [self.x, self.y, self.w, self.h] + } +} + +/// Estimated text width, points: text is placed before it's shaped, at [`CHAR_W`] a character. +pub(super) fn text_w(s: &str, size: f32) -> f32 { + s.chars().count() as f32 * size * CHAR_W +} + +/// `s`, cut short with an ellipsis to fit `width` points by the [`text_w`] estimate. +fn clip_text(s: &str, width: f32, size: f32) -> String { + let fit = (width / (size * CHAR_W)).floor().max(0.0) as usize; + if s.chars().count() <= fit { + return s.to_string(); + } + let mut out: String = s.chars().take(fit.saturating_sub(1)).collect(); + out.push('…'); + out +} + +fn widest(items: &[String], size: f32) -> f32 { + items.iter().map(|s| text_w(s, size)).fold(0.0, f32::max) +} + +fn text(out: &mut Vec, rect: Rect, s: &str, size: f32, align: TextAlign, bold: bool) { + out.push(GraphicItem::Text { rect: rect.arr(), text: s.to_string(), size, color: TEXT, bold, align, font: None }); +} + +/// Text centred on `cx` with its top at `y`. +fn text_at(out: &mut Vec, cx: f32, y: f32, s: &str, size: f32) { + let w = text_w(s, size) + 4.0; + text(out, Rect { x: cx - w / 2.0, y, w, h: size * LINE }, s, size, TextAlign::Center, false); +} + +fn fill(out: &mut Vec, segs: Vec, color: Rgb) { + if !segs.is_empty() { + out.push(GraphicItem::Path { segs, fill: Some(color), stroke: None, stroke_width: 0.0 }); + } +} + +fn stroke(out: &mut Vec, segs: Vec, color: Rgb, width: f32) { + if !segs.is_empty() { + out.push(GraphicItem::Path { segs, fill: None, stroke: Some(color), stroke_width: width }); + } +} + +fn swatch(out: &mut Vec, x: f32, y: f32, color: Rgb) { + out.push(GraphicItem::Shape { rect: [x, y, SWATCH, SWATCH], kind: ShapeKind::Rectangle, fill: Some(color), stroke: None, stroke_width: 0.0 }); +} + +fn seg(x0: f32, y0: f32, x1: f32, y1: f32) -> Vec { + vec![PathSeg::Move(x0, y0), PathSeg::Line(x1, y1)] +} + +fn rect_segs(x0: f32, y0: f32, x1: f32, y1: f32) -> Vec { + vec![PathSeg::Move(x0, y0), PathSeg::Line(x1, y0), PathSeg::Line(x1, y1), PathSeg::Line(x0, y1), PathSeg::Close] +} + +/// Polyline pieces: a gap (`None`) starts a new sub-path. +fn runs(pts: impl Iterator>) -> Vec { + let mut segs = Vec::new(); + let mut open = false; + for p in pts { + match p { + Some((x, y)) => { + segs.push(if open { PathSeg::Line(x, y) } else { PathSeg::Move(x, y) }); + open = true; + } + None => open = false, + } + } + segs +} + +/// Words of `s` in lines of at most `width` points (a word wider than that keeps its own line). +fn wrap(s: &str, width: f32, size: f32) -> Vec { + let mut lines: Vec = Vec::new(); + let mut cur = String::new(); + for word in s.split_whitespace() { + let joined = if cur.is_empty() { word.to_string() } else { format!("{cur} {word}") }; + if cur.is_empty() || text_w(&joined, size) <= width { + cur = joined; + } else { + lines.push(std::mem::take(&mut cur)); + cur = word.to_string(); + } + } + if !cur.is_empty() { + lines.push(cur); + } + if lines.is_empty() { + lines.push(String::new()); + } + lines.truncate(3); + lines +} + +const SWATCH: f32 = 7.0; +/// Space between legend entries in a row, points. +const SEP: f32 = 10.0; + +/// Legend in the given side of `area`; returns the area left for the plot. +fn legend(out: &mut Vec, entries: &[(String, Rgb)], pos: Pos, area: Rect, fs: f32) -> Rect { + if entries.is_empty() { + return area; + } + // Entry texts are cut to a share of the chart, so a long name can't push the legend out. + let entries: Vec<(String, Rgb)> = entries.iter().map(|(s, c)| (clip_text(s, area.w * LEGEND_SHARE, fs), *c)).collect(); + match pos { + Pos::Right | Pos::Left => side_legend(out, &entries, pos == Pos::Left, area, fs), + Pos::Top | Pos::Bottom => row_legend(out, &entries, pos == Pos::Top, area, fs), + } +} + +/// A legend in a column at the left or right of `area`, keeping only the entries that fit above +/// its bottom; returns the area left for the plot. +fn side_legend(out: &mut Vec, entries: &[(String, Rgb)], left: bool, area: Rect, fs: f32) -> Rect { + let lh = fs * LINE; + let entries = entries.get(..((area.h / lh).floor().max(0.0) as usize).min(entries.len())).unwrap_or(&[]); + if entries.is_empty() { + return area; + } + let tw = entries.iter().map(|(s, _)| text_w(s, fs)).fold(0.0, f32::max); + let block_w = SWATCH + GAP + tw; + let block_h = lh * entries.len() as f32; + let x = if left { area.x } else { area.right() - block_w }; + let y0 = area.y + ((area.h - block_h) / 2.0).max(0.0); + for (k, (s, c)) in entries.iter().enumerate() { + let y = y0 + lh * k as f32; + swatch(out, x, y + (lh - SWATCH) / 2.0, *c); + text(out, Rect { x: x + SWATCH + GAP, y, w: tw + 2.0, h: lh }, s, fs, TextAlign::Left, false); + } + let used = block_w + PAD; + let mut area = area; + area.w = (area.w - used).max(1.0); + if left { + area.x += used; + } + area +} + +/// A legend in centred rows the width of `area` above or below it (at most +/// [`MAX_LEGEND_ROWS`], and half its height); returns the area left for the plot. +fn row_legend(out: &mut Vec, entries: &[(String, Rgb)], top: bool, area: Rect, fs: f32) -> Rect { + let lh = fs * LINE; + let max_rows = MAX_LEGEND_ROWS.min((area.h / 2.0 / lh).floor().max(1.0) as usize); + let rows = legend_rows(entries, area.w, fs, max_rows); + let y0 = if top { area.y } else { area.bottom() - lh * rows.len() as f32 }; + for (r, row) in rows.iter().enumerate() { + let total = row.iter().map(|(_, _, w)| SWATCH + GAP + w).sum::() + SEP * row.len().saturating_sub(1) as f32; + let mut x = area.x + ((area.w - total) / 2.0).max(0.0); + let y = y0 + lh * r as f32; + for (s, c, w) in row { + swatch(out, x, y + (lh - SWATCH) / 2.0, *c); + text(out, Rect { x: x + SWATCH + GAP, y, w: w + 2.0, h: lh }, s, fs, TextAlign::Left, false); + x += SWATCH + GAP + w + SEP; + } + } + let used = lh * rows.len() as f32 + PAD; + let mut area = area; + area.h = (area.h - used).max(1.0); + if top { + area.y += used; + } + area +} + +/// Legend entries (text, colour, text width) packed into at most `max_rows` rows of `width` +/// points; the entries past the last row are left out. +fn legend_rows(entries: &[(String, Rgb)], width: f32, fs: f32, max_rows: usize) -> Vec> { + let mut rows: Vec> = Vec::new(); + let mut row_w = 0.0; + for (s, c) in entries { + let w = text_w(s, fs); + let entry_w = SWATCH + GAP + w; + let fits = !rows.is_empty() && row_w + SEP + entry_w <= width; + if fits { + row_w += SEP + entry_w; + } else if rows.len() < max_rows { + rows.push(Vec::new()); + row_w = entry_w; + } else { + break; + } + if let Some(row) = rows.last_mut() { + row.push((s.clone(), *c, w)); + } + } + rows +} + +/// The plot rectangle and the scales that map values and categories onto it. +struct Frame { + plot: Rect, + horiz: bool, + n: usize, + vs: Scale, + xs: Option, + mid: bool, +} + +impl Frame { + /// Width of one category band. + fn band(&self) -> f32 { + let len = if self.horiz { self.plot.h } else { self.plot.w }; + len / self.n.max(1) as f32 + } + + /// Start of category `i`'s band: its left edge, or for bars its top edge (category 0 at the + /// bottom). + fn band_start(&self, i: usize) -> f32 { + let k = i as f32; + if self.horiz { self.plot.bottom() - (k + 1.0) * self.band() } else { self.plot.x + k * self.band() } + } + + /// A value's position along the value direction: y for columns, x for bars. + fn val(&self, v: f64) -> f32 { + let t = ((v - self.vs.lo) / (self.vs.hi - self.vs.lo)).clamp(0.0, 1.0) as f32; + if self.horiz { self.plot.x + t * self.plot.w } else { self.plot.bottom() - t * self.plot.h } + } + + /// x of category `i` for line and area points. + fn cat_x(&self, i: usize) -> f32 { + if !self.mid { + return self.plot.x + (i as f32 + 0.5) * self.band(); + } + if self.n <= 1 { self.plot.x + self.plot.w / 2.0 } else { self.plot.x + self.plot.w * i as f32 / (self.n - 1) as f32 } + } + + /// x of a scatter point. + fn scat_x(&self, x: f64) -> f32 { + match self.xs { + Some(s) => self.plot.x + ((x - s.lo) / (s.hi - s.lo)).clamp(0.0, 1.0) as f32 * self.plot.w, + None => self.plot.x, + } + } +} + +/// The value scale and, for scatter charts, the x scale, with their ticks and tick texts. +struct Axes { + vs: Scale, + vticks: Vec<(f64, String)>, + xs: Option, + xticks: Vec<(f64, String)>, +} + +impl Axes { + fn of(c: &Chart) -> Axes { + let (dlo, dhi) = extent(c); + let (vmin, vmax) = if c.grouping == Grouping::Percent { (Some(0.0), Some(1.0)) } else { (c.val.min, c.val.max) }; + // Lines and scatters don't need zero for a narrow range (1000..1050 stays on its own scale). + let zero = !matches!(c.kind, Kind::Line | Kind::Scatter) || !narrow(dlo, dhi); + let vs = nice_scale(dlo, dhi, zero, vmin, vmax); + let vticks = vs.ticks().into_iter().map(|t| (t, format_value(t, &c.val_fmt, vs.step))).collect(); + let xs = (c.kind == Kind::Scatter).then(|| { + let (xl, xh) = x_extent(c); + nice_scale(xl, xh, false, c.cat.min, c.cat.max) + }); + // Scatter x values show in the axis's own format, else their cache's (dates included). + let xfmt = c.cat.fmt.clone().or_else(|| c.series.iter().find_map(|s| s.x_fmt.clone())).unwrap_or_else(|| "General".to_string()); + let xticks = xs.map(|s| s.ticks().into_iter().map(|t| (t, category_text(t, &xfmt, s.step, c.date1904))).collect()).unwrap_or_default(); + Axes { vs, vticks, xs, xticks } + } +} + +/// The room around the plot for axis labels, and the category labels as they'll be drawn. +struct Margins { + left: f32, + top: f32, + bottom: f32, + /// Category labels in lines (none for scatter charts or a deleted category axis). + wrapped: Vec>, + /// Every `skip`-th category label is shown under a column chart. + skip: usize, +} + +/// The margins the axis labels need around the plot in `area`. Category labels under a column +/// chart wrap to the band; labels that still don't fit show every k-th category. +fn axis_margins(c: &Chart, ax: &Axes, area: Rect) -> Margins { + let fs = c.text_size; + let lh = fs * LINE; + let scatter = c.kind == Kind::Scatter; + let (cat_on, val_on) = (!c.cat.deleted, !c.val.deleted); + let left = if c.horizontal { + if cat_on { widest(&c.cats, fs).min(area.w * 0.4) + GAP } else { 0.0 } + } else if val_on { + ax.vticks.iter().map(|(_, s)| text_w(s, fs)).fold(0.0, f32::max) + GAP + } else { + 0.0 + }; + let band_w = (area.w - left).max(1.0) / c.n as f32; + let wrapped: Vec> = if cat_on && !scatter { + let width = if c.horizontal { left - GAP } else { band_w }; + c.cats.iter().map(|s| wrap(s, width, fs)).collect() + } else { + Vec::new() + }; + let widest_line = wrapped.iter().flatten().map(|s| text_w(s, fs)).fold(0.0, f32::max); + let skip = ((widest_line / band_w).ceil() as usize).max(1); + let label_lines = wrapped.iter().map(Vec::len).max().unwrap_or(1) as f32; + let bottom = if cat_on && !c.horizontal { + if scatter { lh + GAP } else { label_lines * lh + GAP } + } else if c.horizontal && val_on { + lh + GAP + } else { + 0.0 + }; + let top = if !c.horizontal && val_on { lh / 2.0 } else { 0.0 }; + Margins { left, top, bottom, wrapped, skip } +} + +/// A bar, column, line, area or scatter chart in `area`: gridlines, series, axes, then labels. +fn cartesian(out: &mut Vec, c: &Chart, area: Rect) { + if c.n == 0 { + return; + } + let ax = Axes::of(c); + let m = axis_margins(c, &ax, area); + let plot = Rect { x: area.x + m.left, y: area.y + m.top, w: area.w - m.left, h: area.h - m.top - m.bottom }; + if plot.w < 1.0 || plot.h < 1.0 { + return; + } + let g = Frame { plot, horiz: c.horizontal, n: c.n, vs: ax.vs, xs: ax.xs, mid: c.val.mid }; + if c.val.grid { + draw_grid(out, &g, &ax.vticks); + } + 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::Pie | Kind::Doughnut => {} + } + draw_axes(out, c, &g); + if !c.val.deleted { + draw_value_labels(out, c, &g, &ax.vticks, Rect { w: m.left, ..area }); + } + if !c.cat.deleted { + draw_category_labels(out, c, &g, &ax.xticks, &m, area); + } +} + +/// Major gridlines at the value ticks. +fn draw_grid(out: &mut Vec, g: &Frame, ticks: &[(f64, String)]) { + let plot = g.plot; + for (t, _) in ticks { + let p = g.val(*t); + let segs = if g.horiz { seg(p, plot.y, p, plot.bottom()) } else { seg(plot.x, p, plot.right(), p) }; + stroke(out, segs, GRID, 0.75); + } +} + +/// The category axis line, the plot area's outline and the value axis line, as the file shows them. +fn draw_axes(out: &mut Vec, c: &Chart, g: &Frame) { + let plot = g.plot; + if !c.cat.deleted && c.cat.line { + let segs = if g.horiz { seg(plot.x, plot.y, plot.x, plot.bottom()) } else { seg(plot.x, plot.bottom(), plot.right(), plot.bottom()) }; + stroke(out, segs, AXIS, 0.75); + } + if let Some(color) = c.plot_border { + stroke(out, rect_segs(plot.x, plot.y, plot.right(), plot.bottom()), color, 0.75); + } + if !c.val.deleted && c.val.line { + let segs = if g.horiz { seg(plot.x, plot.bottom(), plot.right(), plot.bottom()) } else { seg(plot.x, plot.y, plot.x, plot.bottom()) }; + stroke(out, segs, AXIS, 0.75); + } +} + +/// Value tick labels: under the plot for bars, else right-aligned in `margin` (the area's left +/// margin). +fn draw_value_labels(out: &mut Vec, c: &Chart, g: &Frame, ticks: &[(f64, String)], margin: Rect) { + let (fs, lh) = (c.text_size, c.text_size * LINE); + for (t, s) in ticks { + let p = g.val(*t); + if g.horiz { + text_at(out, p, g.plot.bottom() + GAP / 2.0, s, fs); + } else { + text(out, Rect { x: margin.x, y: p - lh / 2.0, w: margin.w - GAP, h: lh }, s, fs, TextAlign::Right, false); + } + } +} + +/// Category labels: scatter x ticks under the plot, bar labels in the left margin, column +/// labels under their bands (every `m.skip`-th). +fn draw_category_labels(out: &mut Vec, c: &Chart, g: &Frame, xticks: &[(f64, String)], m: &Margins, area: Rect) { + let (fs, lh) = (c.text_size, c.text_size * LINE); + let plot = g.plot; + if c.kind == Kind::Scatter { + for (t, s) in xticks { + text_at(out, g.scat_x(*t), plot.bottom() + GAP / 2.0, s, fs); + } + } else if g.horiz { + for (i, lines) in m.wrapped.iter().enumerate() { + let top = g.band_start(i) + g.band() / 2.0 - lines.len() as f32 * lh / 2.0; + for (k, s) in lines.iter().enumerate() { + text(out, Rect { x: area.x, y: top + k as f32 * lh, w: m.left - GAP, h: lh }, s, fs, TextAlign::Right, false); + } + } + } else { + let band = g.band(); + for (i, lines) in m.wrapped.iter().enumerate().filter(|(i, _)| i % m.skip == 0) { + for (k, s) in lines.iter().enumerate() { + let y = plot.bottom() + GAP / 2.0 + k as f32 * lh; + text(out, Rect { x: g.cat_x(i) - band / 2.0, y, w: band, h: lh }, s, fs, TextAlign::Center, false); + } + } + } +} + +/// Whether data `lo..hi` is a narrow band away from zero: its span is under a sixth of the end +/// farther from zero (the mirror image for negative data). +fn narrow(lo: f64, hi: f64) -> bool { + if lo > 0.0 { + (hi - lo) / hi < 1.0 / 6.0 + } else if hi < 0.0 { + (hi - lo) / lo.abs() < 1.0 / 6.0 + } else { + false + } +} + +/// Lowest and highest value of the plot; stacked series add up per category. +fn extent(c: &Chart) -> (f64, f64) { + let mut pts: Vec = Vec::new(); + if c.stacked() { + for i in 0..c.n { + let (mut p, mut q) = (0.0, 0.0); + for s in &c.series { + match s.values.get(i).copied().flatten() { + Some(v) if v >= 0.0 => p += v, + Some(v) => q += v, + None => {} + } + } + pts.push(p); + pts.push(q); + } + } else { + pts.extend(c.series.iter().flat_map(|s| s.values.iter().flatten().copied())); + } + let lo = pts.iter().copied().fold(f64::INFINITY, f64::min); + let hi = pts.iter().copied().fold(f64::NEG_INFINITY, f64::max); + if lo.is_finite() && hi.is_finite() { (lo, hi) } else { (0.0, 0.0) } +} + +/// Range of scatter x values (index + 1 where missing). +fn x_extent(c: &Chart) -> (f64, f64) { + let xs: Vec = c.series.iter().flat_map(|s| s.xs.iter().flatten().copied()).collect(); + let lo = xs.iter().copied().fold(f64::INFINITY, f64::min); + let hi = xs.iter().copied().fold(f64::NEG_INFINITY, f64::max); + if lo.is_finite() && hi.is_finite() { (lo, hi) } else { (0.0, 1.0) } +} + +fn bars(out: &mut Vec, c: &Chart, g: &Frame) { + let stacked = c.stacked(); + let slots = if stacked { 1 } else { c.series.len().max(1) }; + let ov = if stacked { 0.0 } else { c.overlap }; + let ns = slots as f32; + let band = g.band(); + let thick = band / (ns - (ns - 1.0) * ov + c.gap); + let group = thick * (ns - (ns - 1.0) * ov); + let mut pos = vec![0.0f64; g.n]; + let mut neg = vec![0.0f64; g.n]; + for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) { + let slot = if stacked { 0 } else { j }; + let slot = (if g.horiz { slots.saturating_sub(1 + slot) } else { slot }) as f32; + for i in 0..g.n { + let Some(v) = s.values.get(i).copied().flatten() else { continue }; + let (v0, v1) = if !stacked { + (0.0, v) + } else if v >= 0.0 { + let Some(p) = pos.get_mut(i) else { continue }; + let b = *p; + *p += v; + (b, *p) + } else { + let Some(q) = neg.get_mut(i) else { continue }; + let b = *q; + *q += v; + (b, *q) + }; + let a = g.band_start(i) + (band - group) / 2.0 + slot * thick * (1.0 - ov); + let (p0, p1) = (g.val(v0), g.val(v1)); + 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)); + } + } +} + +/// Whether a series has any point to draw. +fn has_values(s: &Series) -> bool { + s.values.iter().any(Option::is_some) +} + +/// Line series; stacked ones are drawn at the running total of the series up to them. +fn lines(out: &mut Vec, c: &Chart, g: &Frame) { + let mut total = vec![0.0f64; g.n]; + for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) { + let pts: Vec> = (0..g.n) + .map(|i| { + let v = s.values.get(i).copied().flatten()?; + let y = match total.get_mut(i) { + Some(t) if c.stacked() => { + *t += v; + *t + } + _ => v, + }; + Some((g.cat_x(i), g.val(y))) + }) + .collect(); + stroke(out, runs(pts.into_iter()), c.series_colour(j), s.line_w); + } +} + +fn areas(out: &mut Vec, c: &Chart, g: &Frame) { + 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)); + let top: Vec = base.iter().zip(vals).map(|(b, v)| b + v).collect(); + let mut segs = Vec::with_capacity(2 * g.n + 1); + for (i, t) in top.iter().enumerate() { + let (x, y) = (g.cat_x(i), g.val(*t)); + segs.push(if i == 0 { PathSeg::Move(x, y) } else { PathSeg::Line(x, y) }); + } + for (i, b) in base.iter().enumerate().rev() { + segs.push(PathSeg::Line(g.cat_x(i), g.val(*b))); + } + segs.push(PathSeg::Close); + fill(out, segs, c.series_colour(j)); + if c.stacked() { + base = top; + } + } +} + +/// Scatter series as lines, or as small markers when the series has no line. +fn draw_scatter(out: &mut Vec, c: &Chart, g: &Frame) { + const MARKER: f32 = 6.0; + for (j, s) in c.series.iter().enumerate() { + let pts: Vec> = + s.xs.iter() + .zip(&s.values) + .map(|(x, y)| match (x, y) { + (Some(x), Some(y)) => Some((g.scat_x(*x), g.val(*y))), + _ => None, + }) + .collect(); + if s.line { + stroke(out, runs(pts.iter().copied()), c.series_colour(j), s.line_w); + continue; + } + for (x, y) in pts.iter().flatten() { + let rect = [x - MARKER / 2.0, y - MARKER / 2.0, MARKER, MARKER]; + out.push(GraphicItem::Shape { rect, kind: ShapeKind::Ellipse, fill: Some(c.series_colour(j)), stroke: None, stroke_width: 0.0 }); + } + } +} + +fn pie(out: &mut Vec, c: &Chart, area: Rect) { + let Some(s) = c.series.first() else { return }; + let vals: Vec = s.values.iter().map(|v| v.unwrap_or(0.0).max(0.0)).collect(); + let total: f64 = vals.iter().sum(); + let r = (area.w.min(area.h) / 2.0 - 2.0) as f64; + if !(total > 0.0 && total.is_finite() && r >= 1.0) { + return; + } + 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(); + for (i, v) in vals.iter().enumerate() { + if *v <= 0.0 { + continue; + } + let b = a + v / total * TAU; + let mut segs = Vec::new(); + if inner > 0.0 { + segs.push(pt(polar(cx, cy, r, a))); + arc(&mut segs, (cx, cy, r), a, b); + segs.push(line_to(polar(cx, cy, inner, b))); + arc(&mut segs, (cx, cy, inner), b, a); + } else { + segs.push(pt((cx, cy))); + segs.push(line_to(polar(cx, cy, r, a))); + arc(&mut segs, (cx, cy, r), a, b); + } + segs.push(PathSeg::Close); + fill(out, segs, c.point_colour(0, i)); + a = b; + } +} + +/// A point at clockwise angle `t` (radians from 12 o'clock) on a circle of radius `r`. +fn polar(cx: f64, cy: f64, r: f64, t: f64) -> (f64, f64) { + (cx + r * t.sin(), cy - r * t.cos()) +} + +fn pt(p: (f64, f64)) -> PathSeg { + PathSeg::Move(p.0 as f32, p.1 as f32) +} + +fn line_to(p: (f64, f64)) -> PathSeg { + PathSeg::Line(p.0 as f32, p.1 as f32) +} + +/// Cubic segments along a circle from angle `a` to `b` (the current point is at `a`). Each piece +/// spans at most a quarter turn. +fn arc(segs: &mut Vec, (cx, cy, r): (f64, f64, f64), a: f64, b: f64) { + let n = (((b - a).abs() / FRAC_PI_2).ceil() as usize).clamp(1, 16); + let d = (b - a) / n as f64; + let k = 4.0 / 3.0 * (d / 4.0).tan(); + for j in 0..n { + let t0 = a + d * j as f64; + let t1 = t0 + d; + let p0 = polar(cx, cy, r, t0); + let p1 = polar(cx, cy, r, t1); + let c1 = (p0.0 + k * r * t0.cos(), p0.1 + k * r * t0.sin()); + let c2 = (p1.0 - k * r * t1.cos(), p1.1 - k * r * t1.sin()); + segs.push(PathSeg::Cubic(c1.0 as f32, c1.1 as f32, c2.0 as f32, c2.1 as f32, p1.0 as f32, p1.1 as f32)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wordcraft_doc::THEME_COLORS; + + const NS: &str = + r#"xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main""#; + + fn space(plot: &str) -> El { + let x = format!(r#"{plot}"#); + crate::xml::parse(x.as_bytes()).unwrap() + } + + fn theme() -> Vec { + THEME_COLORS.to_vec() + } + + fn ser(i: usize, name: &str, color: Option<&str>, cats: &[&str], vals: &[&str]) -> String { + let fill = color.map_or(String::new(), |c| format!(r#""#)); + let cat_pts: String = cats.iter().enumerate().map(|(k, c)| format!(r#"{c}"#)).collect(); + let val_pts: String = vals.iter().enumerate().map(|(k, v)| format!(r#"{v}"#)).collect(); + format!( + r#"{name}{fill}{cat_pts}General{val_pts}"#, + nc = cats.len(), + nv = vals.len() + ) + } + + fn filled(items: &[GraphicItem]) -> Vec<(Rgb, f32, f32)> { + items + .iter() + .filter_map(|it| match it { + GraphicItem::Path { segs, fill: Some(c), .. } => { + let ys: Vec = segs + .iter() + .filter_map(|s| match s { + PathSeg::Move(_, y) | PathSeg::Line(_, y) => Some(*y), + _ => None, + }) + .collect(); + let lo = ys.iter().copied().fold(f32::INFINITY, f32::min); + let hi = ys.iter().copied().fold(f32::NEG_INFINITY, f32::max); + Some((*c, lo, hi)) + } + _ => None, + }) + .collect() + } + + #[test] + fn clustered_columns_draw_one_bar_per_point() { + let plot = format!( + r#"{}{}"#, + ser(0, "North", Some("FF0000"), &["Q1", "Q2", "Q3"], &["4", "8", "2"]), + ser(1, "South", Some("00FF00"), &["Q1", "Q2", "Q3"], &["2", "6", "4"]), + ); + let items = chart_items(&space(&plot), &theme(), 300.0, 200.0); + let bars = filled(&items); + assert_eq!(bars.len(), 6); + let heights: Vec = bars.iter().map(|(_, lo, hi)| hi - lo).collect(); + // Scale 0..8: North Q2 is the full plot height, North Q1 half of it. + assert!((heights[1] / heights[0] - 2.0).abs() < 1e-3, "{heights:?}"); + // South (second series) Q1 is 2 of 8, Q2 is 6 of 8. + assert!((heights[3] / heights[1] - 0.25).abs() < 1e-3, "{heights:?}"); + assert!((heights[4] / heights[1] - 0.75).abs() < 1e-3, "{heights:?}"); + assert_eq!(bars[0].0, Rgb(0xFF, 0, 0)); + assert_eq!(bars[3].0, Rgb(0, 0xFF, 0)); + assert!(items.iter().any(|it| matches!(it, GraphicItem::Text { text, .. } if text == "Q2"))); + } + + #[test] + fn pie_draws_one_slice_per_point() { + let plot = + format!(r#"{}"#, ser(0, "Share", None, &["A", "B", "C", "D"], &["50", "25", "15", "10"])); + let items = chart_items(&space(&plot), &theme(), 200.0, 150.0); + let slices = filled(&items); + assert_eq!(slices.len(), 4); + assert_eq!(slices[0].0, theme()[4], "first slice is accent1"); + } + + #[test] + fn doughnut_slices_are_annuli() { + let plot = format!( + r#"{}"#, + ser(0, "S", None, &["A", "B"], &["1", "3"]) + ); + let items = chart_items(&space(&plot), &theme(), 200.0, 150.0); + assert_eq!(items.iter().filter(|it| matches!(it, GraphicItem::Path { fill: Some(_), .. })).count(), 2); + } + + #[test] + fn nice_scale_rounds_to_round_steps() { + assert_eq!(nice_scale(0.0, 22.3, true, None, None), Scale { lo: 0.0, hi: 25.0, step: 5.0 }); + assert_eq!(nice_scale(0.0, 100.0, true, None, None), Scale { lo: 0.0, hi: 120.0, step: 20.0 }); + assert_eq!(nice_scale(0.0, 22.3, true, Some(0.0), Some(30.0)).hi, 30.0); + let flat = nice_scale(0.0, 0.0, true, None, None); + assert!(flat.lo == 0.0 && (flat.hi - 1.2).abs() < 1e-9 && flat.step == 0.2, "{flat:?}"); + } + + #[test] + fn number_formats() { + assert_eq!(format_value(1234567.0, "#,##0", 1.0), "1,234,567"); + assert_eq!(format_value(-1500.0, "#,##0", 500.0), "-1,500"); + assert_eq!(format_value(0.25, "0%", 0.05), "25%"); + assert_eq!(format_value(0.5, "General", 0.25), "0.50"); + assert_eq!(format_value(10.0, "\"€\"0.00", 5.0), "€10.00"); + } + + #[test] + fn hostile_values_never_panic() { + let bodies = [ + format!( + r#"{}"#, + r#"xab1e400NaN-1e30851e3081e308"# + ), + r#"-1e3081e30812"#.to_string(), + r#"-41e308"#.to_string(), + ]; + for body in &bodies { + let s = space(body); + for (w, h) in [(0.0, 10.0), (f32::NAN, 10.0), (-1.0, 1.0), (1e30, 1e30), (50.0, 40.0)] { + let _ = chart_items(&s, &theme(), w, h); + } + } + assert!(chart_items(&space(&bodies[0]), &theme(), f32::NAN, 10.0).is_empty()); + assert!(chart_items(&El::default(), &theme(), 100.0, 100.0).is_empty()); + } + + #[test] + fn unsupported_chart_types_draw_nothing() { + let plot = r#"1"#; + assert!(chart_items(&space(plot), &theme(), 100.0, 100.0).is_empty()); + } + + /// Chart items for a chart with a legend at `pos` (`r`, `b`…). + fn with_legend(plot: &str, pos: &str, w: f32, h: f32) -> Vec { + let x = format!( + r#"{plot}"# + ); + chart_items(&crate::xml::parse(x.as_bytes()).unwrap(), &theme(), w, h) + } + + #[test] + fn series_are_capped_to_the_cell_budget() { + // 256 series each declaring 10,000 points: only the 10 that fit the cell budget are kept. + let sers: String = (0..256) + .map(|i| format!(r#"1"#)) + .collect(); + let plot = format!(r#"{sers}"#); + let items = chart_items(&space(&plot), &theme(), 300.0, 200.0); + let strokes = items.iter().filter(|it| matches!(it, GraphicItem::Path { fill: None, stroke: Some(_), .. })).count(); + assert_eq!(strokes, MAX_CELLS / 10_000); + } + + #[test] + fn series_without_values_draw_nothing() { + let plot = format!( + r#"{}{}"#, + ser(0, "A", Some("FF0000"), &["Q1", "Q2"], &["4", "8"]), + ser(1, "Empty", Some("00FF00"), &["Q1", "Q2"], &[]), + ); + let fills = filled(&chart_items(&space(&plot), &theme(), 300.0, 200.0)); + assert_eq!(fills.len(), 1); + assert_eq!(fills[0].0, Rgb(0xFF, 0, 0)); + } + + #[test] + fn value_scale_starts_bars_at_zero_for_negative_data() { + // All-negative data pads below only: the axis ends at zero, as positive data's starts there. + assert_eq!(nice_scale(-10.0, -2.0, true, None, None), Scale { lo: -12.0, hi: 0.0, step: 2.0 }); + assert_eq!(nice_scale(-1050.0, -1000.0, false, None, None).hi, -1000.0); + } + + #[test] + fn narrow_line_scales_leave_zero_out_but_bars_keep_it() { + assert!(narrow(1000.0, 1050.0) && narrow(-1050.0, -1000.0)); + assert!(!narrow(10.0, 100.0) && !narrow(0.0, 1.0)); + assert_eq!(nice_scale(1000.0, 1050.0, false, None, None).lo, 1000.0); + assert_eq!(nice_scale(1000.0, 1050.0, true, None, None).lo, 0.0); + } + + #[test] + fn horizontal_bar_labels_wrap_in_a_column_of_forty_percent() { + let long = "A category label that is much too long for its column"; + let plot = format!( + r#"{}"#, + ser(0, "S", Some("FF0000"), &[long, "Short"], &["4", "8"]), + ); + let items = chart_items(&space(&plot), &theme(), 300.0, 200.0); + let labels: Vec<(&str, f32)> = items + .iter() + .filter_map(|it| match it { + GraphicItem::Text { rect, text, align: TextAlign::Right, .. } => Some((text.as_str(), rect[2])), + _ => None, + }) + .collect(); + assert!(labels.iter().all(|(_, w)| *w <= 0.4 * 300.0), "{labels:?}"); + assert!(labels.iter().filter(|(t, _)| long.contains(t)).count() > 1, "wrapped: {labels:?}"); + } + + #[test] + fn stacked_lines_add_up_like_areas() { + let stacked = format!( + r#"{}{}"#, + ser(0, "A", Some("FF0000"), &["Q1", "Q2"], &["0", "10"]), + ser(1, "B", Some("00FF00"), &["Q1", "Q2"], &["0", "5"]), + ); + let single = format!( + r#"{}"#, + ser(0, "T", Some("0000FF"), &["Q1", "Q2"], &["0", "15"]) + ); + // Where the last point of the line drawn in `colour` is. + let end_y = |items: &[GraphicItem], colour: Rgb| { + items.iter().find_map(|it| match it { + GraphicItem::Path { segs, stroke: Some(c), .. } if *c == colour => match segs.last() { + Some(PathSeg::Line(_, y)) => Some(*y), + _ => None, + }, + _ => None, + }) + }; + let top = end_y(&chart_items(&space(&stacked), &theme(), 300.0, 200.0), Rgb(0, 0xFF, 0)).expect("stacked line"); + let total = end_y(&chart_items(&space(&single), &theme(), 300.0, 200.0), Rgb(0, 0, 0xFF)).expect("single line"); + assert!((top - total).abs() < 1e-3, "stacked {top} vs {total}"); + } + + #[test] + fn category_numbers_show_in_their_format() { + // Excel serial days: 45292 is 1 January 2024, 45323 is 1 February 2024. + let cat = |code: &str| -> Vec { + let x = format!( + r#"{code}4529245323"# + ); + labels(Some(&crate::xml::parse(x.as_bytes()).unwrap()), 10, false) + }; + assert_eq!(cat("m/d/yyyy"), ["1/1/2024", "2/1/2024"]); + assert_eq!(cat("d/m/yyyy"), ["1/1/2024", "1/2/2024"]); + assert_eq!(cat("mmm-yy"), ["Jan-24", "Feb-24"]); + assert_eq!(cat("yyyy"), ["2024", "2024"]); + assert_eq!(cat("0.00"), ["45292.00", "45323.00"]); + let ymd = |v: f64| excel_stamp(v, false).map(|t| (t.y, t.m, t.d)); + assert_eq!(ymd(61.0), Some((1900, 3, 1))); + assert_eq!(ymd(45292.0), Some((2024, 1, 1))); + } + + #[test] + fn number_formats_keep_literal_text_and_currency() { + assert_eq!(format_value(1234.5, "#,##0.00 [$€-407]", 0.25), "1,234.50 €"); + assert_eq!(format_value(5.0, "[Red]0", 1.0), "5"); + assert_eq!(format_value(3.0, "\"Items: \"0", 1.0), "Items: 3"); + assert_eq!(format_value(3.0, "0\" units\"", 1.0), "3 units"); + } + + #[test] + fn side_legend_keeps_only_the_entries_that_fit() { + let sers: String = (0..10).map(|i| ser(i, &format!("S{i}"), Some("FF0000"), &["Q1"], &["1"])).collect(); + let plot = format!( + r#"{sers}"# + ); + let items = with_legend(&plot, "r", 300.0, 60.0); + let swatches = items.iter().filter(|it| matches!(it, GraphicItem::Shape { rect, .. } if rect[2] == 7.0)).count(); + assert!(swatches > 0 && swatches < 10, "{swatches} swatches"); + let bottom = + items.iter().filter_map(|it| if let GraphicItem::Text { rect, .. } = it { Some(rect[1] + rect[3]) } else { None }).fold(0.0, f32::max); + assert!(bottom <= 60.0, "legend text passes the chart's bottom: {bottom}"); + } + + /// The swatches of a legend: their (x, y). + fn swatches(items: &[GraphicItem]) -> Vec<(f32, f32)> { + items + .iter() + .filter_map(|it| if let GraphicItem::Shape { rect, .. } = it { (rect[2] == SWATCH).then_some((rect[0], rect[1])) } else { None }) + .collect() + } + + /// A column chart of one category with a series per name. + fn columns(names: &[&str]) -> String { + let sers: String = names.iter().enumerate().map(|(i, n)| ser(i, n, Some("FF0000"), &["Q1"], &["1"])).collect(); + format!( + r#"{sers}"# + ) + } + + #[test] + fn bottom_legend_wraps_into_rows_inside_the_chart() { + let names: Vec = (0..12).map(|i| format!("Region number {i}")).collect(); + let names: Vec<&str> = names.iter().map(String::as_str).collect(); + let items = with_legend(&columns(&names), "b", 200.0, 200.0); + let sw = swatches(&items); + let rows: std::collections::BTreeSet = sw.iter().map(|(_, y)| *y as i32).collect(); + assert!(rows.len() > 1 && rows.len() <= MAX_LEGEND_ROWS, "rows {rows:?}"); + assert!(sw.iter().all(|(x, _)| *x >= 0.0 && *x <= 200.0), "{sw:?}"); + for it in &items { + if let GraphicItem::Text { rect, text, .. } = it { + assert!(rect[0] >= 0.0 && rect[0] + text_w(text, FONT) <= 200.0 + 1e-3, "{text:?} at {rect:?}"); + } + } + } + + #[test] + fn long_legend_names_are_cut_to_a_share_of_the_chart() { + let long = "A very long series name that would push the legend past the left edge of the chart"; + let items = with_legend(&columns(&[long, "Short"]), "r", 200.0, 150.0); + assert!(swatches(&items).iter().all(|(x, _)| *x >= 0.0), "{:?}", swatches(&items)); + let cut = items.iter().find_map(|it| match it { + GraphicItem::Text { text, rect, .. } if text.ends_with('…') => Some((text.clone(), rect[2])), + _ => None, + }); + let (text, w) = cut.expect("the long name is cut"); + assert!(text_w(&text, FONT) <= 200.0 * LEGEND_SHARE && w <= 200.0 * LEGEND_SHARE + 2.0, "{text:?} {w}"); + } + + #[test] + fn scatter_dates_show_as_dates() { + let pts = |vals: &[&str]| -> String { vals.iter().enumerate().map(|(k, v)| format!(r#"{v}"#)).collect() }; + let plot = format!( + r#"m/d/yyyy{}General{}"#, + pts(&["45292", "45323", "45352"]), + pts(&["1", "2", "3"]) + ); + let items = chart_items(&space(&plot), &theme(), 300.0, 200.0); + let texts: Vec<&str> = + items.iter().filter_map(|it| if let GraphicItem::Text { text, .. } = it { Some(text.as_str()) } else { None }).collect(); + assert!(texts.iter().any(|t| t.ends_with("/2024")), "{texts:?}"); + assert!(!texts.iter().any(|t| t.starts_with("45")), "serials shown: {texts:?}"); + } + + #[test] + fn date1904_counts_from_1904() { + assert_eq!(category_text(0.0, "m/d/yyyy", 1.0, true), "1/1/1904"); + assert_eq!(category_text(1462.0, "m/d/yyyy", 1.0, false), "1/1/1904"); + let x = format!( + r#"yyyy-mm-dd3661"# + ); + let chart = Chart::parse(&crate::xml::parse(x.as_bytes()).unwrap(), &theme()).expect("chart"); + assert_eq!(chart.cats, ["1905-01-01"]); + } + + #[test] + fn date_codes_show_times_weekdays_and_early_serials() { + let t = |v: f64, code: &str| category_text(v, code, 1.0, false); + // 45292 is Monday 1 January 2024; 13:05:09 into it. + let at = 45292.0 + (13.0 * 3600.0 + 5.0 * 60.0 + 9.0) / 86_400.0; + assert_eq!(t(at, "hh:mm:ss"), "13:05:09"); + assert_eq!(t(at, "h:mm AM/PM"), "1:05 PM"); + assert_eq!(t(at, "m:ss"), "5:09"); + assert_eq!(t(45292.0, "h:mm a/p"), "12:00 A"); + assert_eq!(t(at, "mm/dd"), "01/01"); + assert_eq!(t(45292.0, "dddd, mmmm d"), "Monday, January 1"); + assert_eq!(t(45292.0, "ddd d mmm"), "Mon 1 Jan"); + // Serials before March 1900, Excel's phantom 29 February included. + assert_eq!(t(0.0, "m/d/yyyy"), "1/0/1900"); + assert_eq!(t(1.0, "m/d/yyyy"), "1/1/1900"); + assert_eq!(t(59.0, "m/d/yyyy"), "2/28/1900"); + assert_eq!(t(60.0, "m/d/yyyy"), "2/29/1900"); + assert_eq!(t(61.0, "m/d/yyyy dddd"), "3/1/1900 Thursday"); + // Outside the calendar: a number. + assert_eq!(t(-1.0, "m/d/yyyy"), "-1"); + } + + #[test] + fn negative_sections_and_scientific_numbers() { + assert_eq!(format_value(-5.0, "#,##0;(#,##0)", 1.0), "(5)"); + assert_eq!(format_value(5.0, "#,##0;(#,##0)", 1.0), "5"); + assert_eq!(format_value(-1500.0, "#,##0_);(#,##0)", 500.0), "(1,500)"); + assert_eq!(format_value(-2.0, "0\";\"0;-0", 1.0), "-2"); + assert_eq!(format_value(12345.0, "0.00E+00", 1.0), "1.23E+04"); + assert_eq!(format_value(0.00012, "0.0E+0", 0.0001), "1.2E-4"); + assert_eq!(format_value(0.0, "0.00E+00", 1.0), "0.00E+00"); + assert_eq!(format_value(-12345.0, "0.0E-00", 1.0), "-1.2E04"); + } +} diff --git a/crates/docx/src/read/diagram.rs b/crates/docx/src/read/diagram.rs new file mode 100644 index 0000000000..c04e52aa9e --- /dev/null +++ b/crates/docx/src/read/diagram.rs @@ -0,0 +1,673 @@ +//! SmartArt diagrams: the ready-made drawing (`dsp:drawing`) Word keeps beside the diagram data, +//! converted to [`GraphicItem`]s. The diagram's layout isn't run; the saved drawing is the picture. + +use wordcraft_doc::graphic::{GraphicItem, PathSeg, TextAlign}; +use wordcraft_doc::para::ShapeKind; +use wordcraft_doc::props::Rgb; + +use super::Reader; +use super::chart::text_w; +use super::drawing_color::{color, paint}; +use crate::package::{Rel, Rels, rel_is, rt}; +use crate::units::{MAX_LEN_PT, int}; +use crate::xml::El; + +/// Deepest `dsp:grpSp` nesting followed. +const MAX_GROUP_DEPTH: usize = 8; +/// Most shapes read from one drawing. +const MAX_SHAPES: usize = 10_000; +/// Most items emitted for one diagram. +const MAX_ITEMS: usize = 50_000; +/// EMU per point. +const EMU_PT: f64 = 12_700.0; +/// Largest coordinate or extent accepted, EMU (about 78,000 pt). +const MAX_EMU: f64 = 1.0e9; +/// Line height as a multiple of the font size. +const LINE: f32 = 1.2; + +/// An affine map per axis (`x' = kx·x + ox`, `y' = ky·y + oy`). +#[derive(Clone, Copy, Debug)] +struct Xf { + kx: f64, + ox: f64, + ky: f64, + oy: f64, +} + +impl Xf { + const ID: Xf = Xf { kx: 1.0, ox: 0.0, ky: 1.0, oy: 0.0 }; + + /// `self` applied after `inner`. + fn then(self, inner: Xf) -> Xf { + Xf { kx: self.kx * inner.kx, ox: self.kx * inner.ox + self.ox, ky: self.ky * inner.ky, oy: self.ky * inner.oy + self.oy } + } + + /// The box `[x, y, w, h]` mapped to corners `[x0, y0, x1, y1]`. + fn rect(self, b: [f64; 4]) -> [f64; 4] { + let (x0, x1) = (self.kx * b[0] + self.ox, self.kx * (b[0] + b[2]) + self.ox); + let (y0, y1) = (self.ky * b[1] + self.oy, self.ky * (b[1] + b[3]) + self.oy); + [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)] + } +} + +/// A shape's outline in its own box: points and whether it's closed (a fill needs closed). +type Poly = (Vec<(f64, f64)>, bool); + +enum Geo { + Kind(ShapeKind), + Poly(Vec<(f64, f64)>, bool), +} + +/// Points, clamped into the range layout accepts (NaN, from a degenerate transform, is 0). +fn pt(v: f64) -> f32 { + if v.is_nan() { 0.0 } else { v.clamp(-MAX_LEN_PT as f64, MAX_LEN_PT as f64) as f32 } +} + +/// An EMU attribute of `e`, finite and bounded. +fn emu(e: &El, attr: &str) -> Option { + int(e.attr(attr)?).map(|v| (v as f64).clamp(-MAX_EMU, MAX_EMU)) +} + +/// `a:off` / `a:ext` of an element (a shape's `a:xfrm`, `dsp:txXfrm`) as `[x, y, w, h]` in EMU. +fn xfrm_box(x: &El) -> Option<[f64; 4]> { + let (off, ext) = (x.child("a:off")?, x.child("a:ext")?); + Some([emu(off, "x")?, emu(off, "y")?, emu(ext, "cx")?.max(0.0), emu(ext, "cy")?.max(0.0)]) +} + +/// Child-space to parent-space scale of a group, from its `a:xfrm` (`off`/`ext` against `chOff`/`chExt`). +fn group_xf(grp: &El) -> Xf { + let Some(x) = grp.child("dsp:grpSpPr").and_then(|p| p.child("a:xfrm")) else { return Xf::ID }; + let v = |tag: &str, a: &str| x.child(tag).and_then(|e| emu(e, a)).unwrap_or(0.0); + let scale = |e: f64, c: f64| if c > 0.0 && e >= 0.0 { e / c } else { 1.0 }; + let kx = scale(v("a:ext", "cx"), v("a:chExt", "cx")); + let ky = scale(v("a:ext", "cy"), v("a:chExt", "cy")); + Xf { kx, ox: v("a:off", "x") - v("a:chOff", "x") * kx, ky, oy: v("a:off", "y") - v("a:chOff", "y") * ky } +} + +/// Shapes in `tree` (depth-first through groups), each with its transform into the drawing. +fn collect<'a>(tree: &'a El, xf: Xf, depth: usize, out: &mut Vec<(Xf, &'a El)>) { + for e in tree.els() { + match e.name.as_str() { + "dsp:sp" if out.len() < MAX_SHAPES => out.push((xf, e)), + "dsp:grpSp" if depth < MAX_GROUP_DEPTH => collect(e, xf.then(group_xf(e)), depth + 1, out), + _ => {} + } + } +} + +/// The items of a SmartArt drawing, scaled so its extent fills the `w` × `h` (points) frame. +/// `image` maps a relationship id of the drawing part to a `Document::media` key. +pub(crate) fn diagram_items(drawing: &El, theme: &[Rgb], w: f32, h: f32, image: &mut dyn FnMut(&str) -> Option) -> Vec { + let Some(tree) = drawing.find("dsp:spTree") else { return Vec::new() }; + let mut shapes = Vec::new(); + collect(tree, Xf::ID, 0, &mut shapes); + let placed: Vec<(Xf, &El, [f64; 4])> = shapes + .into_iter() + .filter_map(|(xf, sp)| { + let b = sp.child("dsp:spPr").and_then(|p| p.child("a:xfrm")).and_then(xfrm_box)?; + Some((xf, sp, b)) + }) + .collect(); + // The drawing's extent (from its origin) against the frame. One scale for both axes, never + // above 1:1: Word draws the drawing at its own size when it fits the frame. + let (ext_x, ext_y) = placed.iter().fold((0.0f64, 0.0f64), |(mx, my), (xf, _, b)| { + let r = xf.rect(*b); + (mx.max(r[2]), my.max(r[3])) + }); + let frame = |f: f32| if f.is_finite() && f > 0.0 { f as f64 * EMU_PT } else { 0.0 }; + let fit_of = |frame: f64, ext: f64| if frame > 0.0 && ext > 0.0 { frame / ext } else { 1.0 }; + let fit = [1.0, fit_of(frame(w), ext_x), fit_of(frame(h), ext_y)].into_iter().fold(f64::INFINITY, f64::min); + let root = Xf { kx: fit / EMU_PT, ox: 0.0, ky: fit / EMU_PT, oy: 0.0 }; + let mut out = Vec::new(); + for (xf, sp, b) in placed { + if out.len() >= MAX_ITEMS { + break; + } + shape(sp, root.then(xf), b, theme, fit as f32, image, &mut out); + } + out +} + +/// One `dsp:sp`: its picture or outline, then its text. +/// `fit` is the drawing's scale into the frame, which text sizes follow. +fn shape(sp: &El, xf: Xf, b: [f64; 4], theme: &[Rgb], fit: f32, image: &mut dyn FnMut(&str) -> Option, out: &mut Vec) { + let [x0, y0, x1, y1] = xf.rect(b); + if ![x0, y0, x1, y1].iter().all(|v| v.is_finite()) { + return; + } + let (w, h) = (x1 - x0, y1 - y0); + let sppr = sp.child("dsp:spPr"); + let xfrm = sppr.and_then(|p| p.child("a:xfrm")); + let attr = |n: &str| xfrm.and_then(|x| x.attr(n)); + let rot = attr("rot").and_then(int).unwrap_or(0).rem_euclid(21_600_000) as f64 / 60_000.0; + let flip_h = attr("flipH").is_some_and(on); + let flip_v = attr("flipV").is_some_and(on); + let turned = rot != 0.0 || flip_h || flip_v; + let rect = [pt(x0), pt(y0), pt(w), pt(h)]; + let filled; + if let Some(blip) = sppr.and_then(|p| p.child("a:blipFill")).and_then(|f| f.find("a:blip")) { + // A picture fill stretches into the shape's box; no clipping to the outline. + if let Some(media) = blip.attr("r:embed").and_then(image) { + out.push(GraphicItem::Image { rect, media }); + } + filled = false; + } else { + let fill = fill_of(sp, sppr, theme); + let (stroke, stroke_width) = line_of(sp, sppr, theme); + let prst = sppr.and_then(|p| p.child("a:prstGeom")).and_then(|g| g.attr("prst")).unwrap_or("rect"); + let geom = sppr.and_then(|p| p.child("a:prstGeom")); + let geo = match kind_of(prst) { + Some(k) if !turned || prst == "ellipse" => Geo::Kind(k), + _ => match outline(prst, w, h, geom) { + Some((pts, closed)) => Geo::Poly(pts, closed), + None => Geo::Kind(kind_of(prst).unwrap_or(ShapeKind::Rectangle)), + }, + }; + match geo { + Geo::Kind(kind) => out.push(GraphicItem::Shape { rect, kind, fill, stroke, stroke_width }), + Geo::Poly(pts, closed) if !pts.is_empty() => { + // Flip in the box, then turn about its centre (clockwise, y down). + let (s, c) = rot.to_radians().sin_cos(); + let (cx, cy) = (w / 2.0, h / 2.0); + let map = |(px, py): (f64, f64)| { + let px = if flip_h { w - px } else { px }; + let py = if flip_v { h - py } else { py }; + let (dx, dy) = (px - cx, py - cy); + (x0 + cx + dx * c - dy * s, y0 + cy + dx * s + dy * c) + }; + let mut segs = Vec::with_capacity(pts.len() + 1); + for (i, p) in pts.into_iter().enumerate() { + let (x, y) = map(p); + segs.push(if i == 0 { PathSeg::Move(pt(x), pt(y)) } else { PathSeg::Line(pt(x), pt(y)) }); + } + if closed { + segs.push(PathSeg::Close); + } + let fill = if closed { fill } else { None }; + out.push(GraphicItem::Path { segs, fill, stroke, stroke_width }); + } + Geo::Poly(..) => {} + } + filled = fill.is_some(); + } + text(sp, xf, b, theme, filled, fit, out); +} + +fn on(v: &str) -> bool { + v == "1" || v == "true" +} + +/// The colour of a shape's style reference (`a:fillRef`, `a:lnRef`); index 0 means none. +fn style_colour(sp: &El, which: &str, theme: &[Rgb]) -> Option { + let r = sp.child("dsp:style")?.child(which)?; + if r.attr("idx") == Some("0") { + return None; + } + color(r, theme) +} + +/// A shape's fill: its own `a:*Fill`, else the style's fill colour. +fn fill_of(sp: &El, sppr: Option<&El>, theme: &[Rgb]) -> Option { + let own = sppr.and_then(|p| p.els().find(|e| matches!(e.name.as_str(), "a:solidFill" | "a:noFill" | "a:gradFill" | "a:pattFill"))); + match own { + Some(e) => paint(e, theme), + None => style_colour(sp, "a:fillRef", theme), + } +} + +/// A shape's outline colour and width in points (`a:ln`, else the style's line at 0.75 pt). +fn line_of(sp: &El, sppr: Option<&El>, theme: &[Rgb]) -> (Option, f32) { + let ln = sppr.and_then(|p| p.child("a:ln")); + let width = ln.and_then(|l| l.attr("w")).and_then(int).map_or(0.75, |v| (v as f64 / EMU_PT).clamp(0.0, 100.0) as f32); + let own = ln.and_then(|l| l.els().find(|e| matches!(e.name.as_str(), "a:solidFill" | "a:noFill" | "a:gradFill"))); + match own { + Some(e) if e.name == "a:noFill" => (None, 0.0), + Some(e) => (paint(e, theme), width), + None => (style_colour(sp, "a:lnRef", theme), width), + } +} + +/// The preset's own [`ShapeKind`], for the presets that have one. `None` means a polygon. +fn kind_of(prst: &str) -> Option { + Some(match prst { + "roundRect" => ShapeKind::RoundedRectangle, + "ellipse" => ShapeKind::Ellipse, + "triangle" => ShapeKind::Triangle, + "diamond" => ShapeKind::Diamond, + "line" | "straightConnector1" => ShapeKind::Line, + "rightArrow" => ShapeKind::Arrow, + "star5" | "star4" | "star6" => ShapeKind::Star, + "heart" => ShapeKind::Heart, + "leftArrow" | "upArrow" | "downArrow" | "leftRightArrow" | "upDownArrow" | "chevron" | "homePlate" | "hexagon" | "pentagon" | "trapezoid" + | "parallelogram" | "rtTriangle" | "pie" | "blockArc" => return None, + _ => ShapeKind::Rectangle, + }) +} + +/// A preset's adjust value (`a:avLst` `a:gd name`, `fmla="val N"`) in its raw units, `def` when absent. +fn raw_adj(geom: Option<&El>, name: &str, def: f64) -> f64 { + geom.and_then(|g| g.child("a:avLst")) + .and_then(|l| l.children("a:gd").find(|d| d.attr("name") == Some(name))) + .and_then(|d| d.attr("fmla")) + .and_then(|f| f.strip_prefix("val ")) + .and_then(|v| v.trim().parse::().ok()) + .filter(|v| v.is_finite()) + .map_or(def, |v| v.clamp(-1.0e9, 1.0e9)) +} + +/// A preset's adjust value as a fraction of the box (raw / 100000), `def` (a fraction) when absent. +fn adj(geom: Option<&El>, name: &str, def: f64) -> f64 { + raw_adj(geom, name, def * 100_000.0) / 100_000.0 +} + +/// A pie slice (`pie`) or block arc (`blockArc`) from `adj1` to `adj2` (60000ths of a degree, +/// clockwise from 3 o'clock); the block arc's band is `adj3` of the short side thick. +fn sector(block: bool, w: f64, h: f64, geom: Option<&El>) -> Poly { + let deg = |n: &str, def: f64| raw_adj(geom, n, def) / 60_000.0; + let (st, en) = if block { (deg("adj1", 10_800_000.0), deg("adj2", 0.0)) } else { (deg("adj1", 0.0), deg("adj2", 16_200_000.0)) }; + let sweep = (en - st).rem_euclid(360.0); + let (hw, hh) = (w / 2.0, h / 2.0); + let arc = move |rx: f64, ry: f64| { + (0..=36).map(move |k| { + let t = (st + sweep * k as f64 / 36.0).to_radians(); + (hw + rx * t.cos(), hh + ry * t.sin()) + }) + }; + if block { + let band = lim(w.min(h) * adj(geom, "adj3", 0.25), w.min(h) / 2.0); + let pts = arc(hw, hh).chain(arc(hw - band, hh - band).rev()).collect(); + (pts, true) + } else { + let mut pts = vec![(hw, hh)]; + pts.extend(arc(hw, hh)); + (pts, true) + } +} + +/// `v` limited to `[0, hi]` (never panics, unlike `clamp` with `hi < 0`). +fn lim(v: f64, hi: f64) -> f64 { + v.max(0.0).min(hi.max(0.0)) +} + +/// The outline of a polygon preset in a `w` × `h` box (points), or `None` for presets with no +/// polygon (ellipse, heart, stars). Unknown presets are rectangles. +fn outline(prst: &str, w: f64, h: f64, geom: Option<&El>) -> Option { + let ss = w.min(h); + let a = |n: &str, d: f64| adj(geom, n, d); + let (hw, hh) = (w / 2.0, h / 2.0); + let pts = match prst { + "line" | "straightConnector1" => return Some((vec![(0.0, 0.0), (w, h)], false)), + "pie" | "blockArc" => return Some(sector(prst == "blockArc", w, h, geom)), + "ellipse" | "heart" | "star5" | "star4" | "star6" => return None, + "roundRect" => return Some((rounded(w, h, lim(ss * a("adj", 0.16667), ss / 2.0)), true)), + "triangle" => { + let apex = lim(w * a("adj", 0.5), w); + vec![(apex, 0.0), (w, h), (0.0, h)] + } + "rtTriangle" => vec![(0.0, 0.0), (0.0, h), (w, h)], + "diamond" => vec![(hw, 0.0), (w, hh), (hw, h), (0.0, hh)], + "rightArrow" | "leftArrow" | "upArrow" | "downArrow" => { + let (len, thick) = if prst == "upArrow" || prst == "downArrow" { (h, w) } else { (w, h) }; + arrow(len, thick, a("adj1", 0.5), a("adj2", 0.5), false) + .into_iter() + .map(|(u, v)| match prst { + "leftArrow" => (w - u, v), + "upArrow" => (v, h - u), + "downArrow" => (v, u), + _ => (u, v), + }) + .collect() + } + "leftRightArrow" | "upDownArrow" => { + let (len, thick) = if prst == "upDownArrow" { (h, w) } else { (w, h) }; + arrow(len, thick, a("adj1", 0.5), a("adj2", 0.5), true) + .into_iter() + .map(|(u, v)| if prst == "upDownArrow" { (v, u) } else { (u, v) }) + .collect() + } + "chevron" => { + let x1 = lim(ss * a("adj", 0.5), w); + vec![(0.0, 0.0), (w - x1, 0.0), (w, hh), (w - x1, h), (0.0, h), (x1, hh)] + } + "homePlate" => { + let x1 = w - lim(ss * a("adj", 0.5), w); + vec![(0.0, 0.0), (x1, 0.0), (w, hh), (x1, h), (0.0, h)] + } + "hexagon" => { + let x1 = lim(ss * a("adj", 0.25), w / 2.0); + vec![(0.0, hh), (x1, 0.0), (w - x1, 0.0), (w, hh), (w - x1, h), (x1, h)] + } + "pentagon" => (0..5) + .map(|k| { + let t = (-90.0 + 72.0 * k as f64).to_radians(); + (hw + hw * t.cos(), hh + hh * t.sin()) + }) + .collect(), + "trapezoid" => { + let x2 = lim(ss * a("adj", 0.25), w / 2.0); + vec![(0.0, h), (x2, 0.0), (w - x2, 0.0), (w, h)] + } + "parallelogram" => { + let x2 = lim(ss * a("adj", 0.25), w); + vec![(0.0, h), (x2, 0.0), (w, 0.0), (w - x2, h)] + } + _ => vec![(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)], + }; + Some((pts, true)) +} + +/// A block arrow along `len` (tip at `len`) across `thick`. `a1` is the shaft thickness and `a2` +/// the head length, as fractions of the box; `double` puts a head on both ends. +fn arrow(len: f64, thick: f64, a1: f64, a2: f64, double: bool) -> Vec<(f64, f64)> { + let head = lim(len.min(thick) * a2, len / 2.0); + let half = lim(thick * a1 / 2.0, thick / 2.0); + let c = thick / 2.0; + if double { + vec![ + (0.0, c), + (head, 0.0), + (head, c - half), + (len - head, c - half), + (len - head, 0.0), + (len, c), + (len - head, thick), + (len - head, c + half), + (head, c + half), + (head, thick), + ] + } else { + vec![(0.0, c - half), (len - head, c - half), (len - head, 0.0), (len, c), (len - head, thick), (len - head, c + half), (0.0, c + half)] + } +} + +/// A `w` × `h` rectangle with corners of radius `r`, each corner in six steps. +fn rounded(w: f64, h: f64, r: f64) -> Vec<(f64, f64)> { + let mut pts = Vec::with_capacity(28); + for (cx, cy, start) in [(w - r, r, -90.0), (w - r, h - r, 0.0), (r, h - r, 90.0), (r, r, 180.0)] { + for k in 0..=6 { + let t = (start + 15.0 * k as f64).to_radians(); + pts.push((cx + r * t.cos(), cy + r * t.sin())); + } + } + pts +} + +/// One line of a text item before it's placed. +struct Line { + text: String, + size: f32, + color: Rgb, + bold: bool, + align: TextAlign, + font: Option, +} + +/// The text of a `dsp:txBody`: one item per (wrapped) line, stacked and centred in the text box. +fn text(sp: &El, xf: Xf, b: [f64; 4], theme: &[Rgb], filled: bool, fit: f32, out: &mut Vec) { + let Some(tb) = sp.child("dsp:txBody") else { return }; + let body = tb.child("a:bodyPr"); + let scale = body + .and_then(|p| p.child("a:normAutofit")) + .and_then(|n| n.attr("fontScale")) + .and_then(int) + .map_or(1.0, |v| (v as f32 / 100_000.0).clamp(0.1, 1.0)); + let inset = |n: &str, def: f64| body.and_then(|p| p.attr(n)).and_then(int).map_or(def, |v| (v as f64).clamp(0.0, MAX_EMU)); + let text_box = match sp.child("dsp:txXfrm").and_then(xfrm_box) { + Some(t) => t, + None => { + let (l, t, r, bo) = (inset("lIns", 91_440.0), inset("tIns", 45_720.0), inset("rIns", 91_440.0), inset("bIns", 45_720.0)); + [b[0] + l, b[1] + t, (b[2] - l - r).max(0.0), (b[3] - t - bo).max(0.0)] + } + }; + let [x0, y0, x1, y1] = xf.rect(text_box); + let (x0, y0, tw, th) = (pt(x0), pt(y0), pt(x1 - x0), pt(y1 - y0)); + // Each line is one item: stop at the diagram's item limit, however much text there is. + let room = MAX_ITEMS.saturating_sub(out.len()); + let fallback = theme.get(if filled { 1 } else { 0 }).copied().unwrap_or(if filled { Rgb::WHITE } else { Rgb::BLACK }); + let font_ref = sp.child("dsp:style").and_then(|s| s.child("a:fontRef")).and_then(|f| color(f, theme)); + let mut lines: Vec = Vec::new(); + for p in tb.children("a:p") { + if lines.len() >= room { + break; + } + let text: String = p.children("a:r").filter_map(|r| r.child("a:t")).map(|t| t.text()).collect(); + let text: String = text.chars().map(|c| if c.is_control() { ' ' } else { c }).collect(); + let text = text.trim(); + if text.is_empty() { + continue; + } + let run = p.children("a:r").find(|r| r.child("a:t").is_some()); + let rpr = run.and_then(|r| r.child("a:rPr")); + // The file's size (18 pt when unset), reduced by the autofit and scaled with the drawing. + let base = rpr.and_then(|r| r.attr("sz")).and_then(int).map_or(18.0, |v| (v as f32 / 100.0).clamp(1.0, 400.0)); + let size = base * scale * fit; + let color = rpr.and_then(|r| r.child("a:solidFill")).and_then(|f| color(f, theme)).or(font_ref).unwrap_or(fallback); + let bold = rpr.and_then(|r| r.attr("b")).is_some_and(on); + let align = match p.child("a:pPr").and_then(|q| q.attr("algn")) { + Some("l" | "just" | "dist") => TextAlign::Left, + Some("r") => TextAlign::Right, + _ => TextAlign::Center, + }; + let font = rpr + .and_then(|r| r.child("a:latin")) + .and_then(|l| l.attr("typeface")) + .filter(|t| !t.is_empty() && !t.starts_with('+')) + .map(str::to_string); + for line in wrap(text, size, tw, room - lines.len()) { + lines.push(Line { text: line, size, color, bold, align, font: font.clone() }); + } + } + let total: f32 = lines.iter().map(|l| l.size * LINE).sum(); + let mut y = y0 + (th - total) / 2.0; + for l in lines { + let lh = l.size * LINE; + out.push(GraphicItem::Text { rect: [x0, y, tw, lh], text: l.text, size: l.size, color: l.color, bold: l.bold, align: l.align, font: l.font }); + y += lh; + } +} + +/// Splits `text` into at most `max` lines of whole words that fit `width` points (by the shared +/// text width estimate); a word wider than that keeps its own line. +fn wrap(text: &str, size: f32, width: f32, max: usize) -> Vec { + let limit = if width > 0.0 && size > 0.0 { width } else { f32::INFINITY }; + let space = text_w(" ", size); + let mut lines = Vec::new(); + let (mut cur, mut cur_w) = (String::new(), 0.0f32); + for word in text.split_whitespace() { + let w = text_w(word, size); + if !cur.is_empty() && cur_w + space + w > limit { + if lines.len() + 1 >= max { + break; + } + lines.push(std::mem::take(&mut cur)); + cur_w = 0.0; + } + if !cur.is_empty() { + cur.push(' '); + cur_w += space; + } + cur.push_str(word); + cur_w += w; + } + if !cur.is_empty() && lines.len() < max { + lines.push(cur); + } + lines +} + +impl Reader<'_> { + /// The drawing part of a SmartArt diagram (`a:graphicData` with `dgm:relIds`): the one its + /// data part names, else the drawing numbered like the data part. `None` when there's none. + pub(super) fn diagram_part(&mut self, gd: &El, rels: &Rels) -> Option { + let dm = gd.child("dgm:relIds")?.attr("r:dm")?; + let data = super::part_of(rels, dm, rt::DIAGRAM_DATA)?; + let ext_id = self.graphic_part(&data).and_then(|d| d.find("dsp:dataModelExt").and_then(|e| e.attr("relId")).map(str::to_string)); + let is_drawing = |r: &&Rel| !r.external && rel_is(&r.kind, rt::DIAGRAM_DRAWING); + let drawing = ext_id.as_deref().and_then(|id| rels.by_id(id)).filter(is_drawing).or_else(|| { + // No usable relationship id: the drawing whose number matches the data part's. + let num = |t: &str| t.rsplit('/').next().unwrap_or(t).chars().filter(char::is_ascii_digit).collect::(); + let n = num(&data); + rels.list.iter().filter(is_drawing).find(|r| !n.is_empty() && num(&r.target) == n) + }); + drawing.map(|r| r.target.clone()) + } + + /// The items of the SmartArt drawing part at `path`, fitted to `w` × `h` points. + pub(super) fn diagram_drawing(&mut self, path: &str, w: f32, h: f32) -> Vec { + let Some(root) = self.graphic_part(path) else { return Vec::new() }; + let drels = self.pkg.rels(path); + let theme = self.doc.settings.theme_colors.clone(); + let mut image = |id: &str| self.media_for(&drels, id); + diagram_items(&root, &theme, w, h, &mut image) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wordcraft_doc::THEME_COLORS; + + const NS: &str = r#"xmlns:dsp="http://schemas.microsoft.com/office/drawing/2008/diagram" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#; + + fn drawing(body: &str) -> El { + crate::xml::parse(format!("{body}").as_bytes()).unwrap() + } + + /// Items for a drawing whose only image relationship is `rId1`. + fn items(body: &str, w: f32, h: f32) -> Vec { + let root = drawing(body); + diagram_items(&root, &THEME_COLORS, w, h, &mut |id| (id == "rId1").then(|| "image1.png".to_string())) + } + + /// A `dsp:sp` with the given geometry (`prst`, `xfrm` attributes) and extra spPr/txBody XML. + fn sp(xfrm: &str, prst: &str, rest: &str) -> String { + format!( + r#"{rest}"# + ) + } + + #[test] + fn round_rect_takes_theme_colour_and_text() { + let body = r#"Foo"#; + let out = items(body, 100.0, 50.0); + let GraphicItem::Shape { kind, fill, stroke, rect, .. } = &out[0] else { panic!("shape first: {out:?}") }; + assert_eq!(*kind, ShapeKind::RoundedRectangle); + assert_eq!(*fill, Some(Rgb(0x15, 0x60, 0x82))); + assert_eq!(*stroke, None); + assert_eq!(*rect, [0.0, 0.0, 100.0, 50.0]); + let GraphicItem::Text { text, size, color, bold, align, .. } = &out[1] else { panic!("text second: {out:?}") }; + assert_eq!(text, "Foo"); + assert_eq!((*size, *color, *bold, *align), (20.0, Rgb::WHITE, true, TextAlign::Center)); + } + + #[test] + fn chevron_is_a_path() { + let out = items(&sp("", "chevron", ""), 100.0, 100.0); + let GraphicItem::Path { segs, .. } = &out[0] else { panic!("path: {out:?}") }; + assert_eq!(segs.len(), 7); + assert_eq!(segs.last(), Some(&PathSeg::Close)); + } + + #[test] + fn rotated_rect_is_a_rotated_path() { + let out = items(&sp(r#"rot="2700000""#, "rect", r#""#), 100.0, 100.0); + let GraphicItem::Path { segs, fill, .. } = &out[0] else { panic!("path: {out:?}") }; + assert_eq!(*fill, Some(Rgb(255, 0, 0))); + let PathSeg::Move(x, y) = segs[0] else { panic!("move") }; + // A 100 pt square turned 45 degrees about its centre: the top-left corner moves to the + // middle of the top edge's extension. + assert!((x - 50.0).abs() < 0.01 && (y + 20.71).abs() < 0.01, "got {x} {y}"); + } + + #[test] + fn lum_mod_darkens_the_theme_colour() { + let rest = r#""#; + let out = items(&sp("", "rect", rest), 10.0, 10.0); + let GraphicItem::Shape { fill, .. } = &out[0] else { panic!("shape") }; + let Rgb(r, g, b) = fill.expect("fill"); + // accent1 (156082) at half its luminance. + assert!(r < 0x15 && g < 0x60 && b < 0x82, "got {r:02x}{g:02x}{b:02x}"); + } + + #[test] + fn picture_fill_is_an_image() { + let rest = r#""#; + let out = items(&sp("", "ellipse", rest), 10.0, 10.0); + assert!(matches!(&out[0], GraphicItem::Image { media, .. } if media == "image1.png"), "{out:?}"); + } + + #[test] + fn frame_scales_the_drawing_and_groups_transform_children() { + // A 2 x 2 inch drawing in a 72 pt frame: a group at the origin holds a half-size child. + let grp = r#""#; + let out = items(grp, 72.0, 72.0); + let GraphicItem::Shape { rect, .. } = &out[0] else { panic!("shape: {out:?}") }; + // The group's child sits at half of the 1 in group extent (x = 0.5 in): 36 pt of the 72 pt frame. + assert_eq!(*rect, [36.0, 36.0, 36.0, 36.0]); + } + + #[test] + fn long_text_wraps_into_several_lines() { + let body = r#"one two three four five six"#; + let out = items(body, 30.0, 30.0); + let texts: Vec<&str> = out.iter().filter_map(|i| if let GraphicItem::Text { text, .. } = i { Some(text.as_str()) } else { None }).collect(); + assert!(texts.len() > 1, "{texts:?}"); + assert_eq!(texts.join(" "), "one two three four five six"); + } + + #[test] + fn hostile_numbers_and_nesting_do_not_panic() { + let huge = format!( + r#"x{}"#, + sp(r#"rot="2700000""#, "chevron", "") + ); + let mut nested = String::new(); + for _ in 0..40 { + nested = format!( + r#"{nested}{}"#, + sp("", "rect", "") + ); + } + let _ = items(&huge, f32::NAN, -3.0); + let _ = items(&huge, f32::INFINITY, 1.0e30); + let _ = items(&nested, 100.0, 100.0); + let root = drawing(&huge); + assert!(diagram_items(&root, &[], 10.0, 10.0, &mut |_| None).len() <= MAX_ITEMS); + } + + #[test] + fn text_follows_autofit_and_the_drawing_fit() { + // A 100 pt drawing: `fontScale` 50% halves its 20 pt text and the 18 pt default alike. + let body = r#"BigSmall"#; + let sizes = |out: Vec| -> Vec<(String, f32)> { + out.into_iter().filter_map(|i| if let GraphicItem::Text { text, size, .. } = i { Some((text, size)) } else { None }).collect() + }; + assert_eq!(sizes(items(body, 100.0, 100.0)), [("Big".to_string(), 10.0), ("Small".to_string(), 9.0)]); + // In a 50 pt frame the whole drawing is at half size, so its text is too. + assert_eq!(sizes(items(body, 50.0, 50.0)), [("Big".to_string(), 5.0), ("Small".to_string(), 4.5)]); + } + + #[test] + fn one_huge_text_body_stops_at_the_item_limit() { + let words = "word ".repeat(MAX_ITEMS + 100); + let body = format!( + r#"{words}{words}"# + ); + let out = items(&body, 1.0, 1.0); + assert_eq!(out.len(), MAX_ITEMS); + } + + #[test] + fn text_far_outside_nested_groups_stays_finite() { + // Eight groups each scaling 1e9: a shape at -1e9 EMU ends up beyond f32 before clamping. + let mut tree = r#"Far"#.to_string(); + for _ in 0..MAX_GROUP_DEPTH { + tree = format!( + r#"{tree}"# + ); + } + let out = items(&tree, 100.0, 100.0); + let rects: Vec<[f32; 4]> = out.iter().filter_map(|i| if let GraphicItem::Text { rect, .. } = i { Some(*rect) } else { None }).collect(); + assert!(!rects.is_empty() && rects.iter().flatten().all(|v| v.is_finite()), "{rects:?}"); + } +} diff --git a/crates/docx/src/read/drawing_color.rs b/crates/docx/src/read/drawing_color.rs new file mode 100644 index 0000000000..60c73c33f8 --- /dev/null +++ b/crates/docx/src/read/drawing_color.rs @@ -0,0 +1,174 @@ +//! DrawingML colours shared by charts and SmartArt: a colour element with its modifiers, and the +//! paint of a fill or line (solid, the first stop of a gradient, a pattern's foreground). + +use wordcraft_doc::THEME_COLORS; +use wordcraft_doc::props::Rgb; + +use crate::units::int; +use crate::xml::El; + +/// Theme colour `i` (0 dk1, 1 lt1, 2 dk2, 3 lt2, 4..9 accent1..6, 10 hlink, 11 folHlink), falling +/// back to the default theme. +pub(crate) fn slot(theme: &[Rgb], i: usize) -> Rgb { + theme.get(i).or_else(|| THEME_COLORS.get(i)).copied().unwrap_or(Rgb::BLACK) +} + +/// The colour of a fill or line element: a solid colour, the first stop of a gradient, or the +/// foreground of a pattern. `None` for no fill and picture fills. +pub(crate) fn paint(e: &El, theme: &[Rgb]) -> Option { + match e.name.as_str() { + "a:gradFill" => e.child("a:gsLst").and_then(|l| l.child("a:gs")).and_then(|g| color(g, theme)), + "a:pattFill" => e.child("a:fgClr").and_then(|c| color(c, theme)), + "a:noFill" | "a:blipFill" => None, + _ => color(e, theme), + } +} + +/// The fill of a `…Pr` or `a:ln` element (its first solid, gradient or pattern fill), as [`paint`]. +pub(crate) fn fill_colour(e: &El, theme: &[Rgb]) -> Option { + let f = e.els().find(|c| matches!(c.name.as_str(), "a:solidFill" | "a:gradFill" | "a:pattFill"))?; + paint(f, theme) +} + +fn is_color(e: &El) -> bool { + matches!(e.name.as_str(), "a:srgbClr" | "a:schemeClr" | "a:sysClr") +} + +/// A colour element (`a:srgbClr`, `a:schemeClr`, `a:sysClr`), or the one inside `e`, with its +/// `lumMod` / `lumOff` / `tint` / `shade` applied in document order. +pub(crate) fn color(e: &El, theme: &[Rgb]) -> Option { + let c = if is_color(e) { e } else { e.els().find(|c| is_color(c))? }; + let base = match c.name.as_str() { + "a:srgbClr" => Rgb::parse(c.attr("val")?)?, + "a:schemeClr" => *theme.get(scheme_slot(c.attr("val")?)?)?, + "a:sysClr" => Rgb::parse(c.attr("lastClr")?)?, + _ => return None, + }; + let val = |m: &El| m.attr("val").and_then(int).map_or(0.0, |v| (v as f64 / 100_000.0).clamp(-10.0, 10.0)); + let mut rgb = base; + for m in c.els() { + rgb = match m.name.as_str() { + "a:lumMod" => adjust_lum(rgb, |l| l * val(m)), + "a:lumOff" => adjust_lum(rgb, |l| l + val(m)), + "a:tint" => mix(rgb, 255.0, val(m)), + "a:shade" => mix(rgb, 0.0, val(m)), + _ => rgb, + }; + } + Some(rgb) +} + +/// Index into `Document::settings.theme_colors` (dk1, lt1, dk2, lt2, accent1..6, hlink, folHlink). +fn scheme_slot(name: &str) -> Option { + Some(match name { + "dk1" | "tx1" => 0, + "lt1" | "bg1" => 1, + "dk2" | "tx2" => 2, + "lt2" | "bg2" => 3, + "accent1" => 4, + "accent2" => 5, + "accent3" => 6, + "accent4" => 7, + "accent5" => 8, + "accent6" => 9, + "hlink" => 10, + "folHlink" => 11, + _ => return None, + }) +} + +/// `tint` (towards `to` = 255) and `shade` (towards 0): `v` is the share of the original colour kept. +fn mix(c: Rgb, to: f64, v: f64) -> Rgb { + let f = |x: u8| (x as f64 * v + to * (1.0 - v)).round().clamp(0.0, 255.0) as u8; + Rgb(f(c.0), f(c.1), f(c.2)) +} + +fn adjust_lum(c: Rgb, f: impl Fn(f64) -> f64) -> Rgb { + let (h, s, l) = to_hsl(c); + from_hsl(h, s, f(l).clamp(0.0, 1.0)) +} + +fn to_hsl(c: Rgb) -> (f64, f64, f64) { + let (r, g, b) = (c.0 as f64 / 255.0, c.1 as f64 / 255.0, c.2 as f64 / 255.0); + let (max, min) = (r.max(g).max(b), r.min(g).min(b)); + let l = (max + min) / 2.0; + if max == min { + return (0.0, 0.0, l); + } + let d = max - min; + let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) }; + let h = if max == r { + (g - b) / d + if g < b { 6.0 } else { 0.0 } + } else if max == g { + (b - r) / d + 2.0 + } else { + (r - g) / d + 4.0 + }; + (h / 6.0, s, l) +} + +fn from_hsl(h: f64, s: f64, l: f64) -> Rgb { + let byte = |v: f64| (v * 255.0).round().clamp(0.0, 255.0) as u8; + if s == 0.0 { + return Rgb(byte(l), byte(l), byte(l)); + } + let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s }; + let p = 2.0 * l - q; + let channel = |t: f64| { + let t = t.rem_euclid(1.0); + let v = if t < 1.0 / 6.0 { + p + (q - p) * 6.0 * t + } else if t < 0.5 { + q + } else if t < 2.0 / 3.0 { + p + (q - p) * (2.0 / 3.0 - t) * 6.0 + } else { + p + }; + byte(v) + }; + Rgb(channel(h + 1.0 / 3.0), channel(h), channel(h - 1.0 / 3.0)) +} + +#[cfg(test)] +mod tests { + use wordcraft_doc::THEME_COLORS; + + use super::*; + + const NS: &str = r#"xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main""#; + + /// The element `name` with `attrs` and `body`, parsed. + fn el(name: &str, attrs: &str, body: &str) -> El { + crate::xml::parse(format!(r#"<{name} {NS} {attrs}>{body}"#).as_bytes()).unwrap() + } + + #[test] + fn gradient_and_pattern_fills_take_their_first_colour() { + let theme = THEME_COLORS.to_vec(); + let grad = el("a:gradFill", "", r#""#); + assert_eq!(paint(&grad, &theme), Some(Rgb(255, 0, 0))); + let patt = + el("a:pattFill", r#"prst="dkDnDiag""#, r#""#); + assert_eq!(paint(&patt, &theme), Some(Rgb(0, 255, 0))); + let ln = el("a:ln", "", r#""#); + assert_eq!(fill_colour(&ln, &theme), Some(Rgb(255, 0, 0))); + assert_eq!(fill_colour(&el("a:ln", "", ""), &theme), None); + } + + #[test] + fn tint_and_shade_move_towards_white_and_black() { + let theme = THEME_COLORS.to_vec(); + let near = |c: Option| c.is_some_and(|Rgb(r, g, b)| [r, g, b].iter().all(|x| x.abs_diff(128) <= 1)); + assert!(near(color(&el("a:srgbClr", r#"val="000000""#, r#""#), &theme))); + assert!(near(color(&el("a:srgbClr", r#"val="FFFFFF""#, r#""#), &theme))); + } + + #[test] + fn modifiers_apply_in_the_order_written() { + let theme = THEME_COLORS.to_vec(); + let a = el("a:srgbClr", r#"val="808080""#, r#""#); + let b = el("a:srgbClr", r#"val="808080""#, r#""#); + assert_ne!(color(&a, &theme), color(&b, &theme)); + } +} diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index cfefa812ca..2762e0c45d 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -1,5 +1,8 @@ //! DOCX → [`Document`]. +mod chart; +mod diagram; +mod drawing_color; mod math; mod props; mod story; @@ -7,6 +10,7 @@ mod story; use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::Arc; +use wordcraft_doc::graphic::{Graphic, GraphicKind}; use wordcraft_doc::numbering::{AbstractNum, Level, LevelSuffix, Num}; use wordcraft_doc::para::NoteKind; use wordcraft_doc::para::OBJ; @@ -16,7 +20,7 @@ use wordcraft_doc::styles::{Style, StyleKind, StyleSheet, TableStyleParts}; use wordcraft_doc::{Block, Blocks, Comment, Document, InlineObject, PartKind, Revision, RevisionKind}; use crate::DocxError; -use crate::package::{ContentTypes, MAX_VBA_RELATED, Package, Rels, VBA_PROJECT_PART, VBA_RELATED, rt}; +use crate::package::{ContentTypes, MAX_VBA_RELATED, Package, Rels, VBA_PROJECT_PART, VBA_RELATED, rel_is, rt}; use crate::units::{flag, int, on_off, tw, u32_of}; use crate::xml::El; pub(crate) use props::PropCtx; @@ -24,6 +28,9 @@ use story::StoryCtx; /// Most parts (headers, notes, comments, text boxes) we create from one file. const MAX_PARTS: usize = 50_000; +/// Most graphic work (items plus path segments, see `story::graphic_work`) built for one file's +/// charts and diagrams; later ones are left empty. +const MAX_GRAPHIC_WORK: usize = 2_000_000; pub(crate) struct Reader<'p> { pkg: &'p Package, @@ -40,6 +47,18 @@ pub(crate) struct Reader<'p> { pub comment_map: HashMap, pub comments_ended: HashSet, pub bookmarks: HashMap, + /// Chart and diagram parts by path, parsed once. + graphic_parts: HashMap>>, + /// Charts and diagrams by (kind, part path, width bits, height bits), built once. + graphics: HashMap<(GraphicKind, String, u32, u32), Arc>, + /// Graphic work left before charts and diagrams are left empty (see [`MAX_GRAPHIC_WORK`]). + graphic_budget: usize, +} + +/// The package path that internal relationship `id` points at, if it has type `kind` (an `rt` +/// constant): a crafted id can't make a chart of some other, possibly huge, part. +fn part_of(rels: &Rels, id: &str, kind: &str) -> Option { + rels.by_id(id).filter(|r| !r.external && rel_is(&r.kind, kind)).map(|r| r.target.clone()) } /// Read a `.docx` package. @@ -69,6 +88,9 @@ pub fn read(bytes: &[u8]) -> Result { comment_map: HashMap::new(), comments_ended: HashSet::new(), bookmarks: HashMap::new(), + graphic_parts: HashMap::new(), + graphics: HashMap::new(), + graphic_budget: MAX_GRAPHIC_WORK, }; r.pc.major_font = r.doc.settings.major_font.clone(); r.pc.minor_font = r.doc.settings.minor_font.clone(); @@ -302,6 +324,16 @@ impl Reader<'_> { Some(key) } + /// A chart or diagram part, parsed once however often it's referenced (`None` when unreadable). + fn graphic_part(&mut self, path: &str) -> Option> { + if let Some(p) = self.graphic_parts.get(path) { + return p.clone(); + } + let p = self.xml(path).ok().flatten().map(Arc::new); + self.graphic_parts.insert(path.to_string(), p.clone()); + p + } + pub fn load_header_footer(&mut self, path: &str, footer: bool) -> Option { if let Some(id) = self.hf_by_path.get(path) { return Some(*id); diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index f57570881c..a831f6bacb 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -2,6 +2,7 @@ use std::sync::Arc; +use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind}; use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap}; use wordcraft_doc::props::{CharProps, Rgb}; use wordcraft_doc::table::{Cell, MAX_COLS, MAX_ROWS, Row, Table}; @@ -9,7 +10,7 @@ use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, PartKind, RevisionKi use super::Reader; use super::props::{sectpr, tcpr, trpr}; -use crate::package::Rels; +use crate::package::{Rels, rt}; use crate::units::{int, measure}; use crate::xml::El; @@ -649,6 +650,19 @@ impl Reader<'_> { } } let gd = c.child("a:graphic").and_then(|g| g.child("a:graphicData"))?; + // Checked by URI before the blip search: a diagram's data can hold a blip further down. + let uri = gd.attr("uri").unwrap_or(""); + let graphic_kind = if uri.ends_with("/chart") { + Some(GraphicKind::Chart) + } else if uri.ends_with("/diagram") { + Some(GraphicKind::Diagram) + } else { + None + }; + if let Some(kind) = graphic_kind { + let graphic = self.graphic(kind, gd, rels, w, h); + return Some(InlineObject::Graphic { w, h, alt, float, graphic }); + } if let Some(blip) = gd.find("a:blip") { let media = blip.attr("r:embed").and_then(|id| self.media_for(rels, id))?; let mut crop = [0.0f32; 4]; @@ -669,6 +683,38 @@ impl Reader<'_> { None } + /// The chart or SmartArt diagram in an `a:graphicData`, as items in points from its top-left + /// corner. Charts are drawn from their cached data, SmartArt diagrams from the drawing Word + /// stores beside them. Built once per part and size; once the file's graphic budget is spent, + /// later ones are empty. + fn graphic(&mut self, kind: GraphicKind, gd: &El, rels: &Rels, w: f32, h: f32) -> Arc { + let source = match kind { + 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) = source else { return Arc::new(Graphic { kind, items: Vec::new(), w, h }) }; + 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 }); + self.graphics.insert(key, g.clone()); + g + } + + /// The items of the chart part or diagram drawing part at `path`. + fn graphic_items(&mut self, kind: GraphicKind, path: &str, w: f32, h: f32) -> Vec { + match kind { + GraphicKind::Chart => match self.graphic_part(path) { + Some(space) => super::chart::chart_items(&space, &self.doc.settings.theme_colors, w, h), + None => Vec::new(), + }, + GraphicKind::Diagram => self.diagram_drawing(path, w, h), + } + } + fn read_wsp(&mut self, sc: &mut StoryCtx, wsp: &El, rels: &Rels, w: f32, h: f32, float: Float) -> InlineObject { let sppr = wsp.child("wps:spPr"); let prst = sppr.and_then(|s| s.child("a:prstGeom")).and_then(|g| g.attr("prst")).unwrap_or("rect"); @@ -914,3 +960,8 @@ fn vml_float(shape: &El, style: &str) -> Float { ..Float::default() } } + +/// What a graphic's items cost to keep and draw: one per item, plus one per path segment. +fn graphic_work(items: &[GraphicItem]) -> usize { + items.iter().map(|it| if let GraphicItem::Path { segs, .. } = it { segs.len() + 1 } else { 1 }).sum() +} diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index f397e6c20a..007a567f9a 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -369,6 +369,8 @@ impl Writer<'_> { w.close("w:r"); self.rev_close(w, props); } + // Charts and diagrams aren't written back yet. + InlineObject::Graphic { .. } => {} InlineObject::Image { media, w: iw, h: ih, alt, float, crop } => { let Some(file) = self.media_files.get(media).cloned() else { return }; let rid = rels.add(rt::IMAGE, &format!("media/{file}"), false); diff --git a/crates/docx/src/xml.rs b/crates/docx/src/xml.rs index 3a826599b7..5f1d104995 100644 --- a/crates/docx/src/xml.rs +++ b/crates/docx/src/xml.rs @@ -21,6 +21,7 @@ pub const NAMESPACES: &[(&str, &str)] = &[ ("r", "http://schemas.openxmlformats.org/officeDocument/2006/relationships"), ("wp", "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"), ("a", "http://schemas.openxmlformats.org/drawingml/2006/main"), + ("c", "http://schemas.openxmlformats.org/drawingml/2006/chart"), ("pic", "http://schemas.openxmlformats.org/drawingml/2006/picture"), ("mc", "http://schemas.openxmlformats.org/markup-compatibility/2006"), ("w14", "http://schemas.microsoft.com/office/word/2010/wordml"), @@ -28,6 +29,8 @@ pub const NAMESPACES: &[(&str, &str)] = &[ ("wp14", "http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing"), ("wps", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape"), ("wpg", "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"), + ("dgm", "http://schemas.openxmlformats.org/drawingml/2006/diagram"), + ("dsp", "http://schemas.microsoft.com/office/drawing/2008/diagram"), ("m", "http://schemas.openxmlformats.org/officeDocument/2006/math"), ("v", "urn:schemas-microsoft-com:vml"), ("o", "urn:schemas-microsoft-com:office:office"), diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 00ac2c8f3f..876345641c 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -407,6 +407,59 @@ fn vml_image_and_inline_drawing() { assert_eq!(d.media.get("pic.png").unwrap().as_slice(), png); } +#[test] +fn chart_and_diagram_drawings_are_graphic_objects() { + // A diagram's data holds a blip: the URI decides first, so no picture is read from it. + let drawing = |uri: &str, data: &str| { + format!( + r#"{data}"# + ) + }; + let body = format!( + "{}{}", + drawing("http://schemas.openxmlformats.org/drawingml/2006/chart", ""), + drawing("http://schemas.openxmlformats.org/drawingml/2006/diagram", r#""#) + ); + let d = read_body(&body); + let p = paras(&d); + assert_eq!(p[0].objects.len(), 2); + match (&p[0].objects[0], &p[0].objects[1]) { + (InlineObject::Graphic { w: w1, h: h1, alt, graphic: g1, .. }, InlineObject::Graphic { graphic: g2, .. }) => { + assert_eq!((*w1, *h1, alt.as_str()), (216.0, 144.0, "sales")); + assert_eq!(g1.kind, wordcraft_doc::graphic::GraphicKind::Chart); + assert_eq!(g2.kind, wordcraft_doc::graphic::GraphicKind::Diagram); + assert!(g1.items.is_empty() && g2.items.is_empty()); + } + o => panic!("{o:?}"), + } +} + +#[test] +fn a_chart_part_is_built_once_and_only_through_a_chart_relationship() { + let chart = r#"12"#; + let drawing = |id: &str| { + format!( + r#""# + ) + }; + let body = format!("{}{}{}", drawing("rIdChart"), drawing("rIdChart"), drawing("rIdNotChart")); + let bytes = docx( + &body, + &[("rIdChart", "chart", "charts/chart1.xml"), ("rIdNotChart", "image", "charts/chart1.xml")], + &[("word/charts/chart1.xml", chart)], + ); + let d = wordcraft_docx::read(&bytes).unwrap(); + let graphics: Vec<_> = paras(&d)[0] + .objects + .iter() + .filter_map(|o| if let InlineObject::Graphic { graphic, .. } = o { Some(graphic.clone()) } else { None }) + .collect(); + assert_eq!(graphics.len(), 3); + assert!(!graphics[0].items.is_empty(), "the chart is drawn"); + assert!(std::sync::Arc::ptr_eq(&graphics[0], &graphics[1]), "one build for both references"); + assert!(graphics[2].items.is_empty(), "an image relationship is not a chart"); +} + #[test] fn strict_namespace_and_bad_numbers() { let doc = r#" diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index 7360d7abd7..5707331219 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -22,10 +22,21 @@ fn has_object(s: &Session) -> Option<&'static str> { } fn has_floating(s: &Session) -> Option<&'static str> { match selected(s) { + Some((_, InlineObject::Graphic { .. })) => Some(FROZEN), Some((_, InlineObject::Image { float, .. } | InlineObject::Shape { float, .. })) if float.wrap != Wrap::Inline => None, _ => Some("select a floating picture or shape first"), } } +/// 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_object` for the geometry commands: a chart or diagram is frozen. +fn has_movable(s: &Session) -> Option<&'static str> { + match selected(s) { + Some((_, InlineObject::Graphic { .. })) => Some(FROZEN), + _ => has_object(s), + } +} fn has_shape(s: &Session) -> Option<&'static str> { match selected(s) { Some((_, InlineObject::Shape { .. })) => None, @@ -35,7 +46,7 @@ fn has_shape(s: &Session) -> Option<&'static str> { pub fn specs() -> Vec { vec![ - CommandSpec::new("picture.size", "Size", "Picture Format › Size", size).params(r#"{"width"?: pt, "height"?: pt, "lockAspect"?: bool, "scale"?: percent}"#).when(has_object), + 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.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| { @@ -202,7 +213,7 @@ pub fn specs() -> Vec { with_float(s, |f| f.wrap = wrap) }) .params(r#"{"wrap": "inline|square|tight|through|topAndBottom|behindText|inFrontOfText"}"#) - .when(has_object), + .when(has_movable), CommandSpec::new("arrange.position", "Position", "Layout › Arrange", |s, v| { let preset = p::str(v, "preset").unwrap_or("middleCenter").to_string(); let (x, y) = (p::f32(v, "x"), p::f32(v, "y")); @@ -223,9 +234,9 @@ pub fn specs() -> Vec { }) }) .params(r#"{"preset"?: "topLeft|topCenter|topRight|middleLeft|middleCenter|middleRight|bottomLeft|bottomCenter|bottomRight", "x"?: pt, "y"?: pt}"#) - .when(has_object), - CommandSpec::new("arrange.bringForward", "Bring Forward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::InFrontOfText)).when(has_object), - CommandSpec::new("arrange.sendBackward", "Send Backward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::BehindText)).when(has_object), + .when(has_movable), + CommandSpec::new("arrange.bringForward", "Bring Forward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::InFrontOfText)).when(has_movable), + CommandSpec::new("arrange.sendBackward", "Send Backward", "Layout › Arrange", |s, _| with_float(s, |f| f.wrap = Wrap::BehindText)).when(has_movable), CommandSpec::new("arrange.align", "Align", "Layout › Arrange", |s, v| { let h = p::str(v, "value").unwrap_or("center").to_string(); let tw = super::page::sect(s).text_width(); @@ -244,11 +255,11 @@ pub fn specs() -> Vec { }) }) .params(r#"{"value": "left|center|right"}"#) - .when(has_object), + .when(has_movable), CommandSpec::new("arrange.selectionPane", "Selection Pane", "Layout › Arrange", objects_list).pure(), CommandSpec::new("arrange.bounds", "Move or Resize", "Layout › Arrange", bounds) .params(r#"{"width"?: pt, "height"?: pt, "x"?: pt, "y"?: pt, "page"?: n} (x/y: the top-left on page `page` (0-based, default its page); moving an inline object floats it)"#) - .when(has_object), + .when(has_movable), CommandSpec::new("arrange.nudge", "Nudge", "Layout › Arrange", |s, v| { let d = |k| p::f32(v, k).unwrap_or(0.0).clamp(-MAX_OFFSET, MAX_OFFSET); let (dx, dy) = (d("dx"), d("dy")); @@ -315,7 +326,7 @@ pub fn object_selection(s: &Session) -> Option<(Pos, &InlineObject)> { return None; } let o = s.doc.para_at(&a)?.object_at(a.off)?; - matches!(o, InlineObject::Image { .. } | InlineObject::Shape { .. }).then_some((a, o)) + matches!(o, InlineObject::Image { .. } | InlineObject::Graphic { .. } | InlineObject::Shape { .. }).then_some((a, o)) } /// The first picture/shape in the selection, or just before a collapsed caret. @@ -334,7 +345,7 @@ pub fn selected(s: &Session) -> Option<(Pos, InlineObject)> { if !inside { continue; } - if let Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. })) = p.object_at(off) { + if let Some(o @ (InlineObject::Image { .. } | InlineObject::Graphic { .. } | InlineObject::Shape { .. })) = p.object_at(off) { return Some((Pos { story, path: path.clone(), off }, o.clone())); } } @@ -364,7 +375,9 @@ fn bounds(s: &mut Session, v: &Value) -> CmdResult { let h = p::f32(v, "height").unwrap_or(h0).clamp(min, MAX_OFFSET); if (w, h) != (w0, h0) { with_obj(s, |o| { - if let InlineObject::Image { w: ow, h: oh, .. } | InlineObject::Shape { w: ow, h: oh, .. } = o { + if let InlineObject::Image { w: ow, h: oh, .. } | InlineObject::Graphic { w: ow, h: oh, .. } | InlineObject::Shape { w: ow, h: oh, .. } = + o + { *ow = w; *oh = h; } @@ -448,7 +461,7 @@ fn move_object(s: &mut Session, pos: Pos, page: Option, x: f32, y: f32, (w, fn unalign(s: &mut Session) -> Result<(), CmdError> { use wordcraft_doc::para::Anchor; let (pos, obj) = selected(s).ok_or_else(|| CmdError::Disabled("no picture or shape selected".into()))?; - let (InlineObject::Image { float, .. } | InlineObject::Shape { float, .. }) = &obj else { return Ok(()) }; + let (InlineObject::Image { float, .. } | InlineObject::Graphic { float, .. } | InlineObject::Shape { float, .. }) = &obj else { return Ok(()) }; if float.h_align.is_none() && float.v_align.is_none() { return Ok(()); } @@ -498,7 +511,7 @@ fn nearest_line(layout: &wordcraft_layout::DocLayout, page: usize, y: f32) -> Op fn obj_size(o: &InlineObject) -> (f32, f32) { match o { - InlineObject::Image { w, h, .. } | InlineObject::Shape { w, h, .. } => (*w, *h), + InlineObject::Image { w, h, .. } | InlineObject::Graphic { w, h, .. } | InlineObject::Shape { w, h, .. } => (*w, *h), _ => (0.0, 0.0), } } @@ -521,7 +534,7 @@ fn edit_obj(s: &mut Session, pos: &Pos, f: impl Fn(&mut InlineObject)) -> Result fn edit_float(s: &mut Session, pos: &Pos, f: impl Fn(&mut Float)) -> Result { edit_obj(s, pos, |o| { - if let InlineObject::Image { float, .. } | InlineObject::Shape { float, .. } = o { + if let InlineObject::Image { float, .. } | InlineObject::Graphic { float, .. } | InlineObject::Shape { float, .. } = o { f(float); } }) @@ -529,7 +542,7 @@ fn edit_float(s: &mut Session, pos: &Pos, f: impl Fn(&mut Float)) -> Result CmdResult { with_obj(s, |o| match o { - InlineObject::Image { float, .. } | InlineObject::Shape { float, .. } => { + InlineObject::Image { float, .. } | InlineObject::Graphic { float, .. } | InlineObject::Shape { float, .. } => { let was_inline = float.wrap == Wrap::Inline; f(float); // Word's distance from text for a newly wrapped object: 0.125" at the sides. @@ -571,7 +584,7 @@ fn size(s: &mut Session, v: &Value) -> CmdResult { } let (w, h) = (w.clamp(1.0, 4000.0), h.clamp(1.0, 4000.0)); with_obj(s, |o| match o { - InlineObject::Image { w: ow, h: oh, .. } | InlineObject::Shape { w: ow, h: oh, .. } => { + InlineObject::Image { w: ow, h: oh, .. } | InlineObject::Graphic { w: ow, h: oh, .. } | InlineObject::Shape { w: ow, h: oh, .. } => { *ow = w; *oh = h; } @@ -742,7 +755,7 @@ fn all_objects(s: &Session) -> Vec<(Pos, InlineObject)> { for path in s.doc.para_paths(StoryRef::Body) { let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue }; for off in p.object_offsets() { - if let Some(o @ (InlineObject::Image { .. } | InlineObject::Shape { .. })) = p.object_at(off) { + if let Some(o @ (InlineObject::Image { .. } | InlineObject::Graphic { .. } | InlineObject::Shape { .. })) = p.object_at(off) { v.push((Pos { story: StoryRef::Body, path: path.clone(), off }, o.clone())); } } @@ -759,6 +772,7 @@ fn objects_list(s: &mut Session, _: &Value) -> CmdResult { let (kind, name) = match &o { InlineObject::Image { alt, .. } => ("picture", if alt.is_empty() { format!("Picture {}", i + 1) } else { alt.clone() }), InlineObject::Shape { kind, .. } => ("shape", format!("{kind:?} {}", i + 1)), + InlineObject::Graphic { graphic, .. } => ("graphic", format!("{:?} {}", graphic.kind, i + 1)), _ => ("object", format!("Object {}", i + 1)), }; json!({"index": i, "kind": kind, "name": name, "pos": super::pos_json(&pos), "size": obj_size(&o)}) diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 022e8e69a0..004033bb40 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -755,6 +755,12 @@ fn accessibility(s: &mut Session, _: &Value) -> CmdResult { { issues.push(json!({"kind": "error", "issue": "Missing alternative text", "where": path.0, "fix": "Select the picture and run picture.altText"})); } + // Charts and diagrams can't be given alt text yet (see objects.rs), so only report them. + if let InlineObject::Graphic { alt, .. } = o + && alt.trim().is_empty() + { + issues.push(json!({"kind": "error", "issue": "Chart or diagram has no alternative text", "where": path.0})); + } } if let Some(l) = s.doc.styles.resolve_para(&p.props).outline_level { if let Some(prev) = last_level diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 37ecfecd42..07d610ba17 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2139,3 +2139,40 @@ fn timestamps_come_from_the_clock() { // Fixed-width ISO strings sort by time. assert!(date >= cmd::iso_from_unix_secs(before) && date <= cmd::iso_from_unix_secs(cmd::now_unix()), "{date}"); } + +#[test] +fn charts_and_diagrams_can_be_selected_and_deleted_but_not_moved() { + use std::sync::Arc; + use wordcraft_doc::para::InlineObject; + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Before "})); + let chart = InlineObject::Graphic { w: 200.0, h: 100.0, alt: String::new(), float: Default::default(), graphic: Arc::new(Default::default()) }; + s.doc.insert_object(&Pos::body(0, 7), chart, &Default::default()).unwrap(); + let end = 7 + wordcraft_doc::para::OBJ.len_utf8(); + run(&mut s, "select.range", json!({"anchor": Pos::body(0, 7), "focus": Pos::body(0, end)})); + assert!(crate::cmd::objects::object_selection(&s).is_some(), "the chart is selected"); + for (id, v) in [ + ("arrange.bounds", json!({"width": 50})), + ("picture.size", json!({"width": 50})), + ("arrange.wrap", json!({"wrap": "square"})), + ("arrange.position", json!({"preset": "topLeft"})), + ("arrange.align", json!({"value": "left"})), + ("arrange.bringForward", json!({})), + ] { + assert!(s.run(id, &v).is_err(), "{id} must not change a chart"); + } + run(&mut s, "text.delete", json!({})); + assert!(s.doc.para_at(&Pos::body(0, 0)).is_some_and(|p| p.objects.is_empty()), "the chart is deleted"); +} + +#[test] +fn accessibility_reports_a_chart_without_alt_text() { + use std::sync::Arc; + use wordcraft_doc::para::InlineObject; + let mut s = s(); + let chart = InlineObject::Graphic { w: 200.0, h: 100.0, alt: " ".into(), float: Default::default(), graphic: Arc::new(Default::default()) }; + s.doc.insert_object(&Pos::body(0, 0), chart, &Default::default()).unwrap(); + let v = run(&mut s, "file.accessibility", json!({})); + let issues = v["issues"].as_array().expect("issues"); + assert!(issues.iter().any(|i| i["issue"] == "Chart or diagram has no alternative text"), "{issues:?}"); +} diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs index 9265081334..c6fefdc234 100644 --- a/crates/formats/src/html.rs +++ b/crates/formats/src/html.rs @@ -1237,6 +1237,8 @@ pub fn inlines_html(inlines: &[Inline]) -> String { )); } Inline::Anchor(a) => o.push_str(&format!("", esc(a))), + // No picture to show: the browser shows the alt text in its place. + Inline::Figure(alt) => o.push_str(&format!("\"{}\"", esc(alt))), Inline::Equation { .. } => {} } } diff --git a/crates/formats/src/latex.rs b/crates/formats/src/latex.rs index 961243e46e..fc1665581f 100644 --- a/crates/formats/src/latex.rs +++ b/crates/formats/src/latex.rs @@ -265,6 +265,7 @@ impl Writer { o.push_str(&format!("\\textit{{[{}]}}", escape_text(&alt))); } Inline::Anchor(name) => o.push_str(&format!("\\hypertarget{{{}}}{{}}", escape_url(name))), + Inline::Figure(_) => {} Inline::Equation { linear, display } => { self.uses.math = true; let src = linear_to_latex(linear); diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs index 88e90e10e6..79e332be14 100644 --- a/crates/formats/src/markdown.rs +++ b/crates/formats/src/markdown.rs @@ -1087,6 +1087,7 @@ fn inlines_md(inlines: &[Inline]) -> String { let safe: String = a.chars().filter(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | ':')).collect(); pieces.push((String::new(), Fmt::default(), Some(format!("")))); } + Inline::Figure(alt) => pieces.push((String::new(), Fmt::default(), Some(format!("![{}]()", escape(alt))))), Inline::Equation { .. } => {} } } diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index d04fac3a71..a3774284db 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -57,6 +57,9 @@ pub enum Inline { Image(Img), /// A bookmark (link target). Anchor(String), + /// A chart or diagram known here only by its alt text: written where alt text has a place + /// (an HTML or Markdown image), left out of editable formats so it never turns into body text. + Figure(String), /// An equation in the linear format WordCraft keeps them in (`x=(-b±√(b^2-4ac))/2a`). Formats /// without equations write `linear` as text. Equation { @@ -118,7 +121,7 @@ impl Para { for i in &self.inlines { match i { Inline::Text(t, _) | Inline::Equation { linear: t, .. } => s.push_str(t), - Inline::Image(_) | Inline::Anchor(_) => {} + Inline::Image(_) | Inline::Anchor(_) | Inline::Figure(_) => {} } } s @@ -368,6 +371,7 @@ impl Builder<'_> { let e2 = out.len(); let _ = out.insert_object(e2, InlineObject::BookmarkEnd { name: name.clone() }, &CharProps::default()); } + Inline::Figure(_) => {} Inline::Equation { linear, display } => { let linear = clean_text(linear); if !linear.is_empty() { @@ -688,6 +692,14 @@ pub fn flow_paras(doc: &Document, p: &Paragraph) -> Vec { None => {} } } + Some(InlineObject::Graphic { alt, .. }) => { + if !buf.is_empty() { + out.push_text(&std::mem::take(&mut buf), &f); + } + if !alt.trim().is_empty() { + out.inlines.push(Inline::Figure(alt.clone())); + } + } Some(InlineObject::Equation { linear, display, math }) => { if !buf.is_empty() { out.push_text(&std::mem::take(&mut buf), &f); diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index 21c361758d..9bacc65a19 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -247,7 +247,7 @@ impl Writer { prev_space = false; } Inline::Anchor(a) => out.push_str(&format!("", x(a))), - Inline::Equation { .. } => {} + Inline::Figure(_) | Inline::Equation { .. } => {} } } } diff --git a/crates/formats/src/rtf.rs b/crates/formats/src/rtf.rs index 3e9ac928da..13e2b887b4 100644 --- a/crates/formats/src/rtf.rs +++ b/crates/formats/src/rtf.rs @@ -187,7 +187,7 @@ impl Writer { let a = esc(a); out.push_str(&format!("{{\\*\\bkmkstart {a}}}{{\\*\\bkmkend {a}}}")); } - Inline::Equation { .. } => {} + Inline::Figure(_) | Inline::Equation { .. } => {} } } } diff --git a/crates/formats/src/tests.rs b/crates/formats/src/tests.rs index fcd36d4dcf..04438820af 100644 --- a/crates/formats/src/tests.rs +++ b/crates/formats/src/tests.rs @@ -538,3 +538,19 @@ fn html_img_sources_go_through_the_loader() { // Without a loader only data: URIs load. assert!(import("html", html.as_bytes()).unwrap().unwrap().media.is_empty()); } + +#[test] +fn chart_alt_text_is_an_image_alt_never_body_text() { + let mut p = Paragraph::with_text("See below", CharProps::default()); + let chart = InlineObject::Graphic { w: 100.0, h: 50.0, alt: "Sales".into(), float: Default::default(), graphic: Arc::new(Default::default()) }; + p.insert_object(4, chart, &CharProps::default()).unwrap(); + let mut d = Document::new(); + d.body = vec![para_block(p)]; + let text = |ext: &str| String::from_utf8(export(ext, &d).unwrap().unwrap()).unwrap(); + assert!(text("html").contains(r#"Sales"#), "{}", text("html")); + assert!(text("md").contains("![Sales]()"), "{}", text("md")); + for ext in ["txt", "rtf", "tex"] { + assert!(!text(ext).contains("Sales"), "{ext}: {}", text(ext)); + } + assert_eq!(para_texts(&d), ["See below"]); +} diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index 8cdf8ee533..3786fb2904 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -1,10 +1,11 @@ //! Page → draw items, shared by the raster renderer, the PDF exporter and thumbnails. +use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, PathSeg, TextAlign}; use wordcraft_doc::para::{InlineObject, ShapeKind}; use wordcraft_doc::props::{Border, BorderStyle, Rgb, TextColor, TextDirection, Underline}; use wordcraft_doc::section::SectionStart; use wordcraft_doc::{Block, Document, Path, StoryRef}; -use wordcraft_fonts::FaceRef; +use wordcraft_fonts::{BezPath, FaceRef}; use wordcraft_geom::Rect; use crate::math::MItem; @@ -66,6 +67,19 @@ pub enum Draw { stroke: Option, stroke_width: f32, }, + /// A vector path (a chart's or diagram's polygons, slices, lines): filled, then stroked. + Path { + segs: Vec, + fill: Option, + stroke: Option, + stroke_width: f32, + }, + /// An inline chart or diagram's draws, as one figure with its alt text (for tagged PDF). + Figure { + alt: String, + kind: GraphicKind, + draws: Vec, + }, /// A formatting mark (¶ · → ↵) in the UI's mark colour, or in `color` (a tracked /// paragraph mark in its reviser's colour). Mark { @@ -200,6 +214,7 @@ fn item(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, out: &mu Placed::Shape { rect, kind, fill, stroke, stroke_width } => { out.push(Draw::Shape { rect: *rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width }) } + Placed::Graphic { rect, graphic, .. } => out.extend(graphic_draws(doc, graphic, *rect, alpha)), Placed::Cell { .. } | Placed::Object { .. } => {} Placed::Lines { story, path, para, l0, l1, x, y, turn } if turn.is_turned() => { let mut items = Vec::new(); @@ -534,6 +549,9 @@ fn lines( Some(InlineObject::Shape { kind, fill, stroke, stroke_width, .. }) => { out.push(Draw::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width }) } + Some(InlineObject::Graphic { graphic, alt, .. }) => { + out.push(Draw::Figure { alt: alt.clone(), kind: graphic.kind, draws: graphic_draws(doc, graphic, rect, alpha) }) + } Some(InlineObject::Equation { .. }) => { if let Some((_, ml)) = pl.maths.iter().find(|(k, _)| *k == oi) { equation(&ml.items, cx, base, alpha, opts.placeholders, out); @@ -694,6 +712,134 @@ fn equation(items: &[MItem], x: f32, base: f32, alpha: f32, screen: bool, out: & } } +/// The draws of a chart or diagram's items inside `rect` (page coordinates). The items were built +/// for the size `g.w` × `g.h` (0: `rect`'s own size), so they are scaled to `rect`; strokes and text +/// by the mean of the two scales. +fn graphic_draws(doc: &Document, g: &Graphic, rect: Rect, alpha: f32) -> Vec { + let (sx, sy) = (scale_of(rect.w, g.w), scale_of(rect.h, g.h)); + let k = (sx + sy) / 2.0; + let inside = |r: &[f32; 4]| Rect::new(rect.x + r[0] * sx, rect.y + r[1] * sy, r[2] * sx, r[3] * sy); + let mut out = Vec::with_capacity(g.items.len()); + for it in &g.items { + match it { + GraphicItem::Shape { rect: r, kind, fill, stroke, stroke_width } => { + out.push(Draw::Shape { rect: inside(r), kind: *kind, fill: *fill, stroke: *stroke, stroke_width: stroke_width * k }) + } + GraphicItem::Path { segs, fill, stroke, stroke_width } => { + let segs = page_segs(segs, |x, y| (rect.x + x * sx, rect.y + y * sy)); + out.push(Draw::Path { segs, fill: *fill, stroke: *stroke, stroke_width: stroke_width * k }) + } + GraphicItem::Text { rect: r, .. } => out.extend(text_glyphs(doc, inside(r), it, alpha, k)), + GraphicItem::Image { rect: r, media } => out.push(Draw::Image { rect: inside(r), media: media.clone(), crop: [0.0; 4], alpha }), + } + } + out +} + +/// Scale from the size `built` points a graphic's items were made for to `len` points; 1 when +/// the built size is unknown (0). +fn scale_of(len: f32, built: f32) -> f32 { + if built.is_finite() && built > 0.0 && len.is_finite() { (len / built).max(0.0) } else { 1.0 } +} + +/// Path segments mapped to page coordinates by `map`, cleaned for the rasterizer and PDF: a segment +/// with a non-finite number is dropped, and so is anything before a move (or after a broken one). +fn page_segs(segs: &[PathSeg], map: impl Fn(f32, f32) -> (f32, f32)) -> Vec { + let fin = |p: (f32, f32)| p.0.is_finite() && p.1.is_finite(); + let mut out = Vec::with_capacity(segs.len()); + let mut open = false; + for s in segs { + match *s { + PathSeg::Move(x, y) => { + let p = map(x, y); + open = fin(p); + if open { + out.push(PathSeg::Move(p.0, p.1)); + } + } + PathSeg::Line(x, y) => { + let p = map(x, y); + if open && fin(p) { + out.push(PathSeg::Line(p.0, p.1)); + } + } + PathSeg::Cubic(a, b, c, d, e, f) => { + let (p1, p2, p3) = (map(a, b), map(c, d), map(e, f)); + if open && fin(p1) && fin(p2) && fin(p3) { + out.push(PathSeg::Cubic(p1.0, p1.1, p2.0, p2.1, p3.0, p3.1)); + } + } + PathSeg::Close => { + if open { + out.push(PathSeg::Close); + } + open = false; + } + } + } + out +} + +/// A path of clean segments (see [`page_segs`]) as a kurbo path, for the rasteriser and PDF. +pub fn seg_path(segs: &[PathSeg]) -> BezPath { + let mut p = BezPath::new(); + for s in segs { + match *s { + PathSeg::Move(x, y) => p.move_to((x as f64, y as f64)), + PathSeg::Line(x, y) => p.line_to((x as f64, y as f64)), + PathSeg::Cubic(a, b, c, d, e, f) => p.curve_to((a as f64, b as f64), (c as f64, d as f64), (e as f64, f as f64)), + PathSeg::Close => p.close_path(), + } + } + p +} + +/// One line of a `GraphicItem::Text` in `r`, shaped in its font (the document's body font when +/// unset) and aligned horizontally, centred vertically, at its size times `k`. `None` when there is +/// nothing to draw, or the item's rectangle or size isn't a finite number (a degenerate scale: +/// `r` itself is always finite, as [`Rect::new`] makes it). +fn text_glyphs(doc: &Document, r: Rect, it: &GraphicItem, alpha: f32, k: f32) -> Option { + let GraphicItem::Text { rect, text, size, color, bold, align, font } = it else { return None }; + let size = *size * k; + if !rect.iter().chain([&size]).all(|v| v.is_finite()) { + return None; + } + let family = font.as_deref().filter(|f| !f.is_empty()).unwrap_or(&doc.settings.minor_font); + let size = size.clamp(1.0, 400.0); + let resolved = wordcraft_fonts::word::resolve(family, *bold, false); + let face = resolved.face.get(); + let shaped = wordcraft_fonts::shape(face, text, &[], |c| c); + if shaped.is_empty() { + return None; + } + let k = size / face.upem.max(1.0) as f32; + let width: f32 = shaped.iter().map(|g| g.x_advance as f32 * k).sum(); + let (ascent, descent) = wordcraft_fonts::word::line_metrics(face); + let baseline = r.y + r.h / 2.0 + (ascent - descent) as f32 * k / 2.0; + let mut pen = match align { + TextAlign::Left => r.x, + TextAlign::Center => r.x + (r.w - width) / 2.0, + TextAlign::Right => r.x + r.w - width, + }; + let mut glyphs = Vec::with_capacity(shaped.len()); + for g in &shaped { + glyphs.push((g.gid, pen + g.x_offset as f32 * k, baseline - g.y_offset as f32 * k)); + pen += g.x_advance as f32 * k; + } + Some(Draw::Glyphs { + face: resolved.face, + size, + glyphs, + color: *color, + alpha, + synth_bold: resolved.synth_bold, + synth_italic: false, + text: text.clone(), + link: None, + ranges: Vec::new(), + }) +} + fn author_index(doc: &Document, author: &str) -> u32 { let mut seen: Vec<&str> = Vec::new(); for r in &doc.revisions { @@ -722,7 +868,7 @@ pub fn text_color(c: &TextColor, background: Option) -> Rgb { /// baseline at `cx`): inside the room kept for its effects. pub(crate) fn inline_rect(obj: Option<&InlineObject>, cx: f32, base: f32, adv: f32, obj_h: f32) -> Rect { let [l, t, r, b] = match obj { - Some(InlineObject::Image { float, .. } | InlineObject::Shape { float, .. }) => float.effect_extent(), + Some(InlineObject::Image { float, .. } | InlineObject::Graphic { float, .. } | InlineObject::Shape { float, .. }) => float.effect_extent(), _ => [0.0; 4], }; Rect::new(cx + l, base - obj_h + t, (adv - l - r).max(0.0), (obj_h - t - b).max(0.0)) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index a0faa6191c..cea9db790b 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -96,6 +96,15 @@ pub enum Placed { stroke: Option, stroke_width: f32, }, + /// A floating chart or diagram, drawn from its items inside `rect`. The object is the U+FFFC at + /// byte `off` of paragraph `path` (its alt text). + Graphic { + rect: Rect, + graphic: Arc, + story: StoryRef, + path: Path, + off: usize, + }, /// A table cell's area (for hit testing and cell selection). Cell { rect: Rect, @@ -128,7 +137,11 @@ impl Placed { *x += dx; *y += dy; } - Placed::Fill { rect, .. } | Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Cell { rect, .. } => { + Placed::Fill { rect, .. } + | Placed::Image { rect, .. } + | Placed::Shape { rect, .. } + | Placed::Graphic { rect, .. } + | Placed::Cell { rect, .. } => { rect.x += dx; rect.y += dy; } @@ -174,7 +187,11 @@ impl Placed { *t = turn; } } - Placed::Fill { rect, .. } | Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Cell { rect, .. } => { + Placed::Fill { rect, .. } + | Placed::Image { rect, .. } + | Placed::Shape { rect, .. } + | Placed::Graphic { rect, .. } + | Placed::Cell { rect, .. } => { *rect = turn_rect(turn, x, y, *rect); } Placed::Object { rect, origin, .. } => { @@ -702,7 +719,13 @@ fn fit_box(items: Vec, height: f32) -> Vec { Placed::Rule { x0, y0, x1, y1, border } if y0.min(y1) < limit => { out.push(Placed::Rule { x0, y0: y0.min(limit), x1, y1: y1.min(limit), border }); } - Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Cell { rect, .. } | Placed::Object { rect, .. } if rect.y < limit => { + Placed::Image { rect, .. } + | Placed::Shape { rect, .. } + | Placed::Graphic { rect, .. } + | Placed::Cell { rect, .. } + | Placed::Object { rect, .. } + if rect.y < limit => + { out.push(it); } _ => {} @@ -1117,10 +1140,14 @@ struct PageFrame<'a> { origin: (f32, f32), } -/// A floating (not inline) picture or shape: its size and placement. +/// A floating (not inline) picture, chart or shape: its size and placement. fn floating(o: &InlineObject) -> Option<(f32, f32, &Float)> { match o { - InlineObject::Image { w, h, float, .. } | InlineObject::Shape { w, h, float, .. } if float.wrap != Wrap::Inline => Some((*w, *h, float)), + InlineObject::Image { w, h, float, .. } | InlineObject::Graphic { w, h, float, .. } | InlineObject::Shape { w, h, float, .. } + if float.wrap != Wrap::Inline => + { + Some((*w, *h, float)) + } _ => None, } } @@ -1194,6 +1221,7 @@ fn float_item(o: &InlineObject, rect: Rect, story: StoryRef, path: &[u32], off: InlineObject::Shape { kind, fill, stroke, stroke_width, .. } => { Some(Placed::Shape { rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width }) } + InlineObject::Graphic { graphic, .. } => Some(Placed::Graphic { rect, graphic: graphic.clone(), story, path: Path(path.to_vec()), off }), _ => None, } } @@ -1275,7 +1303,12 @@ fn place_objects( if left_out(oi) { continue; } - let Some(obj @ (InlineObject::Image { w, h, float, .. } | InlineObject::Shape { w, h, float, .. })) = p.objects.get(oi) else { continue }; + let Some( + obj @ (InlineObject::Image { w, h, float, .. } | InlineObject::Graphic { w, h, float, .. } | InlineObject::Shape { w, h, float, .. }), + ) = p.objects.get(oi) + else { + continue; + }; let floating = float.wrap != Wrap::Inline; let rect = if floating { floats.get(&oi).copied().unwrap_or_else(|| float_rect(at.page, at.col, at.para_y, *w, *h, float)) @@ -1770,7 +1803,7 @@ fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &Secti /// Is an object floating (not laid out inline)? pub fn is_floating(o: &InlineObject) -> bool { match o { - InlineObject::Image { float, .. } | InlineObject::Shape { float, .. } => float.wrap != Wrap::Inline, + InlineObject::Image { float, .. } | InlineObject::Graphic { float, .. } | InlineObject::Shape { float, .. } => float.wrap != Wrap::Inline, _ => false, } } diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index ef06280c7d..23de1ed383 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -732,7 +732,9 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { let k = obj_index; obj_index += 1; match p.objects.get(k) { - Some(InlineObject::Image { w, h, float, .. }) | Some(InlineObject::Shape { w, h, float, .. }) => { + Some(InlineObject::Image { w, h, float, .. }) + | Some(InlineObject::Graphic { w, h, float, .. }) + | Some(InlineObject::Shape { w, h, float, .. }) => { if float.wrap == wordcraft_doc::para::Wrap::Inline { let maxw = (env.width - rp.indent_left.max(0.0) - rp.indent_right.max(0.0)).max(18.0); let (w, h) = (w.clamp(1.0, 4000.0), h.clamp(1.0, 4000.0)); diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 83c2cb9672..ae6557038d 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -468,7 +468,7 @@ pub fn split_row(row: &RowLayout, cut: f32) -> Option<(RowLayout, RowLayout)> { } } } - Placed::Image { rect, .. } | Placed::Shape { rect, .. } => { + Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Graphic { rect, .. } => { if rect.bottom() <= cut + 0.01 { kept_any = true; } else { @@ -535,7 +535,7 @@ pub fn split_row(row: &RowLayout, cut: f32) -> Option<(RowLayout, RowLayout)> { }); } } - Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Object { rect, .. } => { + Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Graphic { rect, .. } | Placed::Object { rect, .. } => { if rect.bottom() <= cut + 0.01 { a.push(it.clone()); } else { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 01a12782ac..8e4c5976cc 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -2376,6 +2376,158 @@ fn hidden_float_in_a_text_box_is_not_placed() { assert_eq!((shown - hidden_n, shown_areas - hidden_areas), (1, 1), "shown {shown}/{shown_areas}, hidden {hidden_n}/{hidden_areas}"); } +#[test] +fn inline_graphic_reserves_its_height() { + let mut d = Document::from_text("Chart\nAfter"); + let obj = InlineObject::Graphic { w: 216.0, h: 144.0, alt: String::new(), float: Default::default(), graphic: Arc::new(Default::default()) }; + d.insert_object(&Pos::body(0, 0), obj, &Default::default()).unwrap(); + let l = lay(&d); + let tops: Vec = l.pages[0].items.iter().filter_map(|i| if let Placed::Lines { y, .. } = i { Some(*y) } else { None }).collect(); + assert_eq!(tops.len(), 2); + assert!(tops[1] - tops[0] >= 144.0, "the next paragraph starts below the chart: {tops:?}"); +} + +#[test] +fn graphic_items_draw_inside_the_object() { + use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, PathSeg, TextAlign}; + use wordcraft_doc::props::Rgb; + let items = vec![ + GraphicItem::Shape { + rect: [10.0, 5.0, 20.0, 20.0], + kind: wordcraft_doc::para::ShapeKind::Rectangle, + fill: Some(Rgb::BLACK), + stroke: None, + stroke_width: 0.0, + }, + GraphicItem::Path { + segs: vec![PathSeg::Move(1.0, 2.0), PathSeg::Line(30.0, 40.0), PathSeg::Close], + fill: None, + stroke: Some(Rgb::BLACK), + stroke_width: 0.0, + }, + GraphicItem::Text { + rect: [0.0, 100.0, 216.0, 20.0], + text: "Q1".into(), + size: 10.0, + color: Rgb::BLACK, + bold: false, + align: TextAlign::Center, + font: None, + }, + ]; + let mut d = Document::from_text("Chart"); + let graphic = Graphic { kind: GraphicKind::Chart, items, ..Default::default() }; + let obj = InlineObject::Graphic { w: 216.0, h: 144.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) }; + d.insert_object(&Pos::body(0, 0), obj, &Default::default()).unwrap(); + let l = lay(&d); + // The chart's draws sit inside its figure. + let draws = flat(display::page_display(&d, &l.pages[0], &Default::default())); + let shape = draws.iter().find_map(|x| if let display::Draw::Shape { rect, .. } = x { Some(*rect) } else { None }).expect("a shape"); + let first = draws.iter().find_map(|x| if let display::Draw::Path { segs, .. } = x { segs.first().copied() } else { None }).expect("a path"); + let PathSeg::Move(mx, my) = first else { panic!("{first:?}") }; + // Path points are page coordinates, moved with the object like the shape's rectangle. + assert_eq!((mx - shape.x, my - shape.y), (1.0 - 10.0, 2.0 - 5.0)); + assert!(draws.iter().any(|x| matches!(x, display::Draw::Glyphs { text, .. } if text == "Q1"))); +} + +#[test] +fn graphic_text_in_a_degenerate_rectangle_is_not_drawn() { + use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, TextAlign}; + use wordcraft_doc::props::Rgb; + let text = |rect: [f32; 4], size: f32| GraphicItem::Text { + rect, + text: "Q1".into(), + size, + color: Rgb::BLACK, + bold: false, + align: TextAlign::Center, + font: None, + }; + let items = vec![text([f32::NAN, 0.0, 10.0, 10.0], 10.0), text([0.0, 0.0, f32::INFINITY, 10.0], 10.0), text([0.0, 0.0, 10.0, 10.0], f32::NAN)]; + let graphic = Graphic { kind: GraphicKind::Diagram, items, ..Default::default() }; + let obj = InlineObject::Graphic { w: 216.0, h: 144.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(); + let draws = flat(display::page_display(&d, &lay(&d).pages[0], &Default::default())); + assert!(!draws.iter().any(|x| matches!(x, display::Draw::Glyphs { text, .. } if text == "Q1"))); +} + +/// The draws with figures opened up. +fn flat(draws: Vec) -> Vec { + draws.into_iter().flat_map(|x| if let display::Draw::Figure { draws, .. } = x { draws } else { vec![x] }).collect() +} + +#[test] +fn inline_chart_is_a_figure_with_its_alt_text() { + use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, PathSeg}; + use wordcraft_doc::props::Rgb; + let items = vec![GraphicItem::Path { + segs: vec![PathSeg::Move(1.0, 1.0), PathSeg::Line(2.0, 2.0)], + fill: None, + stroke: Some(Rgb::BLACK), + stroke_width: 1.0, + }]; + let graphic = Graphic { kind: GraphicKind::Chart, items, ..Default::default() }; + let obj = InlineObject::Graphic { w: 216.0, h: 144.0, alt: "Sales by quarter".into(), 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(); + let draws = display::page_display(&d, &lay(&d).pages[0], &Default::default()); + let figure = draws.iter().find_map(|x| if let display::Draw::Figure { alt, draws, .. } = x { Some((alt.clone(), draws.len())) } else { None }); + assert_eq!(figure, Some(("Sales by quarter".to_string(), 1))); +} + +#[test] +fn graphic_paths_drop_segments_that_cannot_be_drawn() { + use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, PathSeg}; + use wordcraft_doc::props::Rgb; + // The line before the first move, the move with a NaN, the infinite line and the line after the + // close are all dropped. + let segs = vec![ + PathSeg::Line(5.0, 5.0), + PathSeg::Move(f32::NAN, 1.0), + PathSeg::Move(1.0, 2.0), + PathSeg::Line(3.0, 4.0), + PathSeg::Line(f32::INFINITY, 0.0), + PathSeg::Close, + PathSeg::Line(9.0, 9.0), + ]; + let items = vec![GraphicItem::Path { segs, fill: None, stroke: Some(Rgb::BLACK), stroke_width: 1.0 }]; + let graphic = Graphic { kind: GraphicKind::Chart, items, ..Default::default() }; + let obj = InlineObject::Graphic { w: 216.0, h: 144.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(); + let draws = flat(display::page_display(&d, &lay(&d).pages[0], &Default::default())); + let segs: Vec = + draws.iter().find_map(|x| if let display::Draw::Path { segs, .. } = x { Some(segs.clone()) } else { None }).expect("a path"); + assert!(matches!(segs.as_slice(), [PathSeg::Move(x, y), PathSeg::Line(..), PathSeg::Close] if x.is_finite() && y.is_finite()), "{segs:?}"); +} + +#[test] +fn oversized_graphic_draws_shrunk_with_its_items() { + use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind}; + use wordcraft_doc::props::Rgb; + // A 1000 x 500 pt chart is wider than the text: layout shrinks it, and its items with it. + let items = vec![GraphicItem::Shape { + rect: [0.0, 0.0, 1000.0, 500.0], + kind: wordcraft_doc::para::ShapeKind::Rectangle, + fill: None, + stroke: Some(Rgb::BLACK), + stroke_width: 4.0, + }]; + let graphic = Graphic { kind: GraphicKind::Chart, items, w: 1000.0, h: 500.0 }; + 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(); + let draws = flat(display::page_display(&d, &lay(&d).pages[0], &Default::default())); + let (r, stroke) = draws + .iter() + .find_map(|x| if let display::Draw::Shape { rect, stroke_width, .. } = x { Some((*rect, *stroke_width)) } else { None }) + .expect("a shape"); + assert!(r.w < 1000.0, "{r:?}"); + assert!((r.h / r.w - 0.5).abs() < 1e-3, "{r:?}"); + assert!((stroke / r.w - 4.0 / 1000.0).abs() < 1e-5, "stroke {stroke} at width {}", r.w); +} + /// The paragraph at `path` on page 0: its first line's x, its y and its line count. fn para_at(l: &DocLayout, path: &[u32]) -> Vec<(f32, f32, usize)> { l.pages[0] diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index f7119500b3..c4f04927ac 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -41,6 +41,7 @@ use krilla::paint::{Fill, FillRule, LineCap, LineJoin, Stroke, StrokeDash}; use krilla::surface::Surface; use krilla::tagging::{Artifact, ArtifactType, ContentTag, Identifier, Node, SpanTag, Tag, TagGroup, TagKind, TagTree}; use krilla::text::{Font, GlyphId, KrillaGlyph}; +use wordcraft_doc::graphic::GraphicKind; use wordcraft_doc::para::{InlineObject, ShapeKind}; use wordcraft_doc::{Document, Path as DocPath, Rgb, StoryRef}; use wordcraft_fonts::FaceRef; @@ -197,6 +198,11 @@ fn glyph_outlines(face: &FaceRef, size: f32, glyphs: &[(u32, f32, f32)]) -> Opti pb.finish() } +/// A chart's or diagram's alt text: its own, else what it is ("chart", "diagram"). +fn graphic_alt(alt: &str, kind: GraphicKind) -> &str { + if alt.trim().is_empty() { kind.noun() } else { alt } +} + /// Add the segments of a kurbo path to a krilla path. fn append_path(pb: &mut PathBuilder, path: &kurbo::BezPath) { let f = |v: f64| v as f32; @@ -570,6 +576,12 @@ impl Exporter<'_> { match d { Draw::Glyphs { .. } | Draw::Turned { .. } => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)), Draw::Image { .. } => self.tagged(s, Role::Figure, None, |me, s| me.draw(s, d)), + // An inline chart or diagram: its text is part of the figure, not the paragraph. + Draw::Figure { alt, kind, draws: inner } => self.tagged(s, Role::Figure, Some(graphic_alt(alt, *kind)), |me, s| { + for d in inner { + me.draw(s, d); + } + }), _ => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)), } } @@ -585,6 +597,17 @@ impl Exporter<'_> { } }); } + Placed::Graphic { story, path, off, graphic, .. } => { + let alt = match self.doc.para(*story, path).and_then(|p| p.object_at(*off)) { + Some(InlineObject::Graphic { alt, .. }) => graphic_alt(alt, graphic.kind).to_string(), + _ => graphic.kind.noun().to_string(), + }; + self.tagged(s, Role::Figure, Some(&alt), |me, s| { + for d in &draws { + me.draw(s, d); + } + }); + } _ => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| { for d in &draws { me.draw(s, d); @@ -643,6 +666,11 @@ impl Exporter<'_> { fn draw(&mut self, s: &mut Surface, d: &Draw) { match d { + Draw::Figure { draws, .. } => { + for d in draws { + self.draw(s, d); + } + } Draw::Glyphs { face, size, glyphs, color, alpha, synth_bold, synth_italic, text, link, ranges } => { self.glyphs(s, face, *size, glyphs, *color, *alpha, *synth_bold, *synth_italic, text, ranges); if let Some(l) = link { @@ -659,6 +687,23 @@ impl Exporter<'_> { } Draw::Line { x0, y0, x1, y1, width, color, stroke, alpha } => self.line(s, (*x0, *y0, *x1, *y1), *width, *color, *stroke, *alpha), Draw::Image { rect, media, crop, alpha } => self.picture(s, rect, media, crop, *alpha), + Draw::Path { segs, fill: f, stroke, stroke_width } => { + let mut pb = PathBuilder::new(); + append_path(&mut pb, &wordcraft_layout::display::seg_path(segs)); + let Some(p) = pb.finish() else { return }; + s.set_fill(f.map(|c| fill(c, 1.0))); + s.set_stroke(stroke.map(|c| Stroke { + paint: rgb::Color::new(c.0, c.1, c.2).into(), + // Width 0 (or none) is a hairline. + width: if stroke_width.is_finite() && *stroke_width > 0.0 { stroke_width.clamp(0.25, 200.0) } else { 0.75 }, + ..Default::default() + })); + if f.is_some() || stroke.is_some() { + s.draw_path(&p); + } + s.set_fill(None); + s.set_stroke(None); + } Draw::Shape { rect, kind, fill: f, stroke, stroke_width } => { let Some(p) = shape_path(*kind, rect) else { return }; let can_fill = *kind != ShapeKind::Line; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index d9d2fdc7cc..b6591c6cd5 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -582,6 +582,42 @@ fn turned_cell_text_exports_as_text() { } } +#[test] +fn inline_chart_is_a_figure_with_its_text_inside_it() { + use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, TextAlign}; + use wordcraft_doc::props::Rgb; + let items = vec![GraphicItem::Text { + rect: [0.0, 0.0, 216.0, 20.0], + text: "Q1".into(), + size: 10.0, + color: Rgb::BLACK, + bold: false, + align: TextAlign::Center, + font: None, + }]; + let graphic = Graphic { kind: GraphicKind::Chart, items, ..Default::default() }; + let obj = InlineObject::Graphic { w: 216.0, h: 144.0, alt: "Sales by quarter".into(), float: Default::default(), graphic: Arc::new(graphic) }; + let mut d = Document::from_text("Chart"); + d.insert_object(&wordcraft_doc::Pos::body(0, 0), obj, &Default::default()).unwrap(); + let raw = |tagged| String::from_utf8_lossy(&export(&d, &PdfOptions { tagged, compress: false, ..Default::default() }).unwrap()).into_owned(); + assert!(raw(true).contains("/Figure"), "tagged chart is a figure"); + assert!(!raw(false).contains("/Figure")); +} + +#[test] +fn chart_without_alt_text_is_a_figure_called_chart() { + use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, PathSeg}; + let segs = vec![PathSeg::Move(1.0, 1.0), PathSeg::Line(50.0, 50.0)]; + let items = vec![GraphicItem::Path { segs, fill: None, stroke: Some(wordcraft_doc::props::Rgb::BLACK), stroke_width: 1.0 }]; + let graphic = Graphic { kind: GraphicKind::Chart, items, ..Default::default() }; + let obj = InlineObject::Graphic { w: 216.0, h: 144.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) }; + let mut d = Document::from_text("Chart"); + d.insert_object(&wordcraft_doc::Pos::body(0, 0), obj, &Default::default()).unwrap(); + let raw = String::from_utf8_lossy(&export(&d, &PdfOptions { tagged: true, compress: false, ..Default::default() }).unwrap()).into_owned(); + assert!(raw.contains("/Alt(chart)"), "no chart alt text"); + assert!(!raw.contains("/Alt(picture)")); +} + /// A 25 mm square EMF with a red rectangle over its right half (device units 0.04 per 0.01 mm). fn red_right_half_emf() -> Vec { fn rec(typ: u32, payload: &[u8]) -> Vec { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 1d30cdccc4..d0917dfca2 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -453,6 +453,11 @@ pub fn stem_darkening(ppem: f64) -> f64 { fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visible: &kurbo::Rect, opts: &RenderOptions) { match it { + Draw::Figure { draws, .. } => { + for d in draws { + draw(ctx, doc, d, view, visible, opts); + } + } Draw::Fill { rect, color: c, alpha } => { let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64); if !r.overlaps(*visible) { @@ -524,7 +529,9 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl } for (gid, x, y) in glyphs { let (gx, gy) = (*x as f64, *y as f64); - if gx < visible.x0 - 100.0 || gx > visible.x1 || gy < visible.y0 || gy > visible.y1 + 200.0 { + // Written so a NaN position is skipped too. + let near = gx >= visible.x0 - 100.0 && gx <= visible.x1 && gy >= visible.y0 && gy <= visible.y1 + 200.0; + if !near { continue; } let o = db.outline(face, *gid); @@ -614,6 +621,24 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl ctx.pop_layer(); } } + Draw::Path { segs, fill, stroke, stroke_width } => { + let path = wordcraft_layout::display::seg_path(segs); + if path.elements().is_empty() { + return; + } + ctx.set_transform(view); + if let Some(f) = fill { + ctx.set_paint(color(opts.ink(*f), 1.0)); + ctx.fill_path(&path); + } + if let Some(s) = stroke { + // Width 0 (or none) is a hairline. + let w = if stroke_width.is_finite() && *stroke_width > 0.0 { stroke_width.clamp(0.25, 200.0) } else { 0.75 }; + ctx.set_paint(color(opts.ink(*s), 1.0)); + ctx.set_stroke(kurbo::Stroke::new(w as f64)); + ctx.stroke_path(&path); + } + } Draw::Shape { rect, kind, fill, stroke, stroke_width } => { let r = kurbo::Rect::new(rect.x as f64, rect.y as f64, rect.right() as f64, rect.bottom() as f64); let path = shape_path(*kind, r); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c08dcd696f..8fe27be905 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -252,6 +252,9 @@ fn page_key(app: &WordApp, page: &Page, scale_px: f32, dim_body: bool) -> u64 { Placed::Rule { x0, y0, x1, y1, border } => format!("{x0}{y0}{x1}{y1}{border:?}").hash(&mut h), Placed::Image { rect, media, .. } => format!("{rect:?}{media}").hash(&mut h), Placed::Shape { rect, kind, fill, stroke, .. } => format!("{rect:?}{kind:?}{fill:?}{stroke:?}").hash(&mut h), + Placed::Graphic { rect, graphic, .. } => { + (std::sync::Arc::as_ptr(graphic) as usize, rect.x.to_bits(), rect.y.to_bits(), rect.w.to_bits(), rect.h.to_bits()).hash(&mut h) + } Placed::Cell { .. } | Placed::Object { .. } => {} } } diff --git a/crates/ui-egui/src/objects.rs b/crates/ui-egui/src/objects.rs index 3538efef70..6477f82753 100644 --- a/crates/ui-egui/src/objects.rs +++ b/crates/ui-egui/src/objects.rs @@ -39,6 +39,14 @@ pub fn selected(app: &WordApp) -> Option<(Pos, &InlineObject)> { wordcraft_engine::cmd::objects::object_selection(&app.session).filter(|(p, _)| p.story == StoryRef::Body) } +/// Whether the object under `hit` keeps its place and size: charts and diagrams can be selected +/// and deleted, not moved or resized (they aren't written back on save yet), so they get no +/// handles, no move cursor and no drag. +fn fixed(app: &WordApp, hit: &ObjectHit) -> bool { + let pos = hit.pos(); + matches!(app.session.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)), Some(InlineObject::Graphic { .. })) +} + /// The story of the selected text box. fn selected_text_box(app: &WordApp) -> Option { match selected(app)? { @@ -94,8 +102,8 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect] Some((_, o)) => (wordcraft_engine::cmd::objects::min_size(o), matches!(o, InlineObject::Image { .. })), None => return true, }; - // Objects in table cells can be selected and resized, not moved. - if grab != Grab::Move || movable(&object) { + // Objects in table cells can be selected and resized, not moved; charts neither. + if !fixed(app, &object) && (grab != Grab::Move || movable(&object)) { app.canvas.obj_drag = Some(ObjectDrag { drag: Drag::new(grab, object.page, object.rect, at), object, min, picture }); } } @@ -104,7 +112,7 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, pages: &[Rect] /// What a press at `at` would grab: a handle of the shown frame, else an object. fn grab_at(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: Pos2) -> Option<(ObjectHit, Grab)> { - let handle = active(app, layout).filter(|(o, _)| o.story == StoryRef::Body).and_then(|(o, _)| { + let handle = active(app, layout).filter(|(o, _)| o.story == StoryRef::Body && !fixed(app, o)).and_then(|(o, _)| { let f = screen(pages, scale, o.page, o.rect)?; frame::handle_at(f, at).map(|h| (o, Grab::Resize(h))) }); @@ -156,8 +164,8 @@ pub fn paint(app: &WordApp, painter: &Painter, t: &Tokens, layout: &DocLayout, p if let Some((o, editing)) = active(app, layout) && let Some(f) = screen(pages, scale, o.page, o.rect) { - // Handles where it can be resized (body objects). - frame::paint(painter, t, f, editing, o.story == StoryRef::Body); + // Handles where it can be resized (body objects, not charts): else a plain frame. + frame::paint(painter, t, f, editing, o.story == StoryRef::Body && !fixed(app, &o)); } } @@ -169,7 +177,7 @@ pub fn cursor(app: &WordApp, layout: &DocLayout, pages: &[Rect], scale: f32, at: if crate::canvas::editing_header_footer(app, layout) { return None; } - grab_at(app, layout, pages, scale, at).map(|(_, g)| g.cursor()) + grab_at(app, layout, pages, scale, at).map(|(o, g)| if fixed(app, &o) { egui::CursorIcon::Default } else { g.cursor() }) } /// Keys for a selected object: arrows nudge a floating one; typing or Enter goes into a text box. @@ -197,3 +205,41 @@ pub fn enter_text_box(app: &mut WordApp) -> bool { let end = app.session.doc.end_of(StoryRef::Part(id)); app.run("caret.set", json!({"pos": end})).is_ok() } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use wordcraft_doc::para::{InlineObject, ShapeKind}; + use wordcraft_doc::{Document, Pos}; + + use super::*; + + /// An app whose first paragraph holds `obj`, and where it's laid out. + fn app_with(obj: InlineObject) -> (WordApp, ObjectHit) { + let mut app = WordApp::new(wordcraft_engine::Session::new(Document::new()), crate::Services::default()); + app.session.doc.insert_object(&Pos::body(0, 0), obj, &Default::default()).unwrap(); + let hit = app.session.layout().object(&Pos::body(0, 0), 0).expect("laid out"); + (app, hit) + } + + #[test] + fn charts_get_no_handles_or_drag_but_shapes_do() { + let chart = + InlineObject::Graphic { w: 200.0, h: 100.0, alt: String::new(), float: Default::default(), graphic: Arc::new(Default::default()) }; + let (app, hit) = app_with(chart); + assert!(fixed(&app, &hit)); + let shape = InlineObject::Shape { + kind: ShapeKind::Rectangle, + w: 50.0, + h: 50.0, + fill: None, + stroke: None, + stroke_width: 1.0, + float: Default::default(), + story: None, + }; + let (app, hit) = app_with(shape); + assert!(!fixed(&app, &hit)); + } +}