mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8afaf3f90f | ||
|
|
7d8bb05374 | ||
|
|
ce1c7b7759 | ||
|
|
5c82c08847 | ||
|
|
67b7f82cff | ||
|
|
b585027de2 | ||
|
|
1660982e58 | ||
|
|
f6c143ac85 | ||
|
|
a68da9f51d | ||
|
|
cdfaaf4a38 | ||
|
|
10d3333344 | ||
|
|
bb6f0dc88a | ||
|
|
c4f355773d | ||
|
|
d4bc8812a3 | ||
|
|
dc8a884389 |
@@ -133,6 +133,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes |
|
||||
| 2026-10-12 | Text keys like PowerPoint for Windows (#268): Ctrl+arrows and Ctrl+Shift+arrows by word (punctuation, CJK, emoji, RTL), Ctrl+Backspace/Delete with smart spacing, Ctrl+Up/Down by paragraph, Ctrl+Home/End, End on wrapped lines, Ctrl+]/[ and Ctrl+Shift+>/< font size, Shift+F3 case cycle, word and paragraph selection |
|
||||
| 2026-10-12 | EMF/WMF pictures draw their text (fonts, alignment, dx advances, escapement, symbol fonts), pattern and hatch brushes, PATBLT and the XOR/AND see-through shadow; legacy OLE objects show their VML preview and ActiveX controls their pictures |
|
||||
| 2026-10-12 | File tab (Backstage) like PowerPoint's (#76): File or Alt+F opens Home, New, Open, Info, Save, Save As, Print, Export, Close, Account and Options over the window, Esc or Back returns, arrows walk the side menu; the start screen is File › Home. Pages run the existing file commands; new `file.info`, print slide ranges (`"1-3, 5"`), Print through a PDF, Ctrl+P, and Close/Ctrl+W ask about unsaved changes |
|
||||
| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip |
|
||||
| 2026-10-12 | Render: line widths, dashes and arrowheads inside a group no longer scale with the group's child-coordinate mapping, so a group in a small legacy child space (scale about 1600) draws thin lines instead of covering the slide (#233) |
|
||||
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
|
||||
@@ -146,6 +147,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
|
||||
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials |
|
||||
| 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% |
|
||||
|
||||
@@ -15,7 +15,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as),
|
||||
cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as),
|
||||
cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd),
|
||||
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, dpi?, slides?: [index], includeHidden?, textLayer?, frame?}", has_doc, export),
|
||||
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, dpi?, slides?: [index] | \"1-3,5\", includeHidden?, textLayer?, frame?}", has_doc, export),
|
||||
cmd!(
|
||||
query "file.info",
|
||||
"Info",
|
||||
["File"],
|
||||
None,
|
||||
"{} → {title, path, format, dirty, slides, hiddenSlides, sections, bytes?, properties}",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool} → {png: base64, width, height}", has_doc, render),
|
||||
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{}", has_doc, close),
|
||||
cmd!(
|
||||
@@ -118,26 +127,59 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// PDF options from command params: `{layout: slides|notes|handouts, perPage, dpi, slides: [i],
|
||||
/// includeHidden, textLayer, frame}`.
|
||||
pub fn pdf_options(p: &Value) -> deckcraft_pdf::PdfOptions {
|
||||
/// PDF options from command params: `{layout: slides|notes|handouts, perPage, dpi, slides,
|
||||
/// includeHidden, textLayer, frame}` for a presentation of `count` slides. `slides` is a list of
|
||||
/// 0-based indices or a range as typed in PowerPoint's Print page (`"1-3, 5"`, 1-based).
|
||||
pub fn pdf_options(p: &Value, count: usize) -> Result<deckcraft_pdf::PdfOptions> {
|
||||
let mut o = deckcraft_pdf::PdfOptions { layout: deckcraft_pdf::PageLayout::Slides, ..Default::default() };
|
||||
o.layout = match str_param(p, "layout").unwrap_or("slides") {
|
||||
"notes" => deckcraft_pdf::PageLayout::Notes,
|
||||
"handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).min(9) as u8 },
|
||||
"handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).clamp(1, 9) as u8 },
|
||||
_ => deckcraft_pdf::PageLayout::Slides,
|
||||
};
|
||||
o.dpi = f64_or(p, "dpi", o.dpi).clamp(36.0, 600.0);
|
||||
o.slides = p.get("slides").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as usize)).collect());
|
||||
o.slides = match p.get("slides") {
|
||||
Some(Value::Array(a)) => Some(a.iter().filter_map(|v| v.as_u64().map(|x| x as usize)).filter(|i| *i < count).collect()),
|
||||
Some(Value::String(text)) if !text.trim().is_empty() => Some(parse_slide_range(text, count)?),
|
||||
_ => None,
|
||||
};
|
||||
o.include_hidden = bool_or(p, "includeHidden", false);
|
||||
o.text_layer = bool_or(p, "textLayer", true);
|
||||
o.frame_slides = bool_or(p, "frame", true);
|
||||
o
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
/// A slide range as typed in the Print page's Slides box (`"1-3, 5"`; 1-based, either separator
|
||||
/// `,` or `;`, ranges either way round) → 0-based indices in order, without repeats. Numbers past
|
||||
/// the last slide or a range that isn't numbers is an error, as in PowerPoint.
|
||||
pub fn parse_slide_range(text: &str, count: usize) -> Result<Vec<usize>> {
|
||||
let invalid = || bad("file.export", format!("“{text}” isn't a valid slide range: use numbers and ranges such as 1-3, 5"));
|
||||
let mut out: Vec<usize> = vec![];
|
||||
for part in text.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) {
|
||||
let (a, b) = match part.split_once('-') {
|
||||
Some((a, b)) => (a.trim(), b.trim()),
|
||||
None => (part, part),
|
||||
};
|
||||
let (a, b) = (a.parse::<usize>().map_err(|_| invalid())?, b.parse::<usize>().map_err(|_| invalid())?);
|
||||
let (lo, hi) = (a.min(b), a.max(b));
|
||||
if lo == 0 || hi > count {
|
||||
return Err(bad("file.export", format!("the presentation has {count} slides; “{part}” is outside them")));
|
||||
}
|
||||
for i in lo - 1..hi {
|
||||
if !out.contains(&i) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(invalid());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// PDF bytes for `doc` with [`pdf_options`] from `p`.
|
||||
pub fn pdf_bytes(doc: &Presentation, p: &Value) -> Result<Vec<u8>> {
|
||||
deckcraft_pdf::export(doc, &pdf_options(p)).map_err(|e| EngineError::Other(e.to_string()))
|
||||
deckcraft_pdf::export(doc, &pdf_options(p, doc.slides.len())?).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
pub fn format_for_path(path: &str) -> &'static str {
|
||||
@@ -264,7 +306,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"files": written}))
|
||||
}
|
||||
"pdf" => {
|
||||
let bytes = deckcraft_pdf::export(&st.doc, &pdf_options(p)).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let bytes = pdf_bytes(&st.doc, p)?;
|
||||
write_file(&path, &bytes)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
@@ -276,6 +318,27 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// File › Info: what the Info page shows about the active presentation.
|
||||
fn info(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let doc = &st.doc;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let bytes = st.path.as_ref().and_then(|p| std::fs::metadata(p).ok()).map(|m| m.len());
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let bytes: Option<u64> = None;
|
||||
Ok(json!({
|
||||
"title": st.title(),
|
||||
"path": st.path,
|
||||
"format": st.path.as_deref().map(format_for_path),
|
||||
"dirty": st.is_dirty(),
|
||||
"slides": doc.slides.len(),
|
||||
"hiddenSlides": doc.slides.iter().filter(|sl| sl.hidden).count(),
|
||||
"sections": doc.sections.len(),
|
||||
"bytes": bytes,
|
||||
"properties": serde_json::to_value(&doc.props).unwrap_or_default(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn close(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
if let Some(i) = s.active_index() {
|
||||
s.close_document(i);
|
||||
|
||||
@@ -774,3 +774,64 @@ fn ime_composition_underlines_follow_the_text() {
|
||||
s.commit_text("日本語").unwrap();
|
||||
assert!(cmd::text::composition_underlines(s.doc().unwrap()).is_empty());
|
||||
}
|
||||
|
||||
/// File › Info (#76): the active presentation's name, location, counts and properties.
|
||||
#[test]
|
||||
fn file_info_reports_the_presentation() {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
s.execute("slide.go", &json!({"index": 1})).unwrap();
|
||||
s.execute("slide.hide", &json!({})).unwrap();
|
||||
s.execute("file.properties", &json!({"title": "Quarterly", "author": "Ada"})).unwrap();
|
||||
let v = s.execute("file.info", &json!({})).unwrap();
|
||||
assert_eq!(v["slides"], 3);
|
||||
assert_eq!(v["hiddenSlides"], 1);
|
||||
assert_eq!(v["path"], serde_json::Value::Null);
|
||||
assert_eq!(v["dirty"], true);
|
||||
assert_eq!(v["properties"]["title"], "Quarterly");
|
||||
assert_eq!(v["properties"]["author"], "Ada");
|
||||
assert!(v["title"].as_str().is_some_and(|t| !t.is_empty()));
|
||||
// A query: no undo step, and nothing changes.
|
||||
let before = s.doc().unwrap().history.undo.len();
|
||||
s.execute("file.info", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), before);
|
||||
// Saved to disk, it reports where and how big.
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-info-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("Info test.pptx").to_string_lossy().to_string();
|
||||
s.execute("file.saveAs", &json!({"path": path})).unwrap();
|
||||
let v = s.execute("file.info", &json!({})).unwrap();
|
||||
assert_eq!(v["path"], path.as_str());
|
||||
assert_eq!(v["format"], "pptx");
|
||||
assert_eq!(v["dirty"], false);
|
||||
assert!(v["bytes"].as_u64().is_some_and(|b| b > 0));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
// Nothing open: an error, not a panic.
|
||||
let mut empty = Session::new();
|
||||
assert!(empty.execute("file.info", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// The Print page's Slides box (#76): PowerPoint-style ranges, 1-based.
|
||||
#[test]
|
||||
fn slide_ranges_parse_like_powerpoints_print_box() {
|
||||
use cmd::file::parse_slide_range as r;
|
||||
assert_eq!(r("1-3, 5", 6).unwrap(), vec![0, 1, 2, 4]);
|
||||
assert_eq!(r("3-1;2", 6).unwrap(), vec![0, 1, 2], "reversed ranges and repeats");
|
||||
assert_eq!(r(" 6 ", 6).unwrap(), vec![5]);
|
||||
assert!(r("7", 6).is_err(), "past the last slide");
|
||||
assert!(r("0", 6).is_err());
|
||||
assert!(r("a-b", 6).is_err());
|
||||
assert!(r(" , ", 6).is_err());
|
||||
assert!(r("1-99999999999999999999999", 6).is_err(), "hostile numbers are refused");
|
||||
// file.export takes the same text for a PDF.
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let one = cmd::file::pdf_bytes(&doc, &json!({"slides": "2"})).unwrap();
|
||||
let both = cmd::file::pdf_bytes(&doc, &json!({"slides": "1-2"})).unwrap();
|
||||
assert!(one.len() < both.len());
|
||||
assert!(cmd::file::pdf_bytes(&doc, &json!({"slides": "5"})).is_err());
|
||||
let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap();
|
||||
assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped");
|
||||
}
|
||||
|
||||
@@ -134,6 +134,10 @@ pub struct Series {
|
||||
/// Combo charts: this series' own type.
|
||||
pub kind: Option<ChartType>,
|
||||
pub secondary_axis: bool,
|
||||
/// The number format of the series' data labels (Excel code): the labels' own, else the
|
||||
/// source data's.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub number_format: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -144,6 +148,15 @@ pub struct Chart {
|
||||
pub categories: Vec<String>,
|
||||
pub series: Vec<Series>,
|
||||
pub legend: Option<String>,
|
||||
/// `c:legend/c:overlay`: the legend is drawn over the plot instead of shrinking it.
|
||||
pub legend_overlay: bool,
|
||||
/// Text size in points of the axis labels and legend (from their `c:txPr`, else the chart's);
|
||||
/// `None`: the file gives none.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text_size: Option<f64>,
|
||||
/// Title text size in points (its rich text or `c:txPr`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title_size: Option<f64>,
|
||||
pub data_labels: bool,
|
||||
pub gridlines: bool,
|
||||
pub axis_titles: (Option<String>, Option<String>),
|
||||
@@ -159,6 +172,20 @@ pub struct Chart {
|
||||
pub vary_colors: bool,
|
||||
pub y_min: Option<f64>,
|
||||
pub y_max: Option<f64>,
|
||||
/// The value axis' major unit, when the chart fixes it.
|
||||
pub y_major: Option<f64>,
|
||||
/// The value axis' number format (an Excel format code such as `#,##0` or `0.0%`).
|
||||
pub y_format: Option<String>,
|
||||
/// The value axis is deleted (`c:delete`): no labels, though gridlines may stay.
|
||||
pub y_hidden: bool,
|
||||
/// A combo chart's secondary value axis: bounds, major unit and number format.
|
||||
pub y2_min: Option<f64>,
|
||||
pub y2_max: Option<f64>,
|
||||
pub y2_major: Option<f64>,
|
||||
pub y2_format: Option<String>,
|
||||
pub y2_hidden: bool,
|
||||
/// The chart's language (`c:lang`, e.g. `ru-RU`): digit grouping and decimal mark.
|
||||
pub lang: Option<String>,
|
||||
/// Original chart XML part kept for round-trip of unmodelled options.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub raw: Option<String>,
|
||||
@@ -172,6 +199,9 @@ impl Default for Chart {
|
||||
categories: vec![],
|
||||
series: vec![],
|
||||
legend: Some("b".into()),
|
||||
legend_overlay: false,
|
||||
text_size: None,
|
||||
title_size: None,
|
||||
data_labels: false,
|
||||
gridlines: true,
|
||||
axis_titles: (None, None),
|
||||
@@ -186,6 +216,15 @@ impl Default for Chart {
|
||||
vary_colors: false,
|
||||
y_min: None,
|
||||
y_max: None,
|
||||
y_major: None,
|
||||
y_format: None,
|
||||
y_hidden: false,
|
||||
y2_min: None,
|
||||
y2_max: None,
|
||||
y2_major: None,
|
||||
y2_format: None,
|
||||
y2_hidden: false,
|
||||
lang: None,
|
||||
raw: None,
|
||||
}
|
||||
}
|
||||
@@ -210,6 +249,81 @@ impl Chart {
|
||||
c.vary_colors = n_series == 1;
|
||||
c
|
||||
}
|
||||
/// A series' own chart type: its own in a combo chart (columns when it names none), else the
|
||||
/// chart's.
|
||||
pub fn series_kind(&self, s: &Series) -> ChartType {
|
||||
match (self.kind, s.kind) {
|
||||
(ChartType::Combo, Some(k)) if k != ChartType::Combo => k,
|
||||
(ChartType::Combo, _) => ChartType::Column,
|
||||
(k, _) => k,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a series is plotted against the secondary value axis (combo charts only).
|
||||
pub fn on_secondary(&self, s: &Series) -> bool {
|
||||
self.kind == ChartType::Combo && s.secondary_axis
|
||||
}
|
||||
|
||||
/// The value range of one value axis: the primary axis (`y_min`/`y_max` apply), or a combo
|
||||
/// chart's secondary axis. A combo chart stacks only its stacked series. `None` when no series
|
||||
/// uses that axis.
|
||||
pub fn axis_range(&self, secondary: bool) -> Option<(f64, f64)> {
|
||||
if self.kind != ChartType::Combo {
|
||||
return (!secondary).then(|| self.value_range());
|
||||
}
|
||||
let ser: Vec<&Series> = self.series.iter().filter(|s| self.on_secondary(s) == secondary).collect();
|
||||
if ser.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (mut lo, mut hi) = (0.0_f64, 0.0_f64);
|
||||
let n = ser.iter().map(|s| s.values.len()).max().unwrap_or(0);
|
||||
let mut percent = false;
|
||||
for i in 0..n {
|
||||
let (mut p, mut m) = (0.0, 0.0);
|
||||
for s in &ser {
|
||||
let k = self.series_kind(s);
|
||||
let Some(v) = s.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue };
|
||||
if k.percent() {
|
||||
percent = true;
|
||||
} else if k.stacked() {
|
||||
if v >= 0.0 { p += v } else { m += v }
|
||||
} else {
|
||||
hi = hi.max(v);
|
||||
lo = lo.min(v);
|
||||
}
|
||||
}
|
||||
hi = hi.max(p);
|
||||
lo = lo.min(m);
|
||||
}
|
||||
if percent {
|
||||
hi = hi.max(1.0);
|
||||
}
|
||||
Some(if secondary { (lo, hi) } else { (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) })
|
||||
}
|
||||
|
||||
/// A value axis' scale — (min, max, major unit) — with at most `max_intervals` intervals:
|
||||
/// the bounds and unit the chart fixes, else PowerPoint's automatic ones (the data plus 5 %
|
||||
/// headroom, rounded out to a 1-2-5 unit). `None` when no series uses the axis.
|
||||
pub fn axis_scale(&self, secondary: bool, max_intervals: usize) -> Option<(f64, f64, f64)> {
|
||||
let (lo, hi) = self.axis_range(secondary)?;
|
||||
let (fmin, fmax, major) = if secondary { (self.y2_min, self.y2_max, self.y2_major) } else { (self.y_min, self.y_max, self.y_major) };
|
||||
let full_percent = !secondary && self.kind.percent();
|
||||
Some(axis_scale(lo, hi, fmin, fmax, major, max_intervals, !full_percent))
|
||||
}
|
||||
|
||||
/// A value axis label in the axis' number format and the chart's language.
|
||||
pub fn axis_label(&self, secondary: bool, v: f64) -> String {
|
||||
let code = if secondary { &self.y2_format } else { &self.y_format };
|
||||
crate::numfmt::format(code.as_deref().unwrap_or("General"), v, self.lang.as_deref())
|
||||
}
|
||||
|
||||
/// A data label of series `s`: its number format, else its axis', in the chart's language.
|
||||
pub fn data_label(&self, s: &Series, v: f64) -> String {
|
||||
let axis = if self.on_secondary(s) { &self.y2_format } else { &self.y_format };
|
||||
let code = s.number_format.as_ref().or(axis.as_ref());
|
||||
crate::numfmt::format(code.map_or("General", String::as_str), v, self.lang.as_deref())
|
||||
}
|
||||
|
||||
/// Value range across series (stacked sums for stacked types).
|
||||
pub fn value_range(&self) -> (f64, f64) {
|
||||
let mut lo: f64 = 0.0;
|
||||
@@ -242,6 +356,53 @@ impl Chart {
|
||||
}
|
||||
}
|
||||
|
||||
/// The (min, max, major unit) of an axis over the data range [lo, hi]: `fmin`/`fmax`/`major`
|
||||
/// where the chart fixes them; otherwise the range takes in zero, gets 5 % headroom (`headroom`)
|
||||
/// and rounds out to the smallest 1-2-5 unit giving at most `max_intervals` intervals.
|
||||
pub fn axis_scale(
|
||||
lo: f64,
|
||||
hi: f64,
|
||||
fmin: Option<f64>,
|
||||
fmax: Option<f64>,
|
||||
major: Option<f64>,
|
||||
max_intervals: usize,
|
||||
headroom: bool,
|
||||
) -> (f64, f64, f64) {
|
||||
// Values past ±1e100 aren't axis values; clamping keeps the arithmetic finite.
|
||||
let fin = |v: Option<f64>| v.filter(|v| v.is_finite()).map(|v| v.clamp(-1e100, 1e100));
|
||||
let (fmin, fmax) = (fin(fmin), fin(fmax));
|
||||
let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi).clamp(-1e100, 1e100), lo.max(hi).clamp(-1e100, 1e100)) } else { (0.0, 1.0) };
|
||||
let mut lo = fmin.unwrap_or(lo.min(0.0));
|
||||
let mut hi = fmax.unwrap_or(hi.max(lo));
|
||||
if hi - lo < 1e-12 {
|
||||
hi = lo + lo.abs().max(1.0);
|
||||
}
|
||||
let span = hi - lo;
|
||||
if headroom && fmax.is_none() && hi > 0.0 {
|
||||
hi += span * 0.05;
|
||||
}
|
||||
if headroom && fmin.is_none() && lo < 0.0 {
|
||||
lo -= span * 0.05;
|
||||
}
|
||||
let n = max_intervals.clamp(1, 50) as f64;
|
||||
let step = match fin(major).filter(|m| *m > 0.0 && (hi - lo) / m <= 1000.0) {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
let mut step = 10f64.powf(((hi - lo) / n).log10().floor());
|
||||
let mut k = 0;
|
||||
// 1, 2, 5, 10, 20… × the magnitude until the intervals fit.
|
||||
while ((hi / step).ceil() - (lo / step).floor()) > n && k < 30 {
|
||||
step *= if matches!(k % 3, 1) { 2.5 } else { 2.0 };
|
||||
k += 1;
|
||||
}
|
||||
step
|
||||
}
|
||||
};
|
||||
let min = fmin.unwrap_or((lo / step + 1e-9).floor() * step);
|
||||
let max = fmax.unwrap_or((hi / step - 1e-9).ceil() * step).max(min + step);
|
||||
(min, max, step)
|
||||
}
|
||||
|
||||
/// "Nice" axis scale: (min, max, major step) covering [lo, hi].
|
||||
pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) {
|
||||
let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi), lo.max(hi)) } else { (0.0, 1.0) };
|
||||
@@ -280,6 +441,67 @@ mod tests {
|
||||
assert!(b > a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_axis_scales_like_powerpoint() {
|
||||
// Weekly spend up to ~715 000 on a plot with room for 9 intervals: 0–800 000 by 100 000.
|
||||
assert_eq!(axis_scale(0.0, 715_000.0, None, None, None, 9, true), (0.0, 800_000.0, 100_000.0));
|
||||
// Clicks up to 6 600: 0–7 000 by 1 000.
|
||||
assert_eq!(axis_scale(0.0, 6_600.0, None, None, None, 9, true), (0.0, 7_000.0, 1_000.0));
|
||||
// Fewer intervals fit on a short axis.
|
||||
assert_eq!(axis_scale(0.0, 6_600.0, None, None, None, 4, true), (0.0, 8_000.0, 2_000.0));
|
||||
// Negative data gets room below; fixed bounds and unit are kept.
|
||||
let (min, max, _) = axis_scale(-30.0, 100.0, None, None, None, 8, true);
|
||||
assert!(min <= -30.0 && max >= 105.0);
|
||||
assert_eq!(axis_scale(0.0, 715_000.0, Some(100_000.0), Some(750_000.0), Some(50_000.0), 9, true), (100_000.0, 750_000.0, 50_000.0));
|
||||
// 100 % stacked stays at 0–1 without headroom.
|
||||
assert_eq!(axis_scale(0.0, 1.0, None, None, None, 5, false), (0.0, 1.0, 0.2));
|
||||
// Nonsense never loops or divides by zero.
|
||||
for (lo, hi) in [(f64::NAN, 1.0), (5.0, 5.0), (0.0, 0.0), (-1e300, 1e300)] {
|
||||
let (a, b, s) = axis_scale(lo, hi, None, None, Some(0.0), 0, true);
|
||||
assert!(a.is_finite() && b.is_finite() && s > 0.0 && b > a, "{lo} {hi}: {a} {b} {s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis_labels_use_the_format_and_language() {
|
||||
let mut c = Chart::sample(ChartType::Column);
|
||||
c.y_format = Some(r#"_-* #\ ##0\ "₽"_-;\-* #\ ##0\ "₽"_-;_-* "-"??\ "₽"_-;_-@_-"#.into());
|
||||
c.lang = Some("ru-RU".into());
|
||||
assert_eq!(c.axis_label(false, 800_000.0), "800\u{a0}000 ₽");
|
||||
assert_eq!(c.axis_label(false, 0.0), "- ₽");
|
||||
// No format: General.
|
||||
assert_eq!(c.axis_label(true, 2.5), "2,5");
|
||||
c.lang = None;
|
||||
assert_eq!(c.axis_label(true, 2.5), "2.5");
|
||||
// Data labels: the series' format, else the axis'.
|
||||
c.lang = Some("ru-RU".into());
|
||||
c.series[0].number_format = Some(r#"#,##0\ "₽""#.into());
|
||||
assert_eq!(c.data_label(&c.series[0], 516_600_000.0), "516\u{a0}600\u{a0}000 ₽");
|
||||
assert_eq!(c.data_label(&c.series[1], 0.0), "- ₽");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combo_axis_ranges() {
|
||||
// Spend in columns on the primary axis, clicks as a line on the secondary one.
|
||||
let mut c = Chart::sample(ChartType::Combo);
|
||||
c.series.truncate(2);
|
||||
c.series[0].kind = Some(ChartType::Column);
|
||||
c.series[0].values = vec![Some(200_000.0), Some(310_000.0)];
|
||||
c.series[1].kind = Some(ChartType::Line);
|
||||
c.series[1].values = vec![Some(6_000.0), Some(7_400.0)];
|
||||
c.series[1].secondary_axis = true;
|
||||
assert_eq!(c.axis_range(false), Some((0.0, 310_000.0)));
|
||||
assert_eq!(c.axis_range(true), Some((0.0, 7_400.0)));
|
||||
assert_eq!(c.series_kind(&c.series[1]), ChartType::Line);
|
||||
// Without a secondary series there is no secondary axis; a non-combo chart never has one.
|
||||
c.series[1].secondary_axis = false;
|
||||
assert_eq!(c.axis_range(true), None);
|
||||
let col = Chart::sample(ChartType::Column);
|
||||
assert_eq!(col.axis_range(false), Some(col.value_range()));
|
||||
assert_eq!(col.axis_range(true), None);
|
||||
assert!(!col.on_secondary(&col.series[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_ranges() {
|
||||
let c = Chart::sample(ChartType::StackedColumn);
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod anim;
|
||||
pub mod chart;
|
||||
pub mod defaults;
|
||||
pub mod edit;
|
||||
pub mod numfmt;
|
||||
pub mod resolve;
|
||||
pub mod style;
|
||||
pub mod table;
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Number formats: Excel format codes (`#,##0`, `0.0%`, `_-* # ##0 "₽"_-;…`, `General`) as chart
|
||||
//! axes and data labels show them, in the chart's language (digit grouping and decimal mark).
|
||||
|
||||
/// Digit grouping and decimal mark for a language tag (`ru-RU`, `de-DE`, `en-US`).
|
||||
fn marks(lang: Option<&str>) -> (char, char) {
|
||||
let l = lang.unwrap_or("en").split(['-', '_']).next().unwrap_or("en").to_ascii_lowercase();
|
||||
match l.as_str() {
|
||||
// Space-grouped, comma decimal.
|
||||
"ru" | "uk" | "be" | "kk" | "fr" | "pl" | "cs" | "sk" | "sv" | "fi" | "nb" | "nn" | "no" | "bg" | "lt" | "lv" | "et" | "hu" | "uz" | "hy"
|
||||
| "ka" | "az" => ('\u{a0}', ','),
|
||||
// Dot-grouped, comma decimal.
|
||||
"de" | "es" | "it" | "pt" | "nl" | "da" | "tr" | "el" | "ro" | "id" | "sr" | "hr" | "sl" | "vi" => ('.', ','),
|
||||
_ => (',', '.'),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Tok {
|
||||
/// Text shown as is.
|
||||
Lit(String),
|
||||
/// A digit placeholder: `0`, `#` or `?`.
|
||||
Digit(char),
|
||||
Dot,
|
||||
Comma,
|
||||
Percent,
|
||||
}
|
||||
|
||||
/// A section's tokens (`[$₽-419]` gives its symbol; colours, conditions and locales drop out).
|
||||
fn tokens(section: &str) -> Vec<Tok> {
|
||||
let mut out = vec![];
|
||||
let mut it = section.chars().peekable();
|
||||
while let Some(c) = it.next() {
|
||||
match c {
|
||||
'"' => {
|
||||
let mut s = String::new();
|
||||
for d in it.by_ref() {
|
||||
if d == '"' {
|
||||
break;
|
||||
}
|
||||
s.push(d);
|
||||
}
|
||||
out.push(Tok::Lit(s));
|
||||
}
|
||||
'\\' => {
|
||||
if let Some(d) = it.next() {
|
||||
out.push(Tok::Lit(d.to_string()));
|
||||
}
|
||||
}
|
||||
// Padding the width of the next character: a space.
|
||||
'_' => {
|
||||
it.next();
|
||||
out.push(Tok::Lit(" ".into()));
|
||||
}
|
||||
// Repeat-to-fill: nothing on an axis.
|
||||
'*' => {
|
||||
it.next();
|
||||
}
|
||||
'[' => {
|
||||
let mut s = String::new();
|
||||
for d in it.by_ref() {
|
||||
if d == ']' {
|
||||
break;
|
||||
}
|
||||
s.push(d);
|
||||
}
|
||||
if let Some(sym) = s.strip_prefix('$') {
|
||||
let sym = sym.split('-').next().unwrap_or("");
|
||||
if !sym.is_empty() {
|
||||
out.push(Tok::Lit(sym.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
'0' | '#' | '?' => out.push(Tok::Digit(c)),
|
||||
'.' => out.push(Tok::Dot),
|
||||
',' => out.push(Tok::Comma),
|
||||
'%' => out.push(Tok::Percent),
|
||||
'@' => {}
|
||||
_ => out.push(Tok::Lit(c.to_string())),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The format's sections split at `;` (outside quotes, brackets and escapes).
|
||||
fn sections(code: &str) -> Vec<&str> {
|
||||
let mut out = vec![];
|
||||
let (mut start, mut quoted, mut bracket, mut escaped) = (0, false, false, false);
|
||||
for (i, c) in code.char_indices() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'\\' if !quoted => escaped = true,
|
||||
'"' => quoted = !quoted,
|
||||
'[' if !quoted => bracket = true,
|
||||
']' if !quoted => bracket = false,
|
||||
';' if !quoted && !bracket => {
|
||||
out.push(code.get(start..i).unwrap_or(""));
|
||||
start = i + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.push(code.get(start..).unwrap_or(""));
|
||||
out
|
||||
}
|
||||
|
||||
/// `v` in the General format: up to 10 significant digits, no grouping.
|
||||
fn general(v: f64, dec: char) -> String {
|
||||
if v == 0.0 {
|
||||
return "0".into();
|
||||
}
|
||||
let a = v.abs();
|
||||
let s = if !(1e-9..1e11).contains(&a) {
|
||||
format!("{v:.5E}")
|
||||
} else {
|
||||
let digits = (10 - (a.log10().floor() as i32 + 1)).clamp(0, 10) as usize;
|
||||
let s = format!("{v:.digits$}");
|
||||
if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }
|
||||
};
|
||||
s.replace('.', &dec.to_string())
|
||||
}
|
||||
|
||||
/// Group an integer's digits in threes with `sep`.
|
||||
fn group(digits: &str, sep: char) -> String {
|
||||
let n = digits.chars().count();
|
||||
let mut out = String::new();
|
||||
for (i, d) in digits.chars().enumerate() {
|
||||
if i > 0 && (n - i).is_multiple_of(3) {
|
||||
out.push(sep);
|
||||
}
|
||||
out.push(d);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `v` formatted with the Excel number format `code` in language `lang` (axis labels: the text
|
||||
/// section of a format doesn't apply).
|
||||
pub fn format(code: &str, v: f64, lang: Option<&str>) -> String {
|
||||
let (sep, dec) = marks(lang);
|
||||
if !v.is_finite() {
|
||||
return String::new();
|
||||
}
|
||||
let secs = sections(code);
|
||||
// The section for the value's sign; a negative or zero section shows the value unsigned.
|
||||
let (sec, v, minus) = match (secs.as_slice(), v) {
|
||||
([_, neg, ..], v) if v < 0.0 => (*neg, -v, false),
|
||||
([_, _, zero, ..], v) if v == 0.0 => (*zero, v, false),
|
||||
([pos, ..], v) => (*pos, v.abs(), v < 0.0),
|
||||
([], v) => ("General", v.abs(), v < 0.0),
|
||||
};
|
||||
if sec.trim().eq_ignore_ascii_case("general") || sec.trim().is_empty() {
|
||||
let s = general(v, dec);
|
||||
return if minus { format!("-{s}") } else { s };
|
||||
}
|
||||
let toks = tokens(sec);
|
||||
let first = toks.iter().position(|t| matches!(t, Tok::Digit(_)));
|
||||
let last = toks.iter().rposition(|t| matches!(t, Tok::Digit(_)));
|
||||
let (Some(first), Some(last)) = (first, last) else {
|
||||
// No digits: just the section's text ("-" for zero, a label).
|
||||
let s: String = toks.iter().filter_map(|t| if let Tok::Lit(s) = t { Some(s.as_str()) } else { None }).collect();
|
||||
return s.trim().to_string();
|
||||
};
|
||||
let percent = toks.contains(&Tok::Percent);
|
||||
let number = toks.get(first..=last).unwrap_or(&[]);
|
||||
let dot = number.iter().position(|t| *t == Tok::Dot);
|
||||
let (int_part, frac_part) = match dot {
|
||||
Some(d) => (number.get(..d).unwrap_or(&[]), number.get(d + 1..).unwrap_or(&[])),
|
||||
None => (number, &[][..]),
|
||||
};
|
||||
// Grouping: a comma (or the locale's space, written literally) between integer digits.
|
||||
let grouping = int_part.iter().any(|t| *t == Tok::Comma || matches!(t, Tok::Lit(s) if s.trim().is_empty()));
|
||||
// Commas right after the last digit scale by a thousand each (`#,##0,` = thousands).
|
||||
let after = toks.get(last + 1..).unwrap_or(&[]);
|
||||
let scale_commas = if dot.is_none() { after.iter().take_while(|t| **t == Tok::Comma).count() } else { 0 };
|
||||
let min_int = int_part.iter().filter(|t| **t == Tok::Digit('0')).count();
|
||||
let frac_digits: Vec<char> = frac_part.iter().filter_map(|t| if let Tok::Digit(c) = t { Some(*c) } else { None }).collect();
|
||||
let max_frac = frac_digits.len();
|
||||
let min_frac = frac_digits.iter().filter(|c| **c == '0').count();
|
||||
let mut x = v * if percent { 100.0 } else { 1.0 } / 1000f64.powi(scale_commas as i32);
|
||||
if !x.is_finite() {
|
||||
x = 0.0;
|
||||
}
|
||||
let s = format!("{x:.max_frac$}");
|
||||
let (ip, fp) = s.split_once('.').unwrap_or((s.as_str(), ""));
|
||||
let mut fp = fp.to_string();
|
||||
while fp.len() > min_frac && fp.ends_with('0') {
|
||||
fp.pop();
|
||||
}
|
||||
let ip = ip.trim_start_matches('0');
|
||||
let ip = if ip.len() < min_int { format!("{}{ip}", "0".repeat(min_int - ip.len())) } else { ip.to_string() };
|
||||
let mut num = if grouping { group(&ip, sep) } else { ip };
|
||||
if !fp.is_empty() {
|
||||
num.push(dec);
|
||||
num.push_str(&fp);
|
||||
}
|
||||
let lits = |ts: &[Tok]| -> String {
|
||||
ts.iter()
|
||||
.filter_map(|t| match t {
|
||||
Tok::Lit(s) => Some(s.clone()),
|
||||
Tok::Percent => Some("%".into()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let before = lits(toks.get(..first).unwrap_or(&[]));
|
||||
let after = lits(toks.get(last + 1..).unwrap_or(&[]));
|
||||
let out = format!("{before}{num}{after}");
|
||||
let out = out.trim();
|
||||
if minus && !out.is_empty() { format!("-{out}") } else { out.to_string() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const RUB: &str = r#"_-* #\ ##0\ "₽"_-;\-* #\ ##0\ "₽"_-;_-* "-"??\ "₽"_-;_-@_-"#;
|
||||
|
||||
#[test]
|
||||
fn russian_accounting_roubles() {
|
||||
assert_eq!(format(RUB, 800_000.0, Some("ru-RU")), "800\u{a0}000 ₽");
|
||||
assert_eq!(format(RUB, 1_234_567.0, Some("ru-RU")), "1\u{a0}234\u{a0}567 ₽");
|
||||
assert_eq!(format(RUB, -5_000.0, Some("ru-RU")), "-5\u{a0}000 ₽");
|
||||
assert_eq!(format(RUB, 0.0, Some("ru-RU")), "- ₽");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_codes() {
|
||||
assert_eq!(format("#,##0", 7000.0, Some("ru-RU")), "7\u{a0}000");
|
||||
assert_eq!(format("#,##0", 7000.0, Some("en-US")), "7,000");
|
||||
assert_eq!(format("#,##0", 7000.0, Some("de-DE")), "7.000");
|
||||
assert_eq!(format("0.0%", 0.125, Some("ru-RU")), "12,5%");
|
||||
assert_eq!(format("0.0%", 0.125, None), "12.5%");
|
||||
assert_eq!(format("0%", 0.0, None), "0%");
|
||||
assert_eq!(format("General", 2.5, Some("ru-RU")), "2,5");
|
||||
assert_eq!(format("General", 400_000.0, Some("ru-RU")), "400000");
|
||||
assert_eq!(format("0.00", 1.23456, None), "1.23");
|
||||
assert_eq!(format("#,##0.0#", 1234.5, None), "1,234.5");
|
||||
assert_eq!(format("#,##0,", 12_345_678.0, None), "12,346");
|
||||
assert_eq!(format("[$₽-419]#,##0", 1500.0, Some("ru-RU")), "₽1\u{a0}500");
|
||||
assert_eq!(format("0;[Red]-0", -3.0, None), "-3");
|
||||
assert_eq!(format("0;0", -3.0, None), "3");
|
||||
assert_eq!(format("0", -3.0, None), "-3");
|
||||
assert_eq!(format("\"x\"0", 5.0, None), "x5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_panics() {
|
||||
for code in ["", ";", ";;;", "\"", "[", "\\", "0.", ".", "#,", "%", "_", "*", "@", "0E+00", "[$", "\"unterminated"] {
|
||||
for v in [0.0, -1.5, 1e300, f64::NAN, f64::INFINITY, 1e-300] {
|
||||
let _ = format(code, v, Some("ru"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,6 +387,16 @@ pub struct Effects {
|
||||
pub bevel: Option<String>,
|
||||
/// Unmodelled 3-D XML kept for round-trip (`scene3d`, `sp3d`).
|
||||
pub raw3d: Option<String>,
|
||||
/// `a:fillOverlay`: a fill blended over the shape's fill (`blend`: `over`, `mult`, `screen`,
|
||||
/// `darken`, `lighten`). Only solid and gradient overlays are kept.
|
||||
pub fill_overlay: Option<FillOverlay>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FillOverlay {
|
||||
pub blend: String,
|
||||
pub fill: Fill,
|
||||
}
|
||||
|
||||
impl Effects {
|
||||
@@ -397,6 +407,7 @@ impl Effects {
|
||||
&& self.soft_edge.is_none()
|
||||
&& self.reflection.is_none()
|
||||
&& self.bevel.is_none()
|
||||
&& self.fill_overlay.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,10 +97,30 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
|
||||
c.title = None;
|
||||
}
|
||||
c.legend = ch.child("legend").map(|l| l.child_val("legendPos").unwrap_or("r").to_string());
|
||||
c.legend_overlay = ch.child("legend").and_then(|l| l.child("overlay")).and_then(|o| o.bool("val")).unwrap_or(false);
|
||||
c.chart_fill = simple_fill(root.child("spPr"));
|
||||
c.plot_fill = simple_fill(pa.child("spPr"));
|
||||
|
||||
let mut kinds: Vec<ChartType> = vec![];
|
||||
// The value axis the first plot group uses is the primary one; a group on another value axis
|
||||
// (a combo chart's line on the right) is on the secondary axis.
|
||||
let val_ids: Vec<(&str, bool)> =
|
||||
pa.children_named("valAx").filter_map(|a| Some((a.child_val("axId")?, matches!(a.child_val("axPos"), Some("l" | "r"))))).collect();
|
||||
// A group's value axis: its vertical one when it has two (scatter), else its one.
|
||||
let group_val_axis = |g: &El| {
|
||||
let ids: Vec<(&str, bool)> =
|
||||
g.children_named("axId").filter_map(|a| a.attr("val")).filter_map(|id| val_ids.iter().find(|(v, _)| *v == id).copied()).collect();
|
||||
ids.iter().find(|(_, vertical)| *vertical).or(ids.first()).map(|(id, _)| id.to_string())
|
||||
};
|
||||
// The primary value axis is the one on the left (bottom for bar charts); the secondary one
|
||||
// sits on the right or top, or crosses the category axis at its maximum.
|
||||
let secondary_placed = |a: &El| a.child_val("crosses") == Some("max") || matches!(a.child_val("axPos"), Some("r" | "t"));
|
||||
let used: Vec<String> = pa.elements().filter(|g| g.local().ends_with("Chart")).filter_map(group_val_axis).collect();
|
||||
let primary_val: Option<String> = used
|
||||
.iter()
|
||||
.find(|id| pa.children_named("valAx").find(|a| a.child_val("axId") == Some(id.as_str())).is_some_and(|a| !secondary_placed(a)))
|
||||
.or(used.first())
|
||||
.cloned();
|
||||
for g in pa.elements() {
|
||||
let name = g.local();
|
||||
if !name.ends_with("Chart") {
|
||||
@@ -166,6 +186,8 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
|
||||
c.data_labels = true;
|
||||
}
|
||||
kinds.push(kind);
|
||||
let val_axis = group_val_axis(g);
|
||||
let secondary = val_axis.is_some() && val_axis != primary_val;
|
||||
for s in g.children_named("ser").take(1000) {
|
||||
let mut ser = Series {
|
||||
name: s.child("tx").map(strings).and_then(|v| v.into_iter().next()).unwrap_or_default(),
|
||||
@@ -174,11 +196,22 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
|
||||
smooth: s.child("smooth").and_then(|v| v.bool("val")).unwrap_or(false),
|
||||
marker: s.path(&["marker", "symbol"]).and_then(|m| m.attr("val")).filter(|m| *m != "none").map(String::from),
|
||||
kind: Some(kind),
|
||||
secondary_axis: secondary,
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(d) = s.child("dLbls").and_then(|d| d.child("showVal")).and_then(|v| v.bool("val")) {
|
||||
ser.data_labels = Some(d);
|
||||
}
|
||||
// Data labels' number format: the series' labels', the group's, else the source data's.
|
||||
let label_fmt = |d: Option<&El>| {
|
||||
d.and_then(|d| d.child("numFmt"))
|
||||
.filter(|n| n.attr("sourceLinked") != Some("1"))
|
||||
.and_then(|n| n.attr("formatCode"))
|
||||
.map(str::to_string)
|
||||
};
|
||||
let source =
|
||||
s.child("val").or_else(|| s.child("yVal")).and_then(|v| v.find("numCache")).and_then(|c| c.child("formatCode")).map(|f| f.text());
|
||||
ser.number_format = label_fmt(s.child("dLbls")).or_else(|| label_fmt(g.child("dLbls"))).or(source).filter(|f| !f.is_empty());
|
||||
for dp in s.children_named("dPt") {
|
||||
if let (Some(i), Some(f)) = (dp.child("idx").and_then(|i| i.u32("val")), simple_fill(dp.child("spPr"))) {
|
||||
ser.point_fills.push((i as usize, f));
|
||||
@@ -207,11 +240,29 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
|
||||
if c.kind != ChartType::Combo {
|
||||
for s in &mut c.series {
|
||||
s.kind = None;
|
||||
s.secondary_axis = false;
|
||||
}
|
||||
}
|
||||
// Axes.
|
||||
c.lang = root.child_val("lang").map(str::to_string);
|
||||
let vertical = |a: &&El| matches!(a.child_val("axPos"), Some("l" | "r"));
|
||||
let val_ax = pa.children_named("valAx").find(vertical).or_else(|| pa.children_named("valAx").last());
|
||||
let by_id = |id: &Option<String>| pa.children_named("valAx").find(|a| id.is_some() && a.child_val("axId") == id.as_deref());
|
||||
let val_ax = by_id(&primary_val).or_else(|| pa.children_named("valAx").find(vertical)).or_else(|| pa.children_named("valAx").last());
|
||||
let num = |a: &El, k: &str| a.child_val(k).and_then(|v| v.parse::<f64>().ok()).filter(|v| v.is_finite());
|
||||
let format_code = |a: &El| a.child("numFmt").and_then(|n| n.attr("formatCode")).map(str::to_string);
|
||||
let deleted = |a: &El| a.child("delete").and_then(|d| d.bool("val")) == Some(true);
|
||||
if c.kind == ChartType::Combo && c.series.iter().any(|s| s.secondary_axis) {
|
||||
let primary_id = val_ax.and_then(|a| a.child_val("axId"));
|
||||
// The other vertical value axis is the secondary one.
|
||||
if let Some(a) = pa.children_named("valAx").find(|a| a.child_val("axId") != primary_id && vertical(a)) {
|
||||
let sc = a.child("scaling");
|
||||
c.y2_min = sc.and_then(|s| num(s, "min"));
|
||||
c.y2_max = sc.and_then(|s| num(s, "max"));
|
||||
c.y2_major = num(a, "majorUnit");
|
||||
c.y2_format = format_code(a);
|
||||
c.y2_hidden = deleted(a);
|
||||
}
|
||||
}
|
||||
let cat_ax = pa.child("catAx").or_else(|| pa.child("dateAx")).or_else(|| pa.children_named("valAx").find(|a| !vertical(a)));
|
||||
if let Some(v) = val_ax {
|
||||
c.gridlines = v.child("majorGridlines").is_some();
|
||||
@@ -219,9 +270,122 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
|
||||
c.y_min = sc.and_then(|s| s.child_val("min")).and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite());
|
||||
c.y_max = sc.and_then(|s| s.child_val("max")).and_then(|v| v.parse().ok()).filter(|v: &f64| v.is_finite());
|
||||
c.axis_titles.1 = v.child("title").map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into()));
|
||||
c.y_major = num(v, "majorUnit");
|
||||
c.y_format = format_code(v);
|
||||
c.y_hidden = deleted(v);
|
||||
}
|
||||
if let Some(a) = cat_ax {
|
||||
c.axis_titles.0 = a.child("title").map(|t| rich_text(t).unwrap_or_else(|| "Axis Title".into()));
|
||||
}
|
||||
// Text sizes as the file sets them (PowerPoint draws chart text at these sizes, not scaled to
|
||||
// the chart): the axes' and legend's, else the whole chart's; the title's own.
|
||||
let chart_size = text_size(root.child("txPr"));
|
||||
c.text_size = [val_ax, cat_ax, ch.child("legend")].into_iter().flatten().find_map(|e| text_size(e.child("txPr"))).or(chart_size);
|
||||
c.title_size = ch.child("title").and_then(|t| text_size(t.child("tx").and_then(|x| x.child("rich"))).or_else(|| text_size(t.child("txPr"))));
|
||||
Some(c)
|
||||
}
|
||||
|
||||
/// The size in points of the first `a:defRPr` or `a:rPr` with one under `e` (`sz` in hundredths).
|
||||
fn text_size(e: Option<&El>) -> Option<f64> {
|
||||
fn rec(e: &El, depth: usize) -> Option<f64> {
|
||||
if depth > 8 {
|
||||
return None;
|
||||
}
|
||||
for c in e.elements() {
|
||||
if (c.is("defRPr") || c.is("rPr"))
|
||||
&& let Some(v) = c.attr("sz").and_then(|v| v.parse::<f64>().ok()).filter(|v| v.is_finite() && *v > 0.0)
|
||||
{
|
||||
return Some((v / 100.0).clamp(1.0, 400.0));
|
||||
}
|
||||
if let Some(v) = rec(c, depth + 1) {
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
rec(e?, 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legend_position_and_overlay_are_read() {
|
||||
let legend = r#"<c:legend><c:legendPos val="r"/><c:overlay val="1"/></c:legend>"#;
|
||||
let xml = format!(
|
||||
r#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/></c:barChart></c:plotArea>{legend}</c:chart></c:chartSpace>"#
|
||||
);
|
||||
let c = read_chart_xml(xml.as_bytes()).unwrap();
|
||||
assert_eq!(c.legend.as_deref(), Some("r"));
|
||||
assert!(c.legend_overlay);
|
||||
let xml = br#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:barChart><c:barDir val="col"/></c:barChart></c:plotArea><c:legend/></c:chart></c:chartSpace>"#;
|
||||
let c = read_chart_xml(xml).unwrap();
|
||||
assert_eq!(c.legend.as_deref(), Some("r"));
|
||||
assert!(!c.legend_overlay);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combo_chart_with_a_secondary_axis() {
|
||||
// Columns on axes 1/2, a line on axes 3/4 whose value axis is on the right.
|
||||
let xml = br##"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><c:lang val="ru-RU"/><c:chart><c:plotArea>
|
||||
<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/><c:ser><c:idx val="0"/><c:tx><c:v>Spend</c:v></c:tx><c:cat><c:strLit><c:ptCount val="2"/><c:pt idx="0"><c:v>W1</c:v></c:pt><c:pt idx="1"><c:v>W2</c:v></c:pt></c:strLit></c:cat><c:val><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>200000</c:v></c:pt><c:pt idx="1"><c:v>310000</c:v></c:pt></c:numLit></c:val></c:ser><c:axId val="1"/><c:axId val="2"/></c:barChart>
|
||||
<c:lineChart><c:grouping val="standard"/><c:ser><c:idx val="1"/><c:tx><c:v>Clicks</c:v></c:tx><c:spPr><a:ln w="28575"><a:solidFill><a:srgbClr val="00B050"/></a:solidFill></a:ln></c:spPr><c:val><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>6000</c:v></c:pt><c:pt idx="1"><c:v>7400</c:v></c:pt></c:numLit></c:val></c:ser><c:marker val="1"/><c:axId val="3"/><c:axId val="4"/></c:lineChart>
|
||||
<c:catAx><c:axId val="1"/><c:axPos val="b"/><c:crossAx val="2"/></c:catAx><c:valAx><c:axId val="2"/><c:axPos val="l"/><c:numFmt formatCode="#,##0" sourceLinked="1"/><c:crossAx val="1"/><c:majorUnit val="100000"/></c:valAx>
|
||||
<c:valAx><c:axId val="4"/><c:scaling><c:max val="8000"/></c:scaling><c:axPos val="r"/><c:numFmt formatCode="0.0" sourceLinked="0"/><c:crossAx val="3"/></c:valAx><c:catAx><c:axId val="3"/><c:delete val="1"/><c:axPos val="b"/><c:crossAx val="4"/></c:catAx>
|
||||
</c:plotArea></c:chart></c:chartSpace>"##;
|
||||
let c = read_chart_xml(xml).unwrap();
|
||||
assert_eq!(c.kind, ChartType::Combo);
|
||||
assert_eq!(c.series.len(), 2);
|
||||
assert!(!c.series[0].secondary_axis);
|
||||
assert!(c.series[1].secondary_axis);
|
||||
assert_eq!(c.series[1].kind, Some(ChartType::LineMarkers));
|
||||
assert!(c.series[1].line.as_ref().is_some_and(|l| l.width.is_some_and(|w| (w - 2.25).abs() < 0.01)));
|
||||
// Each value axis' number format, bounds and unit; the chart's language.
|
||||
assert_eq!(c.y_format.as_deref(), Some("#,##0"));
|
||||
assert_eq!((c.y_major, c.y2_max, c.y2_format.as_deref()), (Some(100000.0), Some(8000.0), Some("0.0")));
|
||||
assert_eq!(c.lang.as_deref(), Some("ru-RU"));
|
||||
// Group order doesn't decide: columns on the right-hand axis are the secondary series even
|
||||
// when their group comes first, and the line on the left is primary.
|
||||
let swapped = String::from_utf8_lossy(xml)
|
||||
.replace(
|
||||
"<c:axPos val=\"l\"/><c:numFmt formatCode=\"#,##0\"",
|
||||
"<c:axPos val=\"r\"/><c:crosses val=\"max\"/><c:numFmt formatCode=\"#,##0\"",
|
||||
)
|
||||
.replace("<c:axPos val=\"r\"/><c:numFmt formatCode=\"0.0\"", "<c:axPos val=\"l\"/><c:numFmt formatCode=\"0.0\"");
|
||||
let c2 = read_chart_xml(swapped.as_bytes()).unwrap();
|
||||
assert!(c2.series[0].secondary_axis && !c2.series[1].secondary_axis);
|
||||
assert_eq!((c2.y_format.as_deref(), c2.y2_format.as_deref()), (Some("0.0"), Some("#,##0")));
|
||||
// A chart of one type on one axis has no secondary series.
|
||||
let single = String::from_utf8_lossy(xml)
|
||||
.replace("<c:axId val=\"3\"/><c:axId val=\"4\"/></c:lineChart>", "<c:axId val=\"1\"/><c:axId val=\"2\"/></c:lineChart>");
|
||||
let c = read_chart_xml(single.as_bytes()).unwrap();
|
||||
assert!(c.series.iter().all(|s| !s.secondary_axis));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_sizes_are_read_from_the_file() {
|
||||
// PowerPoint's new charts set the axes' and legend's text to 11.97 pt and the title's to
|
||||
// 18.62 pt; a chart that only sets its overall text size uses it everywhere.
|
||||
let ns =
|
||||
r#"xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main""#;
|
||||
let tx = |sz: u32| format!(r#"<c:txPr><a:bodyPr/><a:p><a:pPr><a:defRPr sz="{sz}"/></a:pPr></a:p></c:txPr>"#);
|
||||
let xml = format!(
|
||||
r#"<c:chartSpace {ns}><c:chart><c:title><c:tx><c:rich><a:bodyPr/><a:p><a:pPr><a:defRPr sz="1862"/></a:pPr><a:r><a:t>T</a:t></a:r></a:p></c:rich></c:tx></c:title><c:plotArea><c:barChart><c:barDir val="col"/><c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/>{}</c:catAx><c:valAx><c:axId val="2"/>{}</c:valAx></c:plotArea></c:chart></c:chartSpace>"#,
|
||||
tx(1197),
|
||||
tx(1197)
|
||||
);
|
||||
let c = read_chart_xml(xml.as_bytes()).unwrap();
|
||||
assert_eq!((c.text_size, c.title_size), (Some(11.97), Some(18.62)));
|
||||
let xml = format!(
|
||||
r#"<c:chartSpace {ns}><c:chart><c:plotArea><c:barChart><c:barDir val="col"/></c:barChart></c:plotArea></c:chart>{}</c:chartSpace>"#,
|
||||
tx(1400)
|
||||
);
|
||||
let c = read_chart_xml(xml.as_bytes()).unwrap();
|
||||
assert_eq!((c.text_size, c.title_size), (Some(14.0), None));
|
||||
let xml = format!(
|
||||
r#"<c:chartSpace {ns}><c:chart><c:plotArea><c:barChart><c:barDir val="col"/></c:barChart></c:plotArea></c:chart></c:chartSpace>"#
|
||||
);
|
||||
assert_eq!(read_chart_xml(xml.as_bytes()).unwrap().text_size, None, "no sizes: the renderer picks one");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +257,14 @@ pub fn effects(el: &El) -> Effects {
|
||||
"innerShdw" => fx.inner_shadow = Some(shadow(e, true)),
|
||||
"glow" => fx.glow = Some(Glow { color: color(e).unwrap_or(ColorRef::rgb(Rgba::BLACK)), radius: pt(e, "rad").unwrap_or(0.0).max(0.0) }),
|
||||
"softEdge" => fx.soft_edge = Some(pt(e, "rad").unwrap_or(0.0).max(0.0)),
|
||||
"fillOverlay" => {
|
||||
let fill = e.elements().find_map(|f| match f.local() {
|
||||
"solidFill" => color(f).map(|c| Fill::Solid { color: c }),
|
||||
"gradFill" => Some(Fill::Gradient(gradient(f))),
|
||||
_ => None,
|
||||
});
|
||||
fx.fill_overlay = fill.map(|fill| deckcraft_model::style::FillOverlay { blend: e.attr("blend").unwrap_or("over").to_string(), fill });
|
||||
}
|
||||
"reflection" => {
|
||||
fx.reflection = Some(Reflection {
|
||||
blur: pt(e, "blurRad").unwrap_or(0.0),
|
||||
|
||||
@@ -467,7 +467,7 @@ pub fn chart_xml(c: &Chart) -> Vec<u8> {
|
||||
};
|
||||
w.open0("c:legend");
|
||||
w.val("c:legendPos", p);
|
||||
w.val("c:overlay", 0);
|
||||
w.val("c:overlay", u8::from(c.legend_overlay));
|
||||
w.close("c:legend");
|
||||
}
|
||||
w.val("c:plotVisOnly", 1);
|
||||
|
||||
@@ -76,40 +76,7 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) {
|
||||
match f {
|
||||
Fill::None => w.empty0("a:noFill"),
|
||||
Fill::Solid { color: c } => solid(w, c),
|
||||
Fill::Gradient(g) => {
|
||||
w.open("a:gradFill", A::new().a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" }));
|
||||
// No stops: no `a:gsLst`, so the gradient keeps taking them from its style's fill.
|
||||
let mut stops = g.stops.clone();
|
||||
if stops.len() == 1 {
|
||||
let mut s = stops[0].clone();
|
||||
s.pos = 1.0;
|
||||
stops.push(s);
|
||||
}
|
||||
if !stops.is_empty() {
|
||||
w.open0("a:gsLst");
|
||||
for s in &stops {
|
||||
w.open("a:gs", A::new().a("pos", pct(s.pos.clamp(0.0, 1.0))));
|
||||
color(w, &s.color);
|
||||
w.close("a:gs");
|
||||
}
|
||||
w.close("a:gsLst");
|
||||
}
|
||||
match &g.shape {
|
||||
GradientShape::Linear { angle, scaled } => {
|
||||
w.empty("a:lin", A::new().a("ang", ang(*angle)).a("scaled", if *scaled { "1" } else { "0" }))
|
||||
}
|
||||
GradientShape::Path { path, focus } => {
|
||||
let p = match path.as_str() {
|
||||
"rect" | "shape" | "circle" => path.as_str(),
|
||||
_ => "circle",
|
||||
};
|
||||
w.open("a:path", A::new().a("path", p));
|
||||
w.empty("a:fillToRect", A::new().a("l", pct(focus[0])).a("t", pct(focus[1])).a("r", pct(focus[2])).a("b", pct(focus[3])));
|
||||
w.close("a:path");
|
||||
}
|
||||
}
|
||||
w.close("a:gradFill");
|
||||
}
|
||||
Fill::Gradient(g) => gradient(w, g),
|
||||
Fill::Picture(pf) => blip_fill(w, x, o, "a:blipFill", pf),
|
||||
Fill::Pattern(p) => {
|
||||
w.open("a:pattFill", A::new().a("prst", if p.preset.is_empty() { "pct5" } else { &p.preset }));
|
||||
@@ -275,8 +242,55 @@ fn shadow(w: &mut W, tag: &str, s: &deckcraft_model::style::Shadow) {
|
||||
w.close(tag);
|
||||
}
|
||||
|
||||
fn gradient(w: &mut W, g: &deckcraft_model::style::Gradient) {
|
||||
w.open("a:gradFill", A::new().a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" }));
|
||||
// No stops: no `a:gsLst`, so the gradient keeps taking them from its style's fill.
|
||||
let mut stops = g.stops.clone();
|
||||
if stops.len() == 1 {
|
||||
let mut s = stops[0].clone();
|
||||
s.pos = 1.0;
|
||||
stops.push(s);
|
||||
}
|
||||
if !stops.is_empty() {
|
||||
w.open0("a:gsLst");
|
||||
for s in &stops {
|
||||
w.open("a:gs", A::new().a("pos", pct(s.pos.clamp(0.0, 1.0))));
|
||||
color(w, &s.color);
|
||||
w.close("a:gs");
|
||||
}
|
||||
w.close("a:gsLst");
|
||||
}
|
||||
match &g.shape {
|
||||
GradientShape::Linear { angle, scaled } => w.empty("a:lin", A::new().a("ang", ang(*angle)).a("scaled", if *scaled { "1" } else { "0" })),
|
||||
GradientShape::Path { path, focus } => {
|
||||
let p = match path.as_str() {
|
||||
"rect" | "shape" | "circle" => path.as_str(),
|
||||
_ => "circle",
|
||||
};
|
||||
w.open("a:path", A::new().a("path", p));
|
||||
w.empty("a:fillToRect", A::new().a("l", pct(focus[0])).a("t", pct(focus[1])).a("r", pct(focus[2])).a("b", pct(focus[3])));
|
||||
w.close("a:path");
|
||||
}
|
||||
}
|
||||
w.close("a:gradFill");
|
||||
}
|
||||
|
||||
pub fn effects(w: &mut W, fx: &Effects) {
|
||||
w.open0("a:effectLst");
|
||||
// Schema order: blur, fillOverlay, glow, innerShdw, outerShdw, prstShdw, reflection, softEdge.
|
||||
if let Some(ov) = &fx.fill_overlay {
|
||||
let blend = match ov.blend.as_str() {
|
||||
b @ ("over" | "mult" | "screen" | "darken" | "lighten") => b,
|
||||
_ => "over",
|
||||
};
|
||||
w.open("a:fillOverlay", A::new().a("blend", blend));
|
||||
match &ov.fill {
|
||||
Fill::Solid { color: c } => solid(w, c),
|
||||
Fill::Gradient(g) => gradient(w, g),
|
||||
_ => w.empty0("a:noFill"),
|
||||
}
|
||||
w.close("a:fillOverlay");
|
||||
}
|
||||
if let Some(g) = &fx.glow {
|
||||
w.open("a:glow", A::new().a("rad", emu(g.radius).max(0)));
|
||||
color(w, &g.color);
|
||||
|
||||
@@ -102,3 +102,34 @@ fn a_gradient_without_stops_is_saved_without_stops() {
|
||||
let s = q.slides[0].shapes.iter().find(|s| s.name == "Styled").expect("shape");
|
||||
assert_eq!(s.fill, Some(Fill::Gradient(own)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_overlay_round_trips() {
|
||||
use deckcraft_model::style::{FillOverlay, Gradient, GradientShape, GradientStop};
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let id = ShapeId(p.alloc_id());
|
||||
let first = p.slides[0].id;
|
||||
let overlay = FillOverlay {
|
||||
blend: "mult".into(),
|
||||
fill: Fill::Gradient(Gradient {
|
||||
stops: vec![
|
||||
GradientStop { pos: 0.0, color: deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1) },
|
||||
GradientStop { pos: 1.0, color: deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(10, 20, 30)) },
|
||||
],
|
||||
shape: GradientShape::Linear { angle: 4.5, scaled: true },
|
||||
rotate_with_shape: true,
|
||||
}),
|
||||
};
|
||||
p.slide_mut(first).expect("slide").shapes.push(Shape {
|
||||
id,
|
||||
name: "Overlaid".into(),
|
||||
xfrm: Some(Xfrm::new(10.0, 10.0, 200.0, 100.0)),
|
||||
fill: Some(Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::WHITE))),
|
||||
effects: Some(deckcraft_model::Effects { fill_overlay: Some(overlay.clone()), ..Default::default() }),
|
||||
..Default::default()
|
||||
});
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
let q = deckcraft_pptx::import(&bytes).expect("import");
|
||||
let s = q.slides[0].shapes.iter().find(|s| s.name == "Overlaid").expect("shape");
|
||||
assert_eq!(s.effects.as_ref().and_then(|e| e.fill_overlay.clone()), Some(overlay));
|
||||
}
|
||||
|
||||
+267
-97
@@ -2,7 +2,7 @@
|
||||
|
||||
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
|
||||
use deckcraft_model::Shape;
|
||||
use deckcraft_model::chart::{Chart, ChartType, nice_scale};
|
||||
use deckcraft_model::chart::{Chart, ChartType, axis_scale};
|
||||
use deckcraft_model::resolve::Ctx;
|
||||
use deckcraft_model::style::{ColorRef, Fill};
|
||||
use deckcraft_model::text::{Align, Anchor, Bullet, TextBody};
|
||||
@@ -36,6 +36,29 @@ fn series_fill(ctx: &Ctx, c: &Chart, i: usize, s: Option<&deckcraft_model::chart
|
||||
}
|
||||
}
|
||||
|
||||
/// A line series' colour: its line's own (where PowerPoint keeps it), else its fill's, else the
|
||||
/// palette's.
|
||||
fn series_line(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series) -> Rgba {
|
||||
match s.line.as_ref().and_then(|l| l.fill.as_ref()) {
|
||||
Some(Fill::Solid { color: cc }) => ctx.color(cc, None),
|
||||
_ => series_fill(ctx, c, i, Some(s)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A category label on two lines, split at its middle space or dash ("03.11.2025 - 09.11.2025").
|
||||
fn split_label(name: &str) -> String {
|
||||
let chars: Vec<char> = name.chars().collect();
|
||||
let mid = chars.len() / 2;
|
||||
let best = chars.iter().enumerate().filter(|(_, c)| c.is_whitespace()).map(|(i, _)| i).min_by_key(|i| i.abs_diff(mid));
|
||||
match best {
|
||||
Some(i) if i > 0 && i + 1 < chars.len() => {
|
||||
let (a, b) = chars.split_at(i);
|
||||
format!("{}\n{}", a.iter().collect::<String>().trim_end(), b.iter().collect::<String>().trim_start())
|
||||
}
|
||||
_ => name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn label(ctx: &mut RenderContext, rctx: &Ctx, m: Affine, text: &str, r: Rect, size: f64, align: Align, anchor: Anchor, col: Rgba, bold: bool) {
|
||||
let mut body = TextBody::from_text(text);
|
||||
body.body.inset_l = Some(0.0);
|
||||
@@ -63,6 +86,42 @@ fn fmt_num(v: f64) -> String {
|
||||
if (v - v.round()).abs() < 1e-9 { format!("{}", v.round() as i64) } else { format!("{v:.1}") }
|
||||
}
|
||||
|
||||
/// Where the legend goes for `c:legendPos`, and the plot area left over.
|
||||
/// `size` is the legend block (width, height); the result is its top-left corner and the plot area.
|
||||
/// `r` (also the OOXML default and the fallback for unknown values) and `l` are vertical stacks centred
|
||||
/// on the area, `tr` is a vertical stack in the top-right corner, `t` and `b` are centred rows.
|
||||
/// An overlaid legend leaves the plot area as it was.
|
||||
fn legend_layout(pos: &str, size: (f64, f64), area: Rect, base: f64, overlay: bool) -> (Point, Rect) {
|
||||
let (lw, lh) = size;
|
||||
let gap = base * 0.5;
|
||||
let cx = (area.x0 + area.x1) / 2.0;
|
||||
let cy = ((area.y0 + area.y1) / 2.0 - lh / 2.0).max(area.y0);
|
||||
let mut plot = area;
|
||||
let origin = match pos {
|
||||
"b" => {
|
||||
plot.y1 = area.y1 - lh - gap;
|
||||
Point::new(cx - lw / 2.0, area.y1 - lh)
|
||||
}
|
||||
"t" => {
|
||||
plot.y0 = area.y0 + lh + gap;
|
||||
Point::new(cx - lw / 2.0, area.y0)
|
||||
}
|
||||
"l" => {
|
||||
plot.x0 = area.x0 + lw + gap;
|
||||
Point::new(area.x0, cy)
|
||||
}
|
||||
"tr" => {
|
||||
plot.x1 = area.x1 - lw - gap;
|
||||
Point::new(area.x1 - lw, area.y0)
|
||||
}
|
||||
_ => {
|
||||
plot.x1 = area.x1 - lw - gap;
|
||||
Point::new(area.x1 - lw, cy)
|
||||
}
|
||||
};
|
||||
(origin, if overlay { area } else { plot })
|
||||
}
|
||||
|
||||
pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h: f64) {
|
||||
let text_col = c.text_color.as_ref().map(|t| rctx.color(t, None)).unwrap_or(Rgba::rgb(0x59, 0x59, 0x59));
|
||||
let grid_col = Rgba::rgb(0xD9, 0xD9, 0xD9);
|
||||
@@ -80,32 +139,53 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
rctx.pres,
|
||||
);
|
||||
}
|
||||
let base = (h.min(w) / 22.0).clamp(7.0, 18.0);
|
||||
// The file's text sizes when it has them (PowerPoint draws chart text at them); otherwise a size
|
||||
// that suits the chart.
|
||||
let base = c.text_size.unwrap_or_else(|| (h.min(w) / 22.0).clamp(7.0, 18.0));
|
||||
let title_size = c.title_size.unwrap_or(base * 1.4);
|
||||
let mut area = Rect::new(w * 0.04, h * 0.04, w * 0.96, h * 0.96);
|
||||
if let Some(t) = &c.title {
|
||||
label(ctx, rctx, m, t, Rect::new(0.0, area.y0, w, area.y0 + base * 1.8), base * 1.4, Align::Center, Anchor::Top, text_col, false);
|
||||
area.y0 += base * 2.4;
|
||||
label(ctx, rctx, m, t, Rect::new(0.0, area.y0, w, area.y0 + title_size * 1.3), title_size, Align::Center, Anchor::Top, text_col, false);
|
||||
area.y0 += title_size * 1.7;
|
||||
}
|
||||
let pie = matches!(c.kind, ChartType::Pie | ChartType::Doughnut);
|
||||
// Legend at the bottom.
|
||||
// Legend placed by `c:legendPos`.
|
||||
let legend_items: Vec<(String, Rgba)> = if pie {
|
||||
c.categories.iter().enumerate().map(|(i, n)| (n.clone(), series_color(rctx, c, i))).collect()
|
||||
} else {
|
||||
c.series.iter().enumerate().map(|(i, s)| (s.name.clone(), series_fill(rctx, c, i, Some(s)))).collect()
|
||||
c.series
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, s)| {
|
||||
let k = c.series_kind(s);
|
||||
let line = !k.is_bar_like() && !matches!(k, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar);
|
||||
(s.name.clone(), if line { series_line(rctx, c, i, s) } else { series_fill(rctx, c, i, Some(s)) })
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
if c.legend.is_some() && !legend_items.is_empty() {
|
||||
let ly = area.y1 - base * 1.4;
|
||||
let item_w = (legend_items.iter().map(|(n, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).sum::<f64>()).min(w * 0.9);
|
||||
let mut x = (w - item_w) / 2.0;
|
||||
if let (Some(pos), false) = (&c.legend, legend_items.is_empty()) {
|
||||
let vertical = matches!(pos.as_str(), "l" | "r" | "tr");
|
||||
let item_ws: Vec<f64> = legend_items.iter().map(|(n, _)| n.chars().count() as f64 * base * 0.55 + base * 1.6).collect();
|
||||
let size = if vertical {
|
||||
(item_ws.iter().copied().fold(0.0, f64::max).min(w * 0.4), legend_items.len() as f64 * base * 1.5)
|
||||
} else {
|
||||
(item_ws.iter().sum::<f64>().min(w * 0.9), base * 1.4)
|
||||
};
|
||||
let (origin, plot_area) = legend_layout(pos, size, area, base, c.legend_overlay);
|
||||
area = plot_area;
|
||||
let (mut x, mut y) = (origin.x, origin.y);
|
||||
for (name, col) in &legend_items {
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(*col, 1.0));
|
||||
ctx.fill_rect(&Rect::new(x, ly + base * 0.3, x + base * 0.7, ly + base));
|
||||
ctx.fill_rect(&Rect::new(x, y + base * 0.3, x + base * 0.7, y + base));
|
||||
let tw = name.chars().count() as f64 * base * 0.55 + base;
|
||||
label(ctx, rctx, m, name, Rect::new(x + base, ly, x + base + tw, ly + base * 1.3), base, Align::Left, Anchor::Middle, text_col, false);
|
||||
x += tw + base * 0.6;
|
||||
label(ctx, rctx, m, name, Rect::new(x + base, y, x + base + tw, y + base * 1.3), base, Align::Left, Anchor::Middle, text_col, false);
|
||||
if vertical {
|
||||
y += base * 1.5;
|
||||
} else {
|
||||
x += tw + base * 0.6;
|
||||
}
|
||||
}
|
||||
area.y1 = ly - base * 0.5;
|
||||
}
|
||||
if pie {
|
||||
let Some(s) = c.series.first() else { return };
|
||||
@@ -162,26 +242,50 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (lo, hi) = c.value_range();
|
||||
let (min, max, step) = nice_scale(lo.min(0.0), hi);
|
||||
let horiz = c.kind.horizontal();
|
||||
// Axis label gutter.
|
||||
let gutter = base * 3.0;
|
||||
let plot = if horiz {
|
||||
Rect::new(area.x0 + gutter * 1.5, area.y0, area.x1, area.y1 - base * 1.6)
|
||||
let combo = c.kind == ChartType::Combo;
|
||||
// Bars are drawn first and lines over them; a combo chart has both.
|
||||
let bar_kind = c.series.iter().map(|s| c.series_kind(s)).find(|k| k.is_bar_like());
|
||||
let horiz = if combo { bar_kind.is_some_and(ChartType::horizontal) } else { c.kind.horizontal() };
|
||||
// As many major units as the value axis has room for labels (PowerPoint's automatic unit).
|
||||
let axis_len = if horiz { area.width() - base * 4.5 } else { area.height() - base * 1.8 };
|
||||
let max_intervals = (axis_len / if horiz { base * 6.0 } else { base * 1.9 }).floor().clamp(2.0, 12.0) as usize;
|
||||
let (min, max, step) = c.axis_scale(false, max_intervals).unwrap_or_else(|| axis_scale(0.0, 1.0, None, None, None, max_intervals, true));
|
||||
// A combo chart's secondary value axis, on the right (vertical value axes only).
|
||||
let second = if horiz { None } else { c.axis_scale(true, max_intervals) };
|
||||
let ticks = |(lo, hi, st): (f64, f64, f64)| {
|
||||
let n = (((hi - lo) / st.max(1e-12)).round() as i64).clamp(1, 50);
|
||||
(0..=n).map(move |k| lo + k as f64 * st)
|
||||
};
|
||||
// Label gutters as wide as the longest value label.
|
||||
let label_w =
|
||||
|sec: bool, sc: (f64, f64, f64)| ticks(sc).map(|v| c.axis_label(sec, v).chars().count()).max().unwrap_or(1) as f64 * base * 0.55 + base * 0.6;
|
||||
let gutter = if horiz {
|
||||
base * 4.5
|
||||
} else if c.y_hidden {
|
||||
base * 0.5
|
||||
} else {
|
||||
Rect::new(area.x0 + gutter, area.y0, area.x1, area.y1 - base * 1.8)
|
||||
label_w(false, (min, max, step)).clamp(base * 1.5, area.width() * 0.3)
|
||||
};
|
||||
let gutter_r = second.filter(|_| !c.y2_hidden).map(|sc| label_w(true, sc).clamp(base * 1.5, area.width() * 0.3)).unwrap_or(0.0);
|
||||
let plot = if horiz {
|
||||
Rect::new(area.x0 + gutter, area.y0, area.x1, area.y1 - base * 1.6)
|
||||
} else {
|
||||
Rect::new(area.x0 + gutter, area.y0, area.x1 - gutter_r, area.y1 - base * 1.8)
|
||||
};
|
||||
if let Some(f) = &c.plot_fill {
|
||||
crate::paint::fill_path(ctx, rctx, f, None, &plot.to_path(0.1), plot, m, deckcraft_geom::preset::FillMode::Norm, 1.0, rctx.pres);
|
||||
}
|
||||
let span = (max - min).max(1e-9);
|
||||
let to_v = |v: f64| if horiz { plot.x0 + (v - min) / span * plot.width() } else { plot.y1 - (v - min) / span * plot.height() };
|
||||
// A series' value as a position on its own axis.
|
||||
let to_axis = |sec: bool, v: f64| match (sec, second) {
|
||||
(true, Some((min2, max2, _))) => plot.y1 - (v - min2) / (max2 - min2).max(1e-9) * plot.height(),
|
||||
_ => to_v(v),
|
||||
};
|
||||
// Gridlines and value labels.
|
||||
let n_steps = ((span / step).round() as i64).clamp(1, 50);
|
||||
for k in 0..=n_steps {
|
||||
let v = min + k as f64 * step;
|
||||
for v in ticks((min, max, step)) {
|
||||
let pos = to_v(v);
|
||||
let text = if c.y_hidden { String::new() } else { c.axis_label(false, v) };
|
||||
let mut g = BezPath::new();
|
||||
if horiz {
|
||||
g.move_to((pos, plot.y0));
|
||||
@@ -190,7 +294,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
ctx,
|
||||
rctx,
|
||||
m,
|
||||
&fmt_num(v),
|
||||
&text,
|
||||
Rect::new(pos - 30.0, plot.y1 + 2.0, pos + 30.0, plot.y1 + base * 1.5),
|
||||
base,
|
||||
Align::Center,
|
||||
@@ -205,7 +309,7 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
ctx,
|
||||
rctx,
|
||||
m,
|
||||
&fmt_num(v),
|
||||
&text,
|
||||
Rect::new(area.x0, pos - base, plot.x0 - base * 0.4, pos + base),
|
||||
base,
|
||||
Align::Right,
|
||||
@@ -221,9 +325,28 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
ctx.stroke_path(&g);
|
||||
}
|
||||
}
|
||||
// The secondary axis' labels, on the right, without gridlines of their own.
|
||||
if let Some(sc) = second.filter(|_| !c.y2_hidden) {
|
||||
for v in ticks(sc) {
|
||||
let pos = to_axis(true, v);
|
||||
label(
|
||||
ctx,
|
||||
rctx,
|
||||
m,
|
||||
&c.axis_label(true, v),
|
||||
Rect::new(plot.x1 + base * 0.4, pos - base, area.x1, pos + base),
|
||||
base,
|
||||
Align::Left,
|
||||
Anchor::Middle,
|
||||
text_col,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
let ncat = c.categories.len().max(c.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).max(1);
|
||||
let band = if horiz { plot.height() / ncat as f64 } else { plot.width() / ncat as f64 };
|
||||
// Category labels.
|
||||
// Category labels: wrapped onto two lines when they don't fit their band (long date ranges).
|
||||
let wraps = !horiz && c.categories.iter().any(|n| n.chars().count() as f64 * base * 0.5 > band);
|
||||
for (i, name) in c.categories.iter().enumerate() {
|
||||
let mid = if horiz { plot.y0 + band * (i as f64 + 0.5) } else { plot.x0 + band * (i as f64 + 0.5) };
|
||||
if horiz {
|
||||
@@ -240,12 +363,13 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
let text = if wraps { split_label(name) } else { name.clone() };
|
||||
label(
|
||||
ctx,
|
||||
rctx,
|
||||
m,
|
||||
name,
|
||||
Rect::new(mid - band / 2.0, plot.y1 + 3.0, mid + band / 2.0, plot.y1 + base * 1.6),
|
||||
&text,
|
||||
Rect::new(mid - band / 2.0, plot.y1 + 3.0, mid + band / 2.0, plot.y1 + base * if wraps { 2.8 } else { 1.6 }),
|
||||
base,
|
||||
Align::Center,
|
||||
Anchor::Top,
|
||||
@@ -254,24 +378,27 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
);
|
||||
}
|
||||
}
|
||||
let ns = c.series.len().max(1);
|
||||
let zero = to_v(0.0f64.clamp(min, max));
|
||||
if c.kind.is_bar_like() {
|
||||
if let Some(bk) = bar_kind {
|
||||
let bars: Vec<(usize, &deckcraft_model::chart::Series)> =
|
||||
c.series.iter().enumerate().filter(|(_, s)| c.series_kind(s).is_bar_like()).collect();
|
||||
let ns = bars.len().max(1);
|
||||
let gap = c.gap_width.clamp(0.0, 5.0);
|
||||
let group = band / (1.0 + gap);
|
||||
let stacked = c.kind.stacked();
|
||||
let stacked = bk.stacked();
|
||||
let bar = if stacked { group } else { group / ns as f64 };
|
||||
for i in 0..ncat {
|
||||
let mut pos_acc = 0.0;
|
||||
let mut neg_acc = 0.0;
|
||||
let total: f64 = if c.kind.percent() {
|
||||
c.series.iter().map(|s| s.values.get(i).copied().flatten().unwrap_or(0.0).abs()).sum::<f64>().max(1e-9)
|
||||
let total: f64 = if bk.percent() {
|
||||
bars.iter().map(|(_, s)| s.values.get(i).copied().flatten().unwrap_or(0.0).abs()).sum::<f64>().max(1e-9)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
for (si, s) in c.series.iter().enumerate() {
|
||||
for (bi, (si, s)) in bars.iter().enumerate() {
|
||||
let sec = c.on_secondary(s);
|
||||
let v = s.values.get(i).copied().flatten().unwrap_or(0.0) / total;
|
||||
let start = band * i as f64 + (band - group) / 2.0 + if stacked { 0.0 } else { bar * si as f64 };
|
||||
let start = band * i as f64 + (band - group) / 2.0 + if stacked { 0.0 } else { bar * bi as f64 };
|
||||
let (a, b) = if stacked {
|
||||
if v >= 0.0 {
|
||||
let r = (pos_acc, pos_acc + v);
|
||||
@@ -288,13 +415,13 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
let r = if horiz {
|
||||
Rect::new(to_v(a), plot.y0 + start, to_v(b), plot.y0 + start + bar)
|
||||
} else {
|
||||
Rect::new(plot.x0 + start, to_v(b), plot.x0 + start + bar, to_v(a))
|
||||
Rect::new(plot.x0 + start, to_axis(sec, b), plot.x0 + start + bar, to_axis(sec, a))
|
||||
};
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(series_fill(rctx, c, si, Some(s)), 1.0));
|
||||
ctx.set_paint(color(series_fill(rctx, c, *si, Some(s)), 1.0));
|
||||
ctx.fill_rect(&r.abs());
|
||||
if c.data_labels {
|
||||
let t = fmt_num(s.values.get(i).copied().flatten().unwrap_or(0.0));
|
||||
let t = c.data_label(s, s.values.get(i).copied().flatten().unwrap_or(0.0));
|
||||
let lr = if horiz {
|
||||
Rect::new(r.x1 + 2.0, r.y0, r.x1 + 40.0, r.y1)
|
||||
} else {
|
||||
@@ -315,66 +442,72 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Lines, areas, scatter, radar (drawn as lines).
|
||||
let stacked = c.kind.stacked();
|
||||
let mut acc = vec![0.0; ncat];
|
||||
for (si, s) in c.series.iter().enumerate() {
|
||||
let col = series_fill(rctx, c, si, Some(s));
|
||||
let mut pts = vec![];
|
||||
for i in 0..ncat {
|
||||
let Some(v) = s.values.get(i).copied().flatten() else { continue };
|
||||
let v = if stacked {
|
||||
let base_v = acc.get(i).copied().unwrap_or(0.0);
|
||||
if let Some(a) = acc.get_mut(i) {
|
||||
*a += v;
|
||||
}
|
||||
base_v + v
|
||||
} else {
|
||||
v
|
||||
};
|
||||
let x = match c.kind {
|
||||
ChartType::Scatter | ChartType::Bubble => {
|
||||
let xs: Vec<f64> = s.x_values.iter().flatten().copied().collect();
|
||||
let (xl, xh) = (xs.iter().copied().fold(f64::MAX, f64::min), xs.iter().copied().fold(f64::MIN, f64::max));
|
||||
let xv = s.x_values.get(i).copied().flatten().unwrap_or(i as f64);
|
||||
if xh > xl { plot.x0 + (xv - xl) / (xh - xl) * plot.width() } else { plot.x0 + band * (i as f64 + 0.5) }
|
||||
}
|
||||
_ => plot.x0 + band * (i as f64 + 0.5),
|
||||
};
|
||||
pts.push(Point::new(x, to_v(v)));
|
||||
}
|
||||
if pts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut p = BezPath::new();
|
||||
for (k, q) in pts.iter().enumerate() {
|
||||
if k == 0 {
|
||||
p.move_to(*q);
|
||||
} else {
|
||||
p.line_to(*q);
|
||||
}
|
||||
// Lines, areas, scatter, radar (drawn as lines): the series that aren't bars.
|
||||
let mut acc = vec![0.0; ncat];
|
||||
for (si, s) in c.series.iter().enumerate() {
|
||||
let kind = c.series_kind(s);
|
||||
if kind.is_bar_like() {
|
||||
continue;
|
||||
}
|
||||
let sec = c.on_secondary(s);
|
||||
let stacked = kind.stacked();
|
||||
let area_kind = matches!(kind, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar);
|
||||
let col = if area_kind { series_fill(rctx, c, si, Some(s)) } else { series_line(rctx, c, si, s) };
|
||||
let mut pts = vec![];
|
||||
for i in 0..ncat {
|
||||
let Some(v) = s.values.get(i).copied().flatten() else { continue };
|
||||
let v = if stacked {
|
||||
let base_v = acc.get(i).copied().unwrap_or(0.0);
|
||||
if let Some(a) = acc.get_mut(i) {
|
||||
*a += v;
|
||||
}
|
||||
}
|
||||
ctx.set_transform(m);
|
||||
if matches!(c.kind, ChartType::Area | ChartType::StackedArea | ChartType::FilledRadar) {
|
||||
let mut a = p.clone();
|
||||
if let (Some(l), Some(f)) = (pts.last(), pts.first()) {
|
||||
a.line_to((l.x, zero));
|
||||
a.line_to((f.x, zero));
|
||||
a.close_path();
|
||||
base_v + v
|
||||
} else {
|
||||
v
|
||||
};
|
||||
let x = match kind {
|
||||
ChartType::Scatter | ChartType::Bubble => {
|
||||
let xs: Vec<f64> = s.x_values.iter().flatten().copied().collect();
|
||||
let (xl, xh) = (xs.iter().copied().fold(f64::MAX, f64::min), xs.iter().copied().fold(f64::MIN, f64::max));
|
||||
let xv = s.x_values.get(i).copied().flatten().unwrap_or(i as f64);
|
||||
if xh > xl { plot.x0 + (xv - xl) / (xh - xl) * plot.width() } else { plot.x0 + band * (i as f64 + 0.5) }
|
||||
}
|
||||
ctx.set_paint(color(col, 0.85));
|
||||
ctx.fill_path(&a);
|
||||
} else if c.kind != ChartType::Scatter {
|
||||
ctx.set_paint(color(col, 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(2.25).with_join(kurbo::Join::Round).with_caps(kurbo::Cap::Round));
|
||||
ctx.stroke_path(&p);
|
||||
_ => plot.x0 + band * (i as f64 + 0.5),
|
||||
};
|
||||
pts.push(Point::new(x, to_axis(sec, v)));
|
||||
}
|
||||
if pts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut p = BezPath::new();
|
||||
for (k, q) in pts.iter().enumerate() {
|
||||
if k == 0 {
|
||||
p.move_to(*q);
|
||||
} else {
|
||||
p.line_to(*q);
|
||||
}
|
||||
if matches!(c.kind, ChartType::LineMarkers | ChartType::Scatter | ChartType::Bubble) {
|
||||
ctx.set_paint(color(col, 1.0));
|
||||
for q in &pts {
|
||||
ctx.fill_path(&kurbo::Circle::new(*q, 3.5).to_path(0.1));
|
||||
}
|
||||
}
|
||||
ctx.set_transform(m);
|
||||
if area_kind {
|
||||
let mut a = p.clone();
|
||||
if let (Some(l), Some(f)) = (pts.last(), pts.first()) {
|
||||
a.line_to((l.x, zero));
|
||||
a.line_to((f.x, zero));
|
||||
a.close_path();
|
||||
}
|
||||
ctx.set_paint(color(col, 0.85));
|
||||
ctx.fill_path(&a);
|
||||
} else if kind != ChartType::Scatter {
|
||||
let lw = s.line.as_ref().and_then(|l| l.width).filter(|w| w.is_finite() && *w > 0.0).unwrap_or(2.25).clamp(0.25, 20.0);
|
||||
ctx.set_paint(color(col, 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(lw).with_join(kurbo::Join::Round).with_caps(kurbo::Cap::Round));
|
||||
ctx.stroke_path(&p);
|
||||
}
|
||||
if matches!(kind, ChartType::LineMarkers | ChartType::Scatter | ChartType::Bubble) {
|
||||
ctx.set_paint(color(col, 1.0));
|
||||
for q in &pts {
|
||||
ctx.fill_path(&kurbo::Circle::new(*q, 3.5).to_path(0.1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -392,3 +525,40 @@ pub fn draw(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64, h
|
||||
ctx.set_stroke(kurbo::Stroke::new(0.75));
|
||||
ctx.stroke_path(&ax);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const AREA: Rect = Rect::new(0.0, 0.0, 400.0, 300.0);
|
||||
|
||||
#[test]
|
||||
fn right_legend_is_a_centred_column_and_narrows_the_plot() {
|
||||
let (o, plot) = legend_layout("r", (60.0, 100.0), AREA, 10.0, false);
|
||||
assert_eq!((o.x, o.y), (340.0, 100.0));
|
||||
assert_eq!((plot.x0, plot.x1, plot.y0, plot.y1), (0.0, 335.0, 0.0, 300.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_top_and_corner_positions() {
|
||||
let (o, plot) = legend_layout("l", (60.0, 100.0), AREA, 10.0, false);
|
||||
assert_eq!((o.x, o.y, plot.x0, plot.x1), (0.0, 100.0, 65.0, 400.0));
|
||||
let (o, plot) = legend_layout("t", (100.0, 14.0), AREA, 10.0, false);
|
||||
assert_eq!((o.x, o.y, plot.y0, plot.y1), (150.0, 0.0, 19.0, 300.0));
|
||||
let (o, plot) = legend_layout("tr", (60.0, 100.0), AREA, 10.0, false);
|
||||
assert_eq!((o.x, o.y, plot.x1), (340.0, 0.0, 335.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bottom_legend_keeps_the_old_row_layout() {
|
||||
let (o, plot) = legend_layout("b", (100.0, 14.0), AREA, 10.0, false);
|
||||
assert_eq!((o.x, o.y, plot.y1, plot.x1), (150.0, 286.0, 281.0, 400.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_does_not_shrink_the_plot() {
|
||||
let (o, plot) = legend_layout("r", (60.0, 100.0), AREA, 10.0, true);
|
||||
assert_eq!(plot, AREA);
|
||||
assert_eq!(o.x, 340.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,6 +681,24 @@ impl Renderer {
|
||||
if depth > 0 && view.determinant().abs() > 1e-12 && view.is_finite() { (view, view.inverse() * m) } else { (m, Affine::IDENTITY) }
|
||||
}
|
||||
|
||||
/// The size and transform a shape's text is set in: inside a group whose child coordinates
|
||||
/// scale the shape, the shape's size in slide points and `m` with that scale taken out, so
|
||||
/// insets and font sizes stay in points; otherwise `w`, `h` and `m` themselves.
|
||||
fn text_space(&self, m: Affine, depth: usize, w: f64, h: f64) -> (f64, f64, Affine) {
|
||||
let (_, rel) = self.line_space(m, depth);
|
||||
let c = rel.as_coeffs();
|
||||
let (sx, sy) = (c[0].hypot(c[1]), c[2].hypot(c[3]));
|
||||
let ok = |k: f64| k.is_finite() && k > 1e-9;
|
||||
if depth == 0 || !ok(sx) || !ok(sy) || ((sx - 1.0).abs() < 1e-9 && (sy - 1.0).abs() < 1e-9) {
|
||||
return (w, h, m);
|
||||
}
|
||||
let (tw, th) = (w * sx, h * sy);
|
||||
if !tw.is_finite() || !th.is_finite() {
|
||||
return (w, h, m);
|
||||
}
|
||||
(tw, th, m * Affine::scale_non_uniform(1.0 / sx, 1.0 / sy))
|
||||
}
|
||||
|
||||
fn shape_at(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) {
|
||||
if depth > 32 || (s.hidden && !f.opts.show_hidden) || f.opts.skip.contains(&s.id) {
|
||||
return;
|
||||
@@ -785,7 +803,12 @@ impl Renderer {
|
||||
let fill = if let Some(t) = st.tint { Some(Fill::solid(deckcraft_model::ColorRef::rgb(t))) } else { fill };
|
||||
// Effects below the shape.
|
||||
if let Some(e) = &effects {
|
||||
self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, m, w, h);
|
||||
// A picture (or picture fill) casts its shadow from its pixels, not its frame.
|
||||
let content = match (&s.kind, &fill) {
|
||||
(ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => Some(paint::resolved(rctx, pf, None).into_owned()),
|
||||
_ => None,
|
||||
};
|
||||
self.effects_below(ctx, rctx, e, fx_ph, &geo, &fill, content.as_ref(), m, w, h);
|
||||
}
|
||||
let soft = effects.as_ref().and_then(|e| e.soft_edge).filter(|r| *r > 0.0);
|
||||
if let Some(r) = soft {
|
||||
@@ -849,6 +872,25 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
}
|
||||
// `a:fillOverlay`: a fill blended over the shape's fill (PowerPoint's "Flow" themes multiply
|
||||
// a gradient over a tiled picture), inside the filled outline, under the line.
|
||||
if let Some(ov) = effects.as_ref().and_then(|e| e.fill_overlay.as_ref())
|
||||
&& fill.as_ref().is_some_and(|f| !f.is_none())
|
||||
{
|
||||
let mix = match ov.blend.as_str() {
|
||||
"mult" => peniko::Mix::Multiply,
|
||||
"screen" => peniko::Mix::Screen,
|
||||
"darken" => peniko::Mix::Darken,
|
||||
"lighten" => peniko::Mix::Lighten,
|
||||
_ => peniko::Mix::Normal,
|
||||
};
|
||||
for (i, sp) in geo.paths.iter().enumerate().filter(|(i, sp)| sp.fill != FillMode::None && fade(*i).fill > 0.0) {
|
||||
ctx.set_transform(m);
|
||||
ctx.push_layer(Some(&sp.path), Some(peniko::BlendMode::new(mix, peniko::Compose::SrcOver)), None, None, None);
|
||||
paint::fill_path(ctx, rctx, &ov.fill, fx_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, FillMode::Norm, fade(i).fill, f.pres);
|
||||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
// Outline.
|
||||
if line.fill.as_ref().is_some_and(|f| !f.is_none()) {
|
||||
let lw = line.width.unwrap_or(0.75).max(0.0);
|
||||
@@ -916,6 +958,16 @@ impl Renderer {
|
||||
if let Some(body) = &s.text
|
||||
&& !skip_text
|
||||
{
|
||||
// Text in a group is set at the shape's size on the slide, in points: a group's
|
||||
// child-coordinate scale sizes and places the box, never the type (#233's twin).
|
||||
let (tw, th, m) = self.text_space(m, depth, w, h);
|
||||
let text_geo;
|
||||
let geo = if (tw, th) == (w, h) {
|
||||
&geo
|
||||
} else {
|
||||
text_geo = shape_geometry(s, tw, th);
|
||||
&text_geo
|
||||
};
|
||||
let tr = geo.text_rect;
|
||||
if !body.is_empty() {
|
||||
let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false });
|
||||
@@ -959,6 +1011,7 @@ impl Renderer {
|
||||
ph: Option<Rgba>,
|
||||
geo: &Geometry,
|
||||
fill: &Option<Fill>,
|
||||
content: Option<&deckcraft_model::style::PictureFill>,
|
||||
m: Affine,
|
||||
w: f64,
|
||||
h: f64,
|
||||
@@ -986,8 +1039,24 @@ impl Renderer {
|
||||
// Blurred by a third of the blur radius, as PowerPoint's export shows.
|
||||
ctx.push_layer(None, None, Some((c.a as f32) / 255.0), None, Some(blur(sh.blur / 3.0 * k)));
|
||||
ctx.set_transform(sm);
|
||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||
ctx.fill_path(&silhouette);
|
||||
match content {
|
||||
// As PowerPoint draws it: the shadow is the picture's own coverage (a logo on a
|
||||
// transparent background casts the logo's shadow): the picture, clipped to the
|
||||
// shape, recoloured to the shadow colour.
|
||||
Some(pf) => {
|
||||
ctx.push_clip_layer(&silhouette);
|
||||
paint::picture(ctx, rctx.pres, pf, Rect::new(0.0, 0.0, w, h), sm, None);
|
||||
ctx.push_layer(None, Some(peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn)), None, None, None);
|
||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||
ctx.fill_rect(&Rect::new(0.0, 0.0, w, h).inflate(1.0, 1.0));
|
||||
ctx.pop_layer();
|
||||
ctx.pop_layer();
|
||||
}
|
||||
None => {
|
||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||
ctx.fill_path(&silhouette);
|
||||
}
|
||||
}
|
||||
ctx.pop_layer();
|
||||
}
|
||||
if let Some(g) = &e.glow {
|
||||
|
||||
@@ -125,6 +125,36 @@ fn effects_tables_charts_render() {
|
||||
assert!(px[0] < 240 && px[0] == px[2], "{px:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combo_chart_draws_columns_and_a_line_on_its_own_axis() {
|
||||
// Columns in the hundreds of thousands, a green line in the thousands on the secondary axis:
|
||||
// the line must sit up in the plot, not flat along the bottom of the primary scale.
|
||||
let mut c = Chart::sample(ChartType::Combo);
|
||||
c.title = None;
|
||||
c.legend = None;
|
||||
c.series.truncate(2);
|
||||
c.categories = vec!["A".into(), "B".into(), "C".into()];
|
||||
c.series[0].kind = Some(ChartType::Column);
|
||||
c.series[0].values = vec![Some(200_000.0), Some(300_000.0), Some(250_000.0)];
|
||||
c.series[1].kind = Some(ChartType::Line);
|
||||
c.series[1].values = vec![Some(7_000.0), Some(7_000.0), Some(7_000.0)];
|
||||
c.series[1].secondary_axis = true;
|
||||
c.series[1].line = Some(deckcraft_model::style::Line::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 176, 80)), 3.0));
|
||||
let sh = Shape { id: ShapeId(620), xfrm: Some(Xfrm::new(0.0, 0.0, 960.0, 540.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() };
|
||||
let img = render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
let green = |x: u32, y0: u32, y1: u32| {
|
||||
(y0..y1).any(|y| {
|
||||
let p = img.pixel(x, y);
|
||||
p[1] > 120 && p[0] < 80 && p[2] < 140
|
||||
})
|
||||
};
|
||||
// The line is in the upper half (7 000 of an axis to 8 000), and columns fill the plot's bottom.
|
||||
assert!(green(480, 20, 270), "line not in the upper half");
|
||||
assert!(!green(480, 400, 500), "line along the bottom");
|
||||
let col = img.pixel(480, 480);
|
||||
assert!(col[0] < 250 || col[1] < 250 || col[2] < 250, "no column: {col:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_prompts_only_in_edit_view() {
|
||||
let p = Presentation::default();
|
||||
@@ -1506,3 +1536,111 @@ fn group_child_scale_does_not_scale_line_widths() {
|
||||
assert!(d * 20 < drawn, "{name}: {d} differing values against {drawn} inked pixels");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_casts_its_shadow_from_its_pixels() {
|
||||
// PowerPoint shades only where a picture paints: a logo on a transparent background casts the
|
||||
// logo's shadow, not its frame's. Here the left half of the image is transparent.
|
||||
let png = {
|
||||
let img = image::RgbaImage::from_fn(8, 4, |x, _| if x < 4 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([20, 160, 20, 255]) });
|
||||
let mut out = std::io::Cursor::new(vec![]);
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||
out.into_inner()
|
||||
};
|
||||
let shadow = deckcraft_model::style::Shadow {
|
||||
color: ColorRef::rgb(deckcraft_color::Rgba::BLACK),
|
||||
blur: 0.0,
|
||||
dist: 20.0,
|
||||
dir: 90.0,
|
||||
inner: false,
|
||||
sx: 1.0,
|
||||
sy: 1.0,
|
||||
kx: 0.0,
|
||||
ky: 0.0,
|
||||
align: String::new(),
|
||||
rotate_with_shape: false,
|
||||
};
|
||||
for as_fill in [false, true] {
|
||||
let mut p = Presentation::default();
|
||||
let media = p.add_media("logo.png", "image/png", png.clone());
|
||||
let pf = deckcraft_model::style::PictureFill { media, ..Default::default() };
|
||||
let mut sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), ..Default::default() };
|
||||
if as_fill {
|
||||
sh.fill = Some(Fill::Picture(pf));
|
||||
sh.line = Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() });
|
||||
} else {
|
||||
sh.kind = ShapeKind::Picture { fill: pf };
|
||||
}
|
||||
sh.effects = Some(deckcraft_model::Effects { outer_shadow: Some(shadow.clone()), ..Default::default() });
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
// Below the picture, 10 pt down: shadow under the opaque right half only.
|
||||
let left = img.pixel(200, 310);
|
||||
let right = img.pixel(400, 310);
|
||||
assert!(left[0] > 240, "no shadow under the transparent half ({as_fill}): {left:?}");
|
||||
assert!(right[0] < 40, "shadow under the opaque half ({as_fill}): {right:?}");
|
||||
// The transparent half shows the slide, not a shadow behind it.
|
||||
assert!(img.pixel(200, 200)[0] > 240, "{as_fill}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_in_a_scaled_group_keeps_its_point_size() {
|
||||
// A group's child coordinates scale its shapes' boxes, never their type: a 24 pt word in a
|
||||
// group whose child space is 1/1600 of its size (as old decks write them) or 4 times it is
|
||||
// 24 pt on the slide, and inside its box (it used to grow until the slide turned black).
|
||||
let draw = |scale: f64| {
|
||||
let mut p = Presentation::default();
|
||||
let mut body = TextBody::from_text("Hello");
|
||||
body.paragraphs[0].runs[0].props.size = Some(24.0);
|
||||
body.paragraphs[0].runs[0].props.fill = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::BLACK)));
|
||||
let child = Shape { id: ShapeId(601), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0 / scale, 200.0 / scale)), text: Some(body), ..Default::default() };
|
||||
let g = Shape {
|
||||
id: ShapeId(600),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
|
||||
kind: ShapeKind::Group { children: vec![child], child: Xfrm::new(0.0, 0.0, 400.0 / scale, 200.0 / scale) },
|
||||
..Default::default()
|
||||
};
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![g];
|
||||
render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||||
};
|
||||
let ((_, y0, _, y1), _) = ink(&draw(1.0));
|
||||
let plain = y1 - y0;
|
||||
assert!(plain > 10 && plain < 30, "24 pt word ink height {plain}");
|
||||
for scale in [1600.0, 4.0, 0.25] {
|
||||
let ((x0, sy0, x1, sy1), _) = ink(&draw(scale));
|
||||
assert!((sy1 - sy0).abs_diff(plain) <= 1, "scale {scale}: ink height {} vs {plain}", sy1 - sy0);
|
||||
assert!(x0 >= 100 && x1 <= 500 && sy0 >= 100 && sy1 <= 300, "scale {scale}: ink inside the box");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_overlay_blends_over_the_fill() {
|
||||
// `a:fillOverlay blend="mult"`: a 50% grey multiplied over a light fill darkens it; "screen"
|
||||
// lightens a dark one; the overlay stays inside the shape.
|
||||
use deckcraft_model::style::FillOverlay;
|
||||
let draw = |base: [u8; 3], blend: &str| {
|
||||
let mut p = Presentation::default();
|
||||
let mut sh = Shape {
|
||||
id: ShapeId(600),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
|
||||
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(base[0], base[1], base[2])))),
|
||||
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
sh.effects = Some(deckcraft_model::Effects {
|
||||
fill_overlay: Some(FillOverlay { blend: blend.into(), fill: Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(128, 128, 128))) }),
|
||||
..Default::default()
|
||||
});
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
(img.pixel(200, 150), img.pixel(50, 50))
|
||||
};
|
||||
let (mult, outside) = draw([200, 100, 50], "mult");
|
||||
assert!((mult[0] as i32 - 100).abs() <= 3 && (mult[1] as i32 - 50).abs() <= 3 && (mult[2] as i32 - 25).abs() <= 3, "{mult:?}");
|
||||
assert_eq!(&outside[..3], &[255, 255, 255], "nothing outside the shape");
|
||||
let (screen, _) = draw([0, 0, 0], "screen");
|
||||
assert!((screen[0] as i32 - 128).abs() <= 3, "{screen:?}");
|
||||
let (over, _) = draw([200, 100, 50], "over");
|
||||
assert!((over[0] as i32 - 128).abs() <= 3 && (over[1] as i32 - 128).abs() <= 3, "{over:?}");
|
||||
}
|
||||
|
||||
+16
-3
@@ -201,6 +201,9 @@ struct Style {
|
||||
/// The `a:ea` and `a:cs` families (theme references resolved), if the document names one.
|
||||
ea: Option<String>,
|
||||
cs: Option<String>,
|
||||
/// The `a:sym` family: private-use characters (U+F000–U+F0FF, as PowerPoint stores symbol-font
|
||||
/// characters) are drawn in it.
|
||||
sym: Option<String>,
|
||||
/// Heading font (`+mj-…`): the theme's major font set applies.
|
||||
major: bool,
|
||||
/// Pair kerning on: `a:rPr/@kern` is set and the size reaches it.
|
||||
@@ -315,6 +318,7 @@ fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgba>) -> Styl
|
||||
lang: r.lang.clone().filter(|l| !l.is_empty()),
|
||||
ea,
|
||||
cs,
|
||||
sym: r.font_sym.as_deref().map(|f| ctx.master.theme.font(f)).filter(|f| !f.trim().is_empty()),
|
||||
major,
|
||||
kern: r.kern.is_some_and(|k| k > 0.0 && size + 1e-6 >= k * scale),
|
||||
}
|
||||
@@ -588,9 +592,18 @@ fn shape_cells(ctx: &Ctx, text: &str, char_style: &[usize], styles: &[Style], fa
|
||||
// Emoji, and pictographs asked to look like text (U+FE0E), pick their own face.
|
||||
let neutral =
|
||||
cluster.iter().all(|c| script::script_of(*c).is_none()) && !deckcraft_fonts::is_emoji_cluster(cluster) && !cluster.contains(&'\u{FE0E}');
|
||||
let face = match prev.as_ref() {
|
||||
Some(p) if neutral && deckcraft_fonts::covers_cluster(p, cluster) && char_style.get(a.wrapping_sub(1)) == Some(&si) => p.clone(),
|
||||
_ => {
|
||||
// A symbol-font character (U+F0xx) with an `a:sym` font draws in that font when it's there.
|
||||
let sym_face = st
|
||||
.sym
|
||||
.as_deref()
|
||||
.filter(|_| cluster.first().is_some_and(|c| ('\u{F000}'..='\u{F0FF}').contains(c)))
|
||||
.filter(|f| db.has_family(f))
|
||||
.map(|f| db.face(f, st.style_name))
|
||||
.filter(|f| deckcraft_fonts::covers_cluster(f, cluster));
|
||||
let face = match (sym_face, prev.as_ref()) {
|
||||
(Some(f), _) => f,
|
||||
(None, Some(p)) if neutral && deckcraft_fonts::covers_cluster(p, cluster) && char_style.get(a.wrapping_sub(1)) == Some(&si) => p.clone(),
|
||||
(None, _) => {
|
||||
let chain = chains.entry((si, slot, sc)).or_insert_with(|| face_chain(ctx, st, slot, sc));
|
||||
db.cascade(cluster, chain, st.style_name)
|
||||
}
|
||||
|
||||
@@ -759,3 +759,25 @@ fn symbol_char_maps_symbol_font_codes() {
|
||||
assert_eq!(symbol_char(&face, "Wingdings", '\u{F06E}'), Some('\u{25A0}'));
|
||||
assert_eq!(symbol_char(&face, "Wingdings", '\u{100}'), None, "not a symbol code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_font_characters_draw_in_the_sym_font() {
|
||||
// PowerPoint stores a Symbol-font character as U+F0xx with `<a:sym typeface="Symbol"/>`:
|
||||
// U+F0DE is the Symbol font's double arrow (⇒). It must come from that font, not from
|
||||
// whichever installed font happens to map U+F0DE (Wingdings draws an arrow at another angle).
|
||||
let symbol = "Symbol";
|
||||
if !FontDb::global().has_family(symbol) {
|
||||
eprintln!("skipped: no Symbol font installed");
|
||||
return;
|
||||
}
|
||||
let (p, mut sh) = setup("x\u{F0DE}y", 600.0, 300.0);
|
||||
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
|
||||
para.props.bullet = Some(Bullet::None);
|
||||
para.runs[0].props.font = Some("Arial".into());
|
||||
para.runs[0].props.font_sym = Some(symbol.into());
|
||||
let l = lay(&p, &sh, 600.0, 300.0);
|
||||
let families: Vec<(String, (usize, usize))> = l.runs.iter().map(|r| (r.face.family.clone(), r.chars)).collect();
|
||||
assert!(families.iter().any(|(f, c)| f.eq_ignore_ascii_case(symbol) && c.0 == 1), "{families:?}");
|
||||
// The letters around it keep the run's font.
|
||||
assert!(families.iter().all(|(f, c)| c.0 == 1 || !f.eq_ignore_ascii_case(symbol)), "{families:?}");
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,438 @@
|
||||
//! The File tab (Backstage, #76) through real egui input: the File tab, Alt+F and Esc, every page,
|
||||
//! and the commands its pages run.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use egui::accesskit::Role;
|
||||
use egui::{Key, Modifiers};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
fn harness(app: SlideApp) -> Harness<'static, SlideApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
// The app keeps asking for frames, so step instead of run().
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn new_app() -> SlideApp {
|
||||
SlideApp::new(Session::with_new(), Services::default())
|
||||
}
|
||||
|
||||
/// A folder of its own for one test's files.
|
||||
fn temp_dir(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("deckcraft-backstage-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Desktop services whose Save dialog answers with `dir`/<suggested name> and records the names.
|
||||
fn saving_into(dir: &std::path::Path) -> (Services, Rc<RefCell<Vec<String>>>) {
|
||||
let asked: Rc<RefCell<Vec<String>>> = Default::default();
|
||||
let log = asked.clone();
|
||||
let dir = dir.to_path_buf();
|
||||
let services = Services {
|
||||
pick_save: Some(Box::new(move |suggested: &str| {
|
||||
log.borrow_mut().push(suggested.to_string());
|
||||
Some(dir.join(suggested).to_string_lossy().to_string())
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
(services, asked)
|
||||
}
|
||||
|
||||
/// Is `r` in the side menu (the harness insets the window by its default margin)?
|
||||
fn in_nav(r: egui::Rect) -> bool {
|
||||
r.left() < 20.0 && r.right() <= NAV_W + 20.0
|
||||
}
|
||||
|
||||
/// Clicks the button called `label` on the page (not the side menu's entry of the same name).
|
||||
fn click_on_page(h: &Harness<'_, SlideApp>, label: &str) {
|
||||
let node = h
|
||||
.query_all_by_role_and_label(Role::Button, label)
|
||||
.find(|n| !in_nav(n.rect()))
|
||||
.or_else(|| h.query_all_by_label(label).find(|n| !in_nav(n.rect())));
|
||||
node.unwrap_or_else(|| panic!("no “{label}” button on the page")).click();
|
||||
}
|
||||
|
||||
/// Clicks the side menu's entry called `label`.
|
||||
fn click_nav(h: &Harness<'_, SlideApp>, label: &str) {
|
||||
let node = h.query_all_by_label(label).find(|n| in_nav(n.rect()));
|
||||
node.unwrap_or_else(|| panic!("no “{label}” in the side menu")).click();
|
||||
}
|
||||
|
||||
fn page(h: &Harness<'_, SlideApp>) -> Option<Page> {
|
||||
h.state().backstage.as_ref().map(|b| b.page)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ids_round_trip_and_unknown_ids_are_refused() {
|
||||
for p in Page::ALL {
|
||||
assert_eq!(Page::parse(p.id()), Some(p));
|
||||
}
|
||||
assert_eq!(Page::parse(" SAVEAS "), Some(Page::SaveAs));
|
||||
assert_eq!(Page::parse("share"), None);
|
||||
assert_eq!(Page::parse(""), None);
|
||||
let (top, bottom) = entries(false);
|
||||
assert_eq!(top, vec![Nav::Page(Page::Home), Nav::Page(Page::New), Nav::Page(Page::Open)], "the start screen has no presentation pages");
|
||||
assert_eq!(bottom, vec![Nav::Page(Page::Account), Nav::Options]);
|
||||
assert_eq!(entries(true).0.len(), 9);
|
||||
assert_eq!(split_path("/home/me/Talk.pptx"), ("Talk.pptx".into(), "/home/me".into()));
|
||||
assert_eq!(format_bytes(512), "512 bytes");
|
||||
assert_eq!(format_bytes(33_485), "32.7 KB");
|
||||
}
|
||||
|
||||
/// The File tab opens Home in place of the ribbon; Esc and the back arrow return.
|
||||
#[test]
|
||||
fn the_file_tab_opens_the_backstage_and_esc_or_back_returns() {
|
||||
let mut h = harness(new_app());
|
||||
assert!(h.query_by_label("Blank Presentation").is_none(), "the slide shows first");
|
||||
h.get_by_label("File").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(page(&h), Some(Page::Home));
|
||||
assert!(h.state().backstage_open());
|
||||
assert!(h.query_by_label("Blank Presentation").is_some(), "Home offers new presentations");
|
||||
assert!(h.query_by_label("File").is_none(), "the ribbon is hidden");
|
||||
h.key_press(Key::Escape);
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().backstage_open(), "Esc goes back to the slide");
|
||||
assert!(h.query_by_label("File").is_some());
|
||||
|
||||
h.get_by_label("File").click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Back").click();
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().backstage_open(), "so does the back arrow");
|
||||
assert_eq!(h.state().session.documents().len(), 1, "nothing was created or closed");
|
||||
}
|
||||
|
||||
/// Alt+F opens it with the keyboard in the side menu; arrows walk it (pages show as they get the
|
||||
/// focus); Enter runs an action; Esc goes back.
|
||||
#[test]
|
||||
fn alt_f_arrows_enter_and_esc_drive_the_side_menu() {
|
||||
let mut h = harness(new_app());
|
||||
h.key_press_modifiers(Modifiers::ALT, Key::F);
|
||||
h.run_steps(3);
|
||||
assert_eq!(page(&h), Some(Page::Home));
|
||||
assert_eq!(h.state().backstage.as_ref().and_then(|b| b.focus), Some(0));
|
||||
for want in [Page::New, Page::Open, Page::Info] {
|
||||
h.key_press(Key::ArrowDown);
|
||||
h.run_steps(2);
|
||||
assert_eq!(page(&h), Some(want));
|
||||
}
|
||||
// Save has the focus now; the page stays Info until it is run.
|
||||
h.key_press(Key::ArrowDown);
|
||||
h.run_steps(2);
|
||||
assert_eq!(page(&h), Some(Page::Info));
|
||||
// A presentation that was never saved goes to Save As, as in PowerPoint.
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(2);
|
||||
assert_eq!(page(&h), Some(Page::SaveAs));
|
||||
// Up from the top wraps to Options at the bottom.
|
||||
for _ in 0..20 {
|
||||
h.key_press(Key::ArrowUp);
|
||||
}
|
||||
h.run_steps(2);
|
||||
h.key_press(Key::Escape);
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().backstage_open());
|
||||
assert!(!h.state().session.active().unwrap().is_dirty(), "the keys never reached the slide");
|
||||
}
|
||||
|
||||
/// Every page draws its own content.
|
||||
#[test]
|
||||
fn every_page_renders() {
|
||||
let mut h = harness(new_app());
|
||||
let last_theme = deckcraft_model::theme::builtin_themes().last().map(|t| t.name.clone()).unwrap();
|
||||
let checks: [(Page, &str); 8] = [
|
||||
(Page::Home, "Recent"),
|
||||
(Page::New, &last_theme),
|
||||
(Page::Open, "Browse"),
|
||||
(Page::Info, "Hidden slides"),
|
||||
(Page::SaveAs, "Not saved yet"),
|
||||
(Page::Print, "Print Hidden Slides"),
|
||||
(Page::Export, "Create PDF Document"),
|
||||
(Page::Account, "About DeckCraft"),
|
||||
];
|
||||
for (p, marker) in checks {
|
||||
h.state_mut().run("app.backstage", json!({"page": p.id()})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(page(&h), Some(p));
|
||||
assert!(h.query_all_by_label_contains(marker).next().is_some(), "{} shows “{marker}”", p.label());
|
||||
let selected = h.query_all_by_label(p.label()).any(|n| in_nav(n.rect()));
|
||||
assert!(selected, "{} is in the side menu", p.label());
|
||||
}
|
||||
assert!(h.state_mut().run("app.backstage", json!({"page": "share"})).is_err(), "Share (cloud) isn't offered");
|
||||
}
|
||||
|
||||
/// New and Home create presentations, which leaves the File tab.
|
||||
#[test]
|
||||
fn new_creates_a_presentation() {
|
||||
let mut h = harness(new_app());
|
||||
h.state_mut().run("app.backstage", json!({"page": "new"})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Blank Presentation").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.documents().len(), 2);
|
||||
assert!(h.state().backstage.is_none(), "the new presentation shows");
|
||||
|
||||
let theme = deckcraft_model::theme::builtin_themes().into_iter().nth(1).unwrap();
|
||||
h.state_mut().run("app.backstage", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.get_by_label(&theme.name).click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.documents().len(), 3);
|
||||
assert_eq!(h.state().session.active().unwrap().doc.masters[0].theme.name, theme.name);
|
||||
assert!(h.state().backstage.is_none());
|
||||
}
|
||||
|
||||
/// Open lists recent presentations by name (filtered by the search box) and opens one.
|
||||
#[test]
|
||||
fn open_lists_recent_files_and_opens_one() {
|
||||
let dir = temp_dir("open");
|
||||
let path = dir.join("Quarterly review.pptx").to_string_lossy().to_string();
|
||||
let mut s = Session::with_new();
|
||||
s.execute("file.saveAs", &json!({"path": path})).unwrap();
|
||||
let mut app = SlideApp::new(Session::with_new(), Services::default());
|
||||
app.ui.recent = vec![path.clone(), dir.join("Other.pptx").to_string_lossy().to_string()];
|
||||
let mut h = harness(app);
|
||||
h.state_mut().run("app.backstage", json!({"page": "open"})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label("Other.pptx").is_some());
|
||||
h.state_mut().backstage.as_mut().unwrap().query = "quarter".into();
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("Other.pptx").is_none(), "the search box filters by name");
|
||||
h.get_by_label("Quarterly review.pptx").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.documents().len(), 2);
|
||||
assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some(path.as_str()));
|
||||
assert!(h.state().backstage.is_none(), "the opened presentation shows");
|
||||
assert_eq!(h.state().ui.recent.first(), Some(&path));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Save As saves through the Save dialog and the presentation takes the new file; Export writes
|
||||
/// copies (PDF, another file type) and leaves the presentation's own file alone.
|
||||
#[test]
|
||||
fn save_as_and_export_run_the_file_commands() {
|
||||
let dir = temp_dir("saveas");
|
||||
let (services, asked) = saving_into(&dir);
|
||||
let mut h = harness(SlideApp::new(Session::with_new(), services));
|
||||
let title = h.state().session.active().unwrap().title();
|
||||
|
||||
// Save on a presentation without a file goes to Save As.
|
||||
h.state_mut().run("app.backstage", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
click_nav(&h, "Save");
|
||||
h.run_steps(3);
|
||||
assert_eq!(page(&h), Some(Page::SaveAs));
|
||||
click_on_page(&h, "Save");
|
||||
h.run_steps(3);
|
||||
let pptx = dir.join(format!("{title}.pptx")).to_string_lossy().to_string();
|
||||
assert!(std::path::Path::new(&pptx).is_file(), "saved as PowerPoint by default");
|
||||
assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some(pptx.as_str()));
|
||||
assert!(h.state().backstage.is_none(), "a save returns to the slide");
|
||||
|
||||
// Export › Create PDF Document.
|
||||
h.state_mut().run("app.backstage", json!({"page": "export"})).unwrap();
|
||||
h.run_steps(3);
|
||||
click_on_page(&h, "Create PDF");
|
||||
h.run_steps(3);
|
||||
let pdf = std::fs::read(dir.join(format!("{title}.pdf"))).unwrap();
|
||||
assert!(pdf.starts_with(b"%PDF"));
|
||||
|
||||
// Export › Change File Type › Outline.
|
||||
click_on_page(&h, "Change File Type");
|
||||
h.run_steps(3);
|
||||
click_on_page(&h, "Outline (*.txt)");
|
||||
h.run_steps(2);
|
||||
click_on_page(&h, "Save As");
|
||||
h.run_steps(3);
|
||||
assert!(dir.join(format!("{title}.txt")).is_file());
|
||||
assert_eq!(h.state().session.active().unwrap().path.as_deref(), Some(pptx.as_str()), "exports are copies");
|
||||
assert_eq!(*asked.borrow(), vec![format!("{title}.pptx"), format!("{title}.pdf"), format!("{title}.txt")]);
|
||||
|
||||
// Save with a file saves in place and goes back.
|
||||
h.state_mut().run("slide.new", json!({})).unwrap();
|
||||
h.state_mut().run("app.backstage", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
click_nav(&h, "Save");
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().session.active().unwrap().is_dirty());
|
||||
assert!(h.state().backstage.is_none());
|
||||
assert_eq!(asked.borrow().len(), 3, "no Save dialog for a presentation with a file");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Close closes a saved presentation at once and asks about unsaved changes first.
|
||||
#[test]
|
||||
fn close_closes_and_asks_about_unsaved_changes() {
|
||||
let mut h = harness(new_app());
|
||||
h.state_mut().run("app.backstage", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
click_nav(&h, "Close");
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.documents().len(), 0);
|
||||
assert_eq!(page(&h), Some(Page::Home), "the start screen shows");
|
||||
assert!(h.query_by_label("Back").is_none() && h.query_by_label("Close").is_none(), "nothing to go back to or close");
|
||||
|
||||
// Unsaved: Cancel keeps it, Don't Save closes it.
|
||||
h.get_by_label("Blank Presentation").click();
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("slide.new", json!({})).unwrap();
|
||||
h.state_mut().run("app.backstage", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
click_nav(&h, "Close");
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("close"));
|
||||
h.get_by_label("Cancel").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.documents().len(), 1);
|
||||
assert!(h.state().backstage_open(), "Cancel leaves the File tab as it was");
|
||||
click_nav(&h, "Close");
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Don't Save").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.documents().len(), 0);
|
||||
}
|
||||
|
||||
/// Ctrl+W asks too (it used to close unsaved presentations without a word).
|
||||
#[test]
|
||||
fn ctrl_w_asks_before_closing_unsaved_changes() {
|
||||
let mut h = harness(new_app());
|
||||
h.state_mut().run("slide.new", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::W);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.documents().len(), 1);
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("close"));
|
||||
}
|
||||
|
||||
/// Info shows the presentation's counts and properties, and edits them through file.properties.
|
||||
#[test]
|
||||
fn info_shows_and_edits_the_properties() {
|
||||
let mut app = new_app();
|
||||
app.run("file.properties", json!({"title": "Quarterly", "keywords": "sales"})).unwrap();
|
||||
app.run("slide.new", json!({})).unwrap();
|
||||
app.run("slide.hide", json!({})).unwrap();
|
||||
let mut h = harness(app);
|
||||
h.state_mut().run("app.backstage", json!({"page": "info"})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Title").value().as_deref(), Some("Quarterly"));
|
||||
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Tags").value().as_deref(), Some("sales"));
|
||||
assert!(h.query_all_by_label("Not saved yet").next().is_some());
|
||||
let author = h.get_by_role_and_label(Role::TextInput, "Author");
|
||||
author.click();
|
||||
h.run_steps(2);
|
||||
h.get_by_role_and_label(Role::TextInput, "Author").type_text("Ada");
|
||||
h.run_steps(2);
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.active().unwrap().doc.props.author, "Ada");
|
||||
// Esc in a field leaves the field; the next one goes back.
|
||||
assert!(h.state().backstage_open());
|
||||
}
|
||||
|
||||
/// With nothing open the File tab is the start screen: Home, New and Open, no back arrow, and Esc
|
||||
/// has nowhere to go.
|
||||
#[test]
|
||||
fn the_start_screen_is_file_home() {
|
||||
let mut h = harness(SlideApp::new(Session::new(), Services::default()));
|
||||
assert_eq!(page(&h), Some(Page::Home));
|
||||
assert!(h.query_by_label("Back").is_none());
|
||||
assert!(h.query_all_by_label("Info").next().is_none());
|
||||
h.key_press(Key::Escape);
|
||||
h.run_steps(2);
|
||||
assert_eq!(page(&h), Some(Page::Home));
|
||||
h.key_press(Key::ArrowDown);
|
||||
h.run_steps(2);
|
||||
assert_eq!(page(&h), Some(Page::New));
|
||||
assert!(h.state_mut().run("app.backstage", json!({"page": "print"})).is_err(), "nothing to print");
|
||||
h.get_by_label("Blank Presentation").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.documents().len(), 1);
|
||||
assert!(h.state().backstage.is_none());
|
||||
}
|
||||
|
||||
/// Print previews the pages its settings make and prints through a PDF opened in the system
|
||||
/// viewer.
|
||||
#[test]
|
||||
fn print_previews_pages_and_prints_a_pdf() {
|
||||
let dir = temp_dir("print");
|
||||
let (services, _) = saving_into(&dir);
|
||||
let mut app = SlideApp::new(Session::with_new(), services);
|
||||
for _ in 0..3 {
|
||||
app.run("slide.new", json!({})).unwrap();
|
||||
}
|
||||
app.run("slide.hide", json!({"index": 1})).unwrap();
|
||||
let mut h = harness(app);
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::P);
|
||||
h.run_steps(3);
|
||||
assert_eq!(page(&h), Some(Page::Print), "Ctrl+P opens File › Print");
|
||||
assert!(h.query_by_label("1 of 3").is_some(), "hidden slides are left out");
|
||||
h.state_mut().backstage.as_mut().unwrap().print_layout = "h4";
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("1 of 1").is_some(), "four slides a page");
|
||||
h.state_mut().backstage.as_mut().unwrap().print_layout = "slides";
|
||||
h.state_mut().backstage.as_mut().unwrap().print_hidden = true;
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("1 of 4").is_some());
|
||||
h.get_by_label("Next Page").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("2 of 4").is_some());
|
||||
{
|
||||
let bs = h.state_mut().backstage.as_mut().unwrap();
|
||||
bs.print_scope = PrintScope::Custom;
|
||||
bs.print_range = "9".into();
|
||||
}
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label_contains("outside them").is_some(), "a bad range says why");
|
||||
h.state_mut().backstage.as_mut().unwrap().print_range = "1-2".into();
|
||||
h.run_steps(2);
|
||||
click_on_page(&h, "Print");
|
||||
h.run_steps(2);
|
||||
let url = h.state().pending_urls.first().cloned().unwrap_or_default();
|
||||
// The URL was handed to egui at the end of the frame; check what it pointed at.
|
||||
let printed = std::env::temp_dir().join(format!("{} (print).pdf", h.state().session.active().unwrap().title()));
|
||||
assert!(printed.is_file(), "the PDF to print was written: {url}");
|
||||
assert!(std::fs::read(&printed).unwrap().starts_with(b"%PDF"));
|
||||
let _ = std::fs::remove_file(printed);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_slides_follow_the_settings() {
|
||||
let mut s = Session::with_new();
|
||||
for _ in 0..4 {
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
}
|
||||
s.execute("slide.hide", &json!({"index": 2})).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let mut bs = Backstage::new(Page::Print, (1, Some(0)));
|
||||
assert_eq!(print_slides(&doc, &bs, 0).unwrap(), vec![0, 1, 3, 4]);
|
||||
bs.print_hidden = true;
|
||||
assert_eq!(print_slides(&doc, &bs, 0).unwrap().len(), 5);
|
||||
bs.print_scope = PrintScope::Current;
|
||||
assert_eq!(print_slides(&doc, &bs, 2).unwrap(), vec![2], "the current slide prints even when hidden");
|
||||
bs.print_scope = PrintScope::Custom;
|
||||
bs.print_range = "2-4".into();
|
||||
bs.print_hidden = false;
|
||||
assert_eq!(print_slides(&doc, &bs, 0).unwrap(), vec![1, 3]);
|
||||
bs.print_layout = "h6";
|
||||
assert_eq!(print_params(&bs, &[1, 3])["perPage"], 6);
|
||||
assert_eq!(print_params(&bs, &[1, 3])["layout"], "handouts");
|
||||
}
|
||||
@@ -216,7 +216,7 @@ pub fn ime_area(app: &SlideApp, xf: Xf) -> Option<Rect> {
|
||||
/// separate jamo (ㅇ ㅏ ㄴ) instead of syllables. A focused text field sets its own.
|
||||
fn ime_output(app: &SlideApp, ui: &Ui, xf: Xf) {
|
||||
let ctx = ui.ctx();
|
||||
if ctx.memory(|m| m.focused().is_some()) || app.dialog.as_ref().is_some_and(|d| d.modal()) || app.palette.is_some() || app.home_open() {
|
||||
if ctx.memory(|m| m.focused().is_some()) || app.dialog.as_ref().is_some_and(|d| d.modal()) || app.palette.is_some() || app.backstage_open() {
|
||||
return;
|
||||
}
|
||||
let Some(r) = ime_area(app, xf) else { return };
|
||||
|
||||
@@ -73,7 +73,8 @@ pub fn inspect(app: &SlideApp, ctx: &egui::Context) -> Value {
|
||||
"slideRect": app.slide_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])),
|
||||
"canvasScale": app.canvas_scale,
|
||||
"dialog": app.dialog.as_ref().map(|d| d.id.clone()),
|
||||
"home": app.home_open(),
|
||||
"home": app.backstage_open(),
|
||||
"backstage": app.backstage.as_ref().map(|b| b.page.id()),
|
||||
"showing": app.show.as_ref().map(|s| json!({"slide": s.state.slide, "step": s.state.step, "ended": s.ended})),
|
||||
"documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty(), "path": d.path})).collect::<Vec<_>>(),
|
||||
"status": app.status.as_ref().map(|s| s.0.clone()),
|
||||
|
||||
+36
-168
@@ -1,11 +1,11 @@
|
||||
//! Dialogs (Insert Table, Slide Size, Header & Footer, Hyperlink, Set Up Show, Zoom, …), the start
|
||||
//! screen and the About window.
|
||||
//! Dialogs (Insert Table, Slide Size, Header & Footer, Hyperlink, Set Up Show, Zoom, …) and the
|
||||
//! About window. The start screen is File › Home (`backstage.rs`).
|
||||
|
||||
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::SlideApp;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{SlideApp, ribbon};
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
pub struct Dialog {
|
||||
@@ -290,6 +290,37 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
});
|
||||
done
|
||||
}
|
||||
"close" => {
|
||||
// File › Close and Ctrl+W on a presentation with unsaved changes.
|
||||
let Some(name) = app.session.active().map(|d| d.title()) else { return true };
|
||||
ui.label(egui::RichText::new(format!("Do you want to save the changes you made to “{name}”?")).strong());
|
||||
ui.label("Your changes will be lost if you don't save them.");
|
||||
ui.add_space(8.0);
|
||||
let mut done = false;
|
||||
ui.horizontal(|ui| {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
if ui.add(egui::Button::new("Don't Save").min_size(vec2(84.0, 24.0))).clicked() {
|
||||
run(app, "file.close", json!({}));
|
||||
done = true;
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let save = ui.add(egui::Button::new(egui::RichText::new("Save").color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0)));
|
||||
if save.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
app.save();
|
||||
// In the browser a save is a download: the presentation stays "unsaved".
|
||||
let web = app.services.pick_save.is_none() && app.services.download.is_some();
|
||||
if web || app.session.active().is_some_and(|d| !d.is_dirty()) {
|
||||
run(app, "file.close", json!({}));
|
||||
}
|
||||
done = true;
|
||||
}
|
||||
if ui.add(egui::Button::new("Cancel").min_size(vec2(72.0, 24.0))).clicked() {
|
||||
done = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
done
|
||||
}
|
||||
"zoom" => {
|
||||
for pct in [400, 200, 150, 100, 75, 66, 50, 33] {
|
||||
if ui.radio(false, format!("{pct}%")).clicked() {
|
||||
@@ -430,45 +461,8 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
};
|
||||
let (ok, cancel) = buttons(ui, "Export");
|
||||
if ok {
|
||||
let name = app.session.active().map(|s| s.title()).unwrap_or_else(|| "Presentation".into());
|
||||
let (ext, all) = match fmt.as_str() {
|
||||
"pdf" => ("pdf", false),
|
||||
"pngAll" => ("png", true),
|
||||
"jpeg" => ("jpg", false),
|
||||
"pptx" => ("pptx", false),
|
||||
"outline" => ("txt", false),
|
||||
_ => ("png", false),
|
||||
};
|
||||
let suggested = format!("{name}.{ext}");
|
||||
if let Some(pick) = app.services.pick_save.as_mut() {
|
||||
if let Some(path) = pick(&suggested) {
|
||||
let mut params = json!({"path": path, "all": all});
|
||||
if ext == "pdf"
|
||||
&& let (Some(o), Some(extra)) = (params.as_object_mut(), pdf_params.as_object())
|
||||
{
|
||||
o.extend(extra.clone());
|
||||
}
|
||||
let r = app.session.execute("file.export", ¶ms);
|
||||
match r {
|
||||
Ok(_) => app.set_status(format!("Exported {path}")),
|
||||
Err(e) => app.set_status(e.to_string()),
|
||||
}
|
||||
}
|
||||
} else if let (Some(dl), Some(s)) = (app.services.download.as_mut(), app.session.active()) {
|
||||
let bytes = match ext {
|
||||
"png" | "jpg" => {
|
||||
let img = deckcraft_render::render_slide(
|
||||
&s.doc,
|
||||
s.selection.slide,
|
||||
&deckcraft_render::RenderOpts { scale: 2.0, ..Default::default() },
|
||||
);
|
||||
if ext == "png" { img.to_png() } else { img.to_jpeg(92) }
|
||||
}
|
||||
"pdf" => deckcraft_pdf_bytes(&s.doc, &pdf_params),
|
||||
other => deckcraft_engine::cmd::file::save_bytes(&s.doc, other).unwrap_or_default(),
|
||||
};
|
||||
dl(&suggested, &bytes);
|
||||
}
|
||||
let extra = if fmt == "pdf" { pdf_params } else { json!({}) };
|
||||
app.export_as(&fmt, extra);
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
@@ -835,129 +829,3 @@ pub fn paint_logo(p: &egui::Painter, r: Rect) {
|
||||
Stroke::NONE,
|
||||
));
|
||||
}
|
||||
|
||||
/// The start screen (no presentation open): new from themes, open, recent files.
|
||||
pub fn start_screen(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
ui.add_space(24.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(40.0);
|
||||
let (r, _) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::hover());
|
||||
paint_logo(ui.painter(), r);
|
||||
ui.label(egui::RichText::new("DeckCraft").font(theme::bold(26.0)));
|
||||
});
|
||||
if app.home_open() {
|
||||
let title = app.session.active().map(|d| d.title()).unwrap_or_default();
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(40.0);
|
||||
if ui.button(format!("← Back to {title}")).on_hover_text("Esc").clicked() {
|
||||
let _ = app.run("app.home", json!({"on": false}));
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(40.0);
|
||||
ui.label(egui::RichText::new("New presentation").font(theme::bold(16.0)));
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
inset_wrapped(ui, |ui| {
|
||||
for th in deckcraft_model::theme::builtin_themes() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(180.0, 128.0), Sense::click());
|
||||
let tile = Rect::from_min_size(r.min, vec2(180.0, 101.0));
|
||||
ribbon::paint_theme_tile(ui.painter(), tile, &th);
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_stroke(tile, CornerRadius::same(2), Stroke::new(2.0, t.accent), egui::StrokeKind::Outside);
|
||||
}
|
||||
ui.painter().text(pos2(r.min.x, r.max.y - 10.0), Align2::LEFT_CENTER, &th.name, theme::font(12.0), t.text);
|
||||
if resp.clicked() {
|
||||
let _ = app.run("file.new", json!({"theme": th.name}));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(16.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(40.0);
|
||||
if ui.button("Open…").clicked() {
|
||||
let _ = app.run("app.openDialog", json!({}));
|
||||
}
|
||||
if ui.button("Open the sample deck").clicked()
|
||||
&& let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session)
|
||||
{
|
||||
app.set_status(e.to_string());
|
||||
}
|
||||
});
|
||||
if !app.ui.recent.is_empty() {
|
||||
ui.add_space(16.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(40.0);
|
||||
ui.label(egui::RichText::new("Recent").font(theme::bold(16.0)));
|
||||
});
|
||||
for p in app.ui.recent.clone() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(40.0);
|
||||
if ui.link(&p).clicked()
|
||||
&& let Err(e) = app.open_path(&p)
|
||||
{
|
||||
app.set_status(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
ui.add_space(30.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(40.0);
|
||||
ui.hyperlink_to("Join the ArtCraft community on Discord", deckcraft_engine::links::DISCORD);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Wrapped rows that all start 40 points in from the left, in line with the start screen's
|
||||
/// headings. The inset is a margin on the container: an `add_space` inside `horizontal_wrapped`
|
||||
/// would indent only the first row.
|
||||
fn inset_wrapped(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui)) {
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 40, ..Default::default() }).show(ui, |ui| {
|
||||
ui.horizontal_wrapped(add_contents);
|
||||
});
|
||||
}
|
||||
|
||||
/// PDF bytes with the export dialog's options (web: downloaded instead of written).
|
||||
fn deckcraft_pdf_bytes(doc: &deckcraft_model::Presentation, params: &serde_json::Value) -> Vec<u8> {
|
||||
deckcraft_engine::cmd::file::pdf_bytes(doc, params).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::{Cell, RefCell};
|
||||
|
||||
/// Start screen theme tiles that wrap onto a second row keep the 40-point inset (#17).
|
||||
#[test]
|
||||
fn inset_wrapped_indents_every_row() {
|
||||
let left = Cell::new(0.0_f32);
|
||||
let tiles = RefCell::new(Vec::<Rect>::new());
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(vec2(500.0, 400.0)).build_ui(|ui| {
|
||||
left.set(ui.max_rect().left());
|
||||
tiles.borrow_mut().clear();
|
||||
inset_wrapped(ui, |ui| {
|
||||
for _ in 0..4 {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(180.0, 128.0), Sense::click());
|
||||
tiles.borrow_mut().push(r);
|
||||
}
|
||||
});
|
||||
});
|
||||
harness.run();
|
||||
let tiles = tiles.borrow().clone();
|
||||
let first_top = tiles.first().map_or(0.0, |r| r.top());
|
||||
assert!(tiles.iter().any(|r| r.top() > first_top), "tiles should wrap onto a second row: {tiles:?}");
|
||||
let mut row_top = f32::NEG_INFINITY;
|
||||
for r in &tiles {
|
||||
if r.top() > row_top {
|
||||
row_top = r.top();
|
||||
assert!((r.left() - (left.get() + 40.0)).abs() < 0.5, "row starts at {} instead of {}", r.left(), left.get() + 40.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,6 +306,13 @@ icons! {
|
||||
Collapse => "collapse",
|
||||
Expand => "expand",
|
||||
Pin => "pin",
|
||||
// File tab (Backstage)
|
||||
BackCircle => "back-circle",
|
||||
SaveAs => "save-as",
|
||||
Account => "account",
|
||||
Recent => "recent",
|
||||
NewFile => "new-file",
|
||||
OpenFolder => "open-folder",
|
||||
// Status bar (small, monochrome)
|
||||
NotesSmall => "notes-small",
|
||||
CommentsSmall => "comments-small",
|
||||
@@ -2361,6 +2368,33 @@ fn draw(p: &Pen<'_>, icon: Icon) {
|
||||
p.rshape(&m(&[(-4.2, -10.0), (4.2, -10.0), (4.2, -7.0), (-4.2, -7.0)]), 0.6, R, N);
|
||||
}
|
||||
|
||||
// ----- File tab (Backstage) navigation: outlines in the ink colour -----
|
||||
I::BackCircle => {
|
||||
p.circle((12.0, 12.0), 9.8, N, K);
|
||||
p.arrow((17.0, 12.0), (6.5, 12.0), K);
|
||||
}
|
||||
I::SaveAs => {
|
||||
p.rline(&[(11.0, 20.5), (2.5, 20.5), (2.5, 3.0), (15.0, 3.0), (18.0, 6.0), (18.0, 10.5)], 1.2, K);
|
||||
p.rect(6.0, 3.0, 13.0, 7.5, 0.6, N, K);
|
||||
p.seg_w((13.5, 21.0), (21.5, 13.0), O, 1.4);
|
||||
p.seg_w((12.5, 22.0), (13.5, 21.0), K, 1.0);
|
||||
}
|
||||
I::Account => {
|
||||
p.circle((12.0, 8.0), 4.5, N, K);
|
||||
p.line(&arc_pts((12.0, 22.0), 8.5, 7.5, 180.0, 360.0), K);
|
||||
}
|
||||
I::Recent => {
|
||||
p.circle((12.0, 12.0), 9.8, N, K);
|
||||
p.line(&[(12.0, 6.0), (12.0, 12.0), (16.0, 14.5)], K);
|
||||
}
|
||||
I::NewFile => {
|
||||
p.rline(&[(14.0, 2.5), (5.0, 2.5), (5.0, 21.5), (19.0, 21.5), (19.0, 7.5), (14.0, 2.5), (14.0, 7.5), (19.0, 7.5)], 0.8, K);
|
||||
}
|
||||
I::OpenFolder => {
|
||||
p.rline(&[(18.5, 9.0), (18.5, 6.5), (10.0, 6.5), (8.0, 4.0), (2.5, 4.0), (2.5, 19.5), (18.0, 19.5)], 1.0, K);
|
||||
p.rline(&[(2.5, 19.5), (6.0, 10.0), (22.0, 10.0), (18.0, 19.5)], 1.0, K);
|
||||
}
|
||||
|
||||
// ----- Status bar (monochrome) -----
|
||||
I::NotesSmall => {
|
||||
p.rect(3.0, 4.0, 21.0, 20.0, 1.5, N, K);
|
||||
|
||||
+209
-29
@@ -6,6 +6,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod backstage;
|
||||
pub mod canvas;
|
||||
pub mod control;
|
||||
pub mod credits;
|
||||
@@ -154,6 +155,9 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
("show.presenter", "Presenter View", None, "{on?: bool}"),
|
||||
("show.end", "End Show", None, "{}"),
|
||||
("app.home", "Home", None, "{on?}"),
|
||||
("app.backstage", "File", Some("Alt+F"), "{page?: home|new|open|info|saveAs|print|export|account, on?: bool}"),
|
||||
("app.closePresentation", "Close", Some("Cmd+W"), "{}"),
|
||||
("app.print", "Print", Some("Cmd+P"), "{layout?, perPage?, slides?, includeHidden?, frame?}"),
|
||||
("app.about", "About DeckCraft", None, "{}"),
|
||||
("app.palette", "Command Palette", Some("Cmd+Shift+P"), "{}"),
|
||||
("app.preferences", "Preferences…", Some("Cmd+,"), "{}"),
|
||||
@@ -200,9 +204,10 @@ pub struct SlideApp {
|
||||
pub palette: Option<(String, usize)>,
|
||||
/// Integrated title bar (macOS traffic lights over our chrome).
|
||||
pub integrated_titlebar: bool,
|
||||
/// The start screen is shown over the open presentations (Home), with the document count
|
||||
/// and active index when it was opened: opening or creating a presentation leaves it.
|
||||
pub home: Option<(usize, Option<usize>)>,
|
||||
/// The File tab's full-window view (Backstage). Over a presentation it replaces the ribbon,
|
||||
/// slides and status bar until Esc or Back; with nothing open it is the start screen. Opening,
|
||||
/// creating or closing a presentation leaves it.
|
||||
pub backstage: Option<backstage::Backstage>,
|
||||
pending_urls: Vec<String>,
|
||||
shot_token: u64,
|
||||
queued_shots: Vec<(u64, f64, u32)>,
|
||||
@@ -247,7 +252,7 @@ impl SlideApp {
|
||||
canvas_region: None,
|
||||
palette: None,
|
||||
integrated_titlebar: false,
|
||||
home: None,
|
||||
backstage: None,
|
||||
pending_urls: vec![],
|
||||
shot_token: 0,
|
||||
queued_shots: vec![],
|
||||
@@ -289,13 +294,153 @@ impl SlideApp {
|
||||
r
|
||||
}
|
||||
|
||||
fn docs_key(&self) -> (usize, Option<usize>) {
|
||||
pub(crate) fn docs_key(&self) -> (usize, Option<usize>) {
|
||||
(self.session.documents().len(), self.session.active_index())
|
||||
}
|
||||
|
||||
/// Home is open over a presentation (not just the start screen with nothing open).
|
||||
pub fn home_open(&self) -> bool {
|
||||
self.home.is_some() && self.session.active().is_some()
|
||||
/// The File tab's view is open over a presentation (not just the start screen with nothing
|
||||
/// open).
|
||||
pub fn backstage_open(&self) -> bool {
|
||||
self.backstage.is_some() && self.session.active().is_some()
|
||||
}
|
||||
|
||||
/// Opens the File tab's view on `page` (keeping its state when it is already open).
|
||||
pub fn open_backstage(&mut self, page: backstage::Page) {
|
||||
let key = self.docs_key();
|
||||
match &mut self.backstage {
|
||||
Some(b) => b.go(page),
|
||||
None => self.backstage = Some(backstage::Backstage::new(page, key)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Back to the presentation (no effect on the start screen, where nothing is open).
|
||||
pub fn close_backstage(&mut self) {
|
||||
if self.session.active().is_some() {
|
||||
self.backstage = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the File tab's view in step with the open presentations: it closes when one is
|
||||
/// opened, created or closed (the result shows), and it is the start screen when none is open.
|
||||
fn sync_backstage(&mut self) {
|
||||
let key = self.docs_key();
|
||||
let has_doc = self.session.active().is_some();
|
||||
let stale = self.backstage.as_ref().is_some_and(|b| b.docs != key);
|
||||
if has_doc && stale {
|
||||
self.backstage = None;
|
||||
} else if !has_doc && (stale || self.backstage.is_none()) {
|
||||
self.backstage = Some(backstage::Backstage::new(backstage::Page::Home, key));
|
||||
}
|
||||
}
|
||||
|
||||
/// File › Close and Ctrl+W: closes the active presentation, asking first when it has unsaved
|
||||
/// changes (Save, Don't Save, Cancel), as PowerPoint does.
|
||||
pub fn close_presentation(&mut self) {
|
||||
match self.session.active() {
|
||||
None => {}
|
||||
Some(d) if d.is_dirty() => self.dialog = Some(dialogs::Dialog::new("close")),
|
||||
Some(_) => {
|
||||
let _ = self.run("file.close", json!({}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The file name a save or export suggests: the presentation's title with `ext`.
|
||||
fn suggested_name(&self, ext: &str) -> String {
|
||||
let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into());
|
||||
format!("{name}.{ext}")
|
||||
}
|
||||
|
||||
/// Exports the active presentation as `fmt` (`pdf`, `png`, `pngAll`, `jpeg`, `pptx`,
|
||||
/// `deckcraft`, `outline`) through `file.export`: the save picker on the desktop, a download
|
||||
/// on the web. `extra` adds `file.export` params (the PDF's layout, slides…). The
|
||||
/// presentation keeps its own file. Returns whether a file was written or handed over.
|
||||
pub fn export_as(&mut self, fmt: &str, extra: Value) -> bool {
|
||||
let Some(slide) = self.session.active().map(|d| d.selection.slide) else { return false };
|
||||
let (ext, format, all) = match fmt {
|
||||
"pdf" => ("pdf", "pdf", false),
|
||||
"png" => ("png", "png", false),
|
||||
"pngAll" => ("png", "png", true),
|
||||
"jpeg" | "jpg" => ("jpg", "jpeg", false),
|
||||
"pptx" => ("pptx", "pptx", false),
|
||||
"outline" | "txt" => ("txt", "outline", false),
|
||||
"deckcraft" => ("deckcraft", "deckcraft", false),
|
||||
other => {
|
||||
self.set_status(format!("DeckCraft can't export “{other}”"));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let suggested = self.suggested_name(ext);
|
||||
if let Some(pick) = self.services.pick_save.as_mut() {
|
||||
let Some(path) = pick(&suggested) else { return false };
|
||||
let mut params = json!({"path": path, "format": format, "all": all});
|
||||
if let (Some(o), Some(x)) = (params.as_object_mut(), extra.as_object()) {
|
||||
o.extend(x.clone());
|
||||
}
|
||||
return match self.run("file.export", params) {
|
||||
Ok(_) => {
|
||||
self.set_status(format!("Exported {path}"));
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
};
|
||||
}
|
||||
let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return false };
|
||||
let bytes = match format {
|
||||
"png" | "jpeg" => {
|
||||
let img = deckcraft_render::render_slide(&doc, slide, &deckcraft_render::RenderOpts { scale: 2.0, ..Default::default() });
|
||||
Ok(if format == "png" { img.to_png() } else { img.to_jpeg(92) })
|
||||
}
|
||||
"pdf" => deckcraft_engine::cmd::file::pdf_bytes(&doc, &extra),
|
||||
other => deckcraft_engine::cmd::file::save_bytes(&doc, other),
|
||||
};
|
||||
self.download(&suggested, bytes)
|
||||
}
|
||||
|
||||
/// Hands `bytes` to the browser as a download (web); false (with the reason in the status
|
||||
/// bar) when there are no bytes or no way to download.
|
||||
fn download(&mut self, name: &str, bytes: deckcraft_engine::Result<Vec<u8>>) -> bool {
|
||||
match (bytes, self.services.download.as_mut()) {
|
||||
(Ok(b), Some(dl)) => {
|
||||
dl(name, &b);
|
||||
true
|
||||
}
|
||||
(Err(e), _) => {
|
||||
self.set_status(e.to_string());
|
||||
false
|
||||
}
|
||||
(Ok(_), None) => {
|
||||
self.set_status("Saving a copy needs a file picker, which this window doesn't have.");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// File › Print: DeckCraft prints through a PDF. On the desktop the PDF (with the Print page's
|
||||
/// layout, slides and options) is written to the temporary folder and opened in the system's
|
||||
/// PDF viewer, which has the printers; on the web it downloads.
|
||||
pub fn print_pdf(&mut self, params: Value) -> bool {
|
||||
let Some((title, doc)) = self.session.active().map(|d| (d.title(), d.doc.clone())) else { return false };
|
||||
let mut params = if params.is_object() { params } else { json!({}) };
|
||||
if self.services.pick_save.is_none() {
|
||||
// Web: the browser hands the PDF to the user.
|
||||
let bytes = deckcraft_engine::cmd::file::pdf_bytes(&doc, ¶ms);
|
||||
return self.download(&format!("{title}.pdf"), bytes);
|
||||
}
|
||||
let safe: String = title.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect();
|
||||
let path = std::env::temp_dir().join(format!("{safe} (print).pdf")).to_string_lossy().to_string();
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.insert("path".into(), json!(path));
|
||||
o.insert("format".into(), json!("pdf"));
|
||||
}
|
||||
match self.run("file.export", params) {
|
||||
Ok(_) => {
|
||||
self.open_url(&format!("file:///{}", path.replace('\\', "/").trim_start_matches('/')));
|
||||
self.set_status("Opened the presentation as a PDF to print");
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn flag(&mut self, params: &Value, cur: bool) -> bool {
|
||||
@@ -358,8 +503,34 @@ impl SlideApp {
|
||||
self.media.stop_owned(media::Owner::Show);
|
||||
}
|
||||
"app.home" => {
|
||||
let on = self.flag(p, self.home.is_some());
|
||||
self.home = (on && self.session.active().is_some()).then(|| self.docs_key());
|
||||
let on = self.flag(p, self.backstage_open());
|
||||
if on {
|
||||
self.open_backstage(backstage::Page::Home);
|
||||
} else {
|
||||
self.close_backstage();
|
||||
}
|
||||
}
|
||||
"app.backstage" => {
|
||||
if !p.get("on").and_then(Value::as_bool).unwrap_or(true) {
|
||||
self.close_backstage();
|
||||
return Ok(json!({"page": self.backstage.as_ref().map(|b| b.page.id())}));
|
||||
}
|
||||
let page = match p.get("page").and_then(Value::as_str) {
|
||||
Some(id) => backstage::Page::parse(id).ok_or_else(|| format!("unknown File page `{id}`"))?,
|
||||
None => backstage::Page::Home,
|
||||
};
|
||||
if page.needs_doc() && self.session.active().is_none() {
|
||||
return Err(format!("File › {} needs an open presentation", page.label()));
|
||||
}
|
||||
self.open_backstage(page);
|
||||
return Ok(json!({"page": page.id()}));
|
||||
}
|
||||
"app.closePresentation" => self.close_presentation(),
|
||||
"app.print" => {
|
||||
if self.session.active().is_none() {
|
||||
return Err("nothing to print: no presentation is open".into());
|
||||
}
|
||||
return Ok(json!({"printed": self.print_pdf(p.clone())}));
|
||||
}
|
||||
"app.about" => self.dialog = Some(dialogs::Dialog::new("about")),
|
||||
"app.palette" => self.palette = Some((String::new(), 0)),
|
||||
@@ -678,16 +849,22 @@ impl SlideApp {
|
||||
return;
|
||||
}
|
||||
let events = ctx.input(|i| i.events.clone());
|
||||
if self.home_open() {
|
||||
// Only app shortcuts (Open, Command Palette…) and Escape, which goes back to the
|
||||
// presentation: typing, pasting or deleting would edit the hidden presentation.
|
||||
if self.backstage.is_some() {
|
||||
// The File tab: its own keys (Esc goes back, arrows walk the side menu) and app
|
||||
// shortcuts (New, Open, Command Palette…); typing, pasting or deleting would edit the
|
||||
// hidden presentation.
|
||||
for e in events {
|
||||
if let egui::Event::Key { key, pressed: true, modifiers, .. } = e {
|
||||
let mods = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command || modifiers.ctrl };
|
||||
if key == egui::Key::Escape {
|
||||
self.home = None;
|
||||
} else if mods.cmd || mods.alt {
|
||||
menus::ui_shortcut(self, key, mods);
|
||||
if backstage::key(self, key, mods) || !(mods.cmd || mods.alt) || menus::ui_shortcut(self, key, mods) {
|
||||
continue;
|
||||
}
|
||||
// Engine shortcuts that don't touch the hidden slide (Ctrl+N).
|
||||
if mods.cmd
|
||||
&& matches!(key, egui::Key::N)
|
||||
&& let Err(e) = self.session.key(key_name(key), mods)
|
||||
{
|
||||
self.set_status(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -763,18 +940,21 @@ impl SlideApp {
|
||||
self.perf.frame_ms = now_ms() - t0;
|
||||
return;
|
||||
}
|
||||
if self.home.is_some_and(|k| k != self.docs_key()) {
|
||||
self.home = None;
|
||||
}
|
||||
self.sync_backstage();
|
||||
self.keyboard(&ctx);
|
||||
self.sync_backstage();
|
||||
ribbon::title_bar(self, ui);
|
||||
if self.session.active().is_some() && !self.home_open() {
|
||||
if self.backstage.is_none() && self.session.active().is_some() {
|
||||
ribbon::show(self, ui);
|
||||
status::status_bar(self, ui);
|
||||
}
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {
|
||||
if self.session.active().is_none() || self.home_open() {
|
||||
dialogs::start_screen(self, ui);
|
||||
if self.backstage.is_some() {
|
||||
// File tab: the Backstage view replaces everything below the title bar.
|
||||
backstage::show(self, ui);
|
||||
return;
|
||||
}
|
||||
if self.session.active().is_none() {
|
||||
return;
|
||||
}
|
||||
match self.ui.view {
|
||||
@@ -1010,7 +1190,7 @@ mod tests {
|
||||
|
||||
harness.state_mut().run("app.home", json!({})).unwrap();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().home_open());
|
||||
assert!(harness.state().backstage_open());
|
||||
assert_eq!(harness.state().session.documents().len(), 1, "Home doesn't create a presentation");
|
||||
|
||||
// Typing on the start screen doesn't reach the presentation behind it; Escape goes back.
|
||||
@@ -1021,18 +1201,18 @@ mod tests {
|
||||
assert!(!harness.state().session.active().unwrap().is_dirty());
|
||||
harness.key_press(egui::Key::Escape);
|
||||
harness.run_steps(2);
|
||||
assert!(!harness.state().home_open());
|
||||
assert!(!harness.state().backstage_open());
|
||||
|
||||
// Home again toggles; picking a theme on the start screen (a new presentation) leaves it.
|
||||
harness.state_mut().run("app.home", json!({})).unwrap();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().home_open());
|
||||
assert!(harness.state().backstage_open());
|
||||
harness.state_mut().run("app.home", json!({})).unwrap();
|
||||
assert!(!harness.state().home_open());
|
||||
assert!(!harness.state().backstage_open());
|
||||
harness.state_mut().run("app.home", json!({"on": true})).unwrap();
|
||||
harness.state_mut().run("file.new", json!({})).unwrap();
|
||||
harness.run_steps(2);
|
||||
assert!(!harness.state().home_open(), "a new presentation leaves Home");
|
||||
assert!(!harness.state().backstage_open(), "a new presentation leaves Home");
|
||||
assert_eq!(harness.state().session.documents().len(), 2);
|
||||
}
|
||||
|
||||
@@ -1123,7 +1303,7 @@ mod tests {
|
||||
fn home_needs_an_open_presentation() {
|
||||
let mut app = SlideApp::new(Session::new(), Services::default());
|
||||
app.run("app.home", json!({})).unwrap();
|
||||
assert!(!app.home_open() && app.home.is_none(), "with nothing open the start screen shows anyway");
|
||||
assert!(!app.backstage_open(), "with nothing open the start screen shows anyway");
|
||||
}
|
||||
fn canvas_harness() -> egui_kittest::Harness<'static, SlideApp> {
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|
||||
@@ -29,6 +29,25 @@ pub fn ui_shortcut(app: &mut SlideApp, key: egui::Key, m: Mods) -> bool {
|
||||
return true;
|
||||
}
|
||||
(S, true, true, false) => "app.saveAsDialog",
|
||||
// File tab: Alt+F opens it on Home with the keyboard in its side menu; Ctrl+P on Print.
|
||||
(F, false, false, true) => {
|
||||
app.open_backstage(crate::backstage::Page::Home);
|
||||
if let Some(b) = app.backstage.as_mut() {
|
||||
b.focus = Some(0);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
(P, true, false, false) => {
|
||||
if app.session.active().is_none() {
|
||||
return false;
|
||||
}
|
||||
app.open_backstage(crate::backstage::Page::Print);
|
||||
return true;
|
||||
}
|
||||
(W, true, false, false) => {
|
||||
app.close_presentation();
|
||||
return true;
|
||||
}
|
||||
(O, true, false, false) => "app.openDialog",
|
||||
(P, true, true, false) => "app.palette",
|
||||
(Comma, true, false, false) => "app.preferences",
|
||||
@@ -133,10 +152,11 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
|
||||
("New Presentation", "file.new"),
|
||||
("Open…", "app.openDialog"),
|
||||
("-", ""),
|
||||
("Close", "file.close"),
|
||||
("Close", "app.closePresentation"),
|
||||
("Save", "file.save"),
|
||||
("Save As…", "app.saveAsDialog"),
|
||||
("Export…", "app.exportDialog"),
|
||||
("Print…", "app.print"),
|
||||
("-", ""),
|
||||
("Properties", "file.properties"),
|
||||
],
|
||||
|
||||
@@ -47,7 +47,7 @@ pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) {
|
||||
child.spacing_mut().item_spacing.x = 2.0;
|
||||
let has_doc = app.session.active().is_some();
|
||||
let dirty = app.session.active().is_some_and(|d| d.is_dirty());
|
||||
if icon_toggle(&mut child, Icon::Home, "Home", true, app.home_open()).clicked() {
|
||||
if icon_toggle(&mut child, Icon::Home, "Home", true, app.backstage_open()).clicked() {
|
||||
let _ = app.run("app.home", json!({}));
|
||||
}
|
||||
if icon_toggle(&mut child, Icon::Save, "Save (⌘S)", has_doc, false).clicked() {
|
||||
@@ -143,9 +143,24 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let h = 28.0 + if app.ui.ribbon_collapsed { 4.0 } else { RIBBON_H + 20.0 };
|
||||
egui::Panel::top("ribbon").exact_size(h).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {
|
||||
let rect = ui.max_rect();
|
||||
// Tabs.
|
||||
// Tabs. File comes first and opens the File tab's full-window view (Backstage), as in
|
||||
// PowerPoint.
|
||||
let mut x = rect.min.x + 12.0;
|
||||
let y = rect.min.y + 2.0;
|
||||
{
|
||||
let w = ui.fonts_mut(|f| f.layout_no_wrap("File".to_string(), theme::font(13.0), t.text).size().x) + 18.0;
|
||||
let r = Rect::from_min_size(pos2(x, y), vec2(w, 24.0));
|
||||
let resp = ui.interact(r, ui.id().with("tab_file"), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "File"));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r.shrink2(vec2(2.0, 2.0)), CornerRadius::same(4), t.hover);
|
||||
}
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, "File", theme::font(13.0), t.text);
|
||||
if resp.on_hover_text("File (Alt+F)").clicked() {
|
||||
app.open_backstage(crate::backstage::Page::Home);
|
||||
}
|
||||
x += w + 2.0;
|
||||
}
|
||||
let all: Vec<(&str, bool)> = TABS.iter().map(|s| (*s, false)).chain(ctx_tabs.iter().map(|s| (*s, true))).collect();
|
||||
for (name, contextual) in all {
|
||||
let font = theme::font(13.0);
|
||||
|
||||
@@ -15,7 +15,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
|
||||
| `engine.execute` / `ui.menu.invoke` / `command` | `{command, params}` | Run any command (see `engine.commands`; `deckcraft-cli describe ID` documents one) |
|
||||
| `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled / disabled reason |
|
||||
| `document.inspect` | — | Slides, sections, layouts, shapes, selection |
|
||||
| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, Home (start screen over the open presentations), slide show state, documents, perf (fps, render ms) |
|
||||
| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state, documents, perf (fps, render ms) |
|
||||
| `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` |
|
||||
| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse |
|
||||
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show) |
|
||||
|
||||
@@ -63,7 +63,7 @@ the round trip even though DeckCraft doesn't model it.
|
||||
| Themes: colour, font (Latin/EA/CS), effect schemes, background styles | yes | yes | Unknown theme parts kept |
|
||||
| Notes slides and notes master | yes | yes | No notes-master editing view |
|
||||
| Handout master | no | no | |
|
||||
| Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`). Outlines, dashes, arrowheads and ink inside a group keep their width in points whatever the group's child-coordinate scale (#233); text in a group with a tiny child space is not drawn yet. |
|
||||
| Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`). Outlines, dashes, arrowheads and ink inside a group, and text, keep their size in points whatever the group's child-coordinate scale (#233). |
|
||||
| Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | |
|
||||
| Lines: dash, compound, caps, joins, arrowheads | yes | yes | |
|
||||
| Effects: shadow, glow, soft edges, reflection | yes | yes | |
|
||||
|
||||
+2
-2
@@ -197,8 +197,8 @@ Detail in [ui-parity.md](ui-parity.md).
|
||||
blocker.**
|
||||
- **Format Shape pane thinner than PowerPoint's** (Fill/Line/Effects/Size/Text Box pages, picture
|
||||
and texture options, 3-D). 6–10 h. **Beta blocker.**
|
||||
- **First-run and start screen** (#17, #76: missing File overlay, broken layout; recent files and
|
||||
templates thin). 3–5 h.
|
||||
- **File tab depth** (#76 landed the Backstage): no online templates, cloud places, printer
|
||||
choice, video/GIF/CD exports or document protection. 4–8 h for the parts that need no cloud.
|
||||
- **Window chrome** (#71 macOS controls misaligned, #26 GNOME title bar, #41 system light/dark).
|
||||
2–4 h.
|
||||
- **Icon consistency across Craft apps** (#73, #56). 2–4 h; coordinate in craftrules.
|
||||
|
||||
@@ -7,13 +7,13 @@ The summary between `<!-- SUMMARY -->
|
||||
|
||||
| Priority | Features | Done | Partial | Missing | Parity |
|
||||
|---|---|---|---|---|---|
|
||||
| P0 | 72 | 60 | 12 | 0 | 92% |
|
||||
| P0 | 72 | 61 | 11 | 0 | 92% |
|
||||
| P1 | 65 | 38 | 21 | 6 | 75% |
|
||||
| P2 | 54 | 13 | 14 | 27 | 37% |
|
||||
|
||||
| Area | Parity |
|
||||
|---|---|
|
||||
| Application shell | 71% |
|
||||
| Application shell | 77% |
|
||||
| Files | 71% |
|
||||
| Slides | 90% |
|
||||
| Views | 90% |
|
||||
@@ -36,13 +36,13 @@ The summary between `<!-- SUMMARY -->
|
||||
| Automation (✱) | 100% |
|
||||
| Platforms (✱) | 67% |
|
||||
|
||||
Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design), Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending), Format Shape pane, Font, size, bold, italic, underline, strikethrough (only Regular/Bold/Italic faces, #69), Edit chart data, Crop (and crop to shape, aspect ratio), Paste image from clipboard (fails on Windows and Linux, #53), Audio/video playback in the show (WMA/WMV not decodable yet), Animation pane (reorder, timing), Presenter view (notes, next slide, timer), macOS, Windows, Linux builds.
|
||||
Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design), Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending), Format Shape pane, Font, size, bold, italic, underline, strikethrough (only Regular/Bold/Italic faces, #69), Edit chart data, Crop (and crop to shape, aspect ratio), Paste image from clipboard (fails on Windows and Linux, #53), Audio/video playback in the show (WMA/WMV not decodable yet), Animation pane (reorder, timing), Presenter view (notes, next slide, timer), macOS, Windows, Linux builds.
|
||||
|
||||
## Rows
|
||||
|
||||
| Area | Feature | Priority | Status |
|
||||
|---|---|---|---|
|
||||
| Application shell | Start screen (new, templates/themes, recent) | P0 | P |
|
||||
| Application shell | Start screen (new, templates/themes, recent) | P0 | D |
|
||||
| Application shell | Ribbon with Home/Insert/Draw/Design/Transitions/Animations/Slide Show/Review/View tabs | P0 | D |
|
||||
| Application shell | Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design) | P0 | P |
|
||||
| Application shell | Quick Access toolbar / title bar commands | P1 | P |
|
||||
|
||||
+33
-3
@@ -1,6 +1,6 @@
|
||||
# UI parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
@@ -18,13 +18,42 @@ AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to pa
|
||||
| Menu-bar items with a DeckCraft equivalent | 217 app-specific items | 128 matched by name (59%, heuristic lower bound) | See [target-app-parity.md](target-app-parity.md) |
|
||||
| Ribbon tabs: Home, Insert, Draw, Design, Transitions, Animations, Slide Show, Record, Review, View | 10 | 10 | Record tab is a placeholder |
|
||||
| Contextual tabs: Shape Format, Picture Format, Table Design, Table Layout, Chart Design, Chart Format, Video/Audio Format and Playback | yes | most; checklist P0 row partial | Chart Format, media tabs thinner |
|
||||
| Start screen: new, themes, templates, recent, open | yes | yes, thin (#17 broken layout, #76 missing overlay) | 3–5 h |
|
||||
| File tab (Backstage): Home, New, Open, Info, Save, Save As, Print, Share, Export, Close, Account, Options | yes | yes (#76), see [File tab](#file-tab-backstage); no Share (cloud) | Video/GIF/CD exports, templates beyond the built-in themes |
|
||||
| Start screen: new, themes, templates, recent, open | yes, the same page as File › Home | yes: File › Home with Home, New, Open, Account and Options (#76) | |
|
||||
| Status bar: slide counter, language, notes, comments, views, zoom slider | yes | yes | |
|
||||
| Command search (Tell Me) | yes | command palette | #79 default entry fails |
|
||||
| Light / dark appearance | follows the system | light or dark; doesn't follow the system (#41) | 1–2 h |
|
||||
| One window per presentation, New Window, Arrange All | yes | tabs in one window; no New Window | 3–5 h |
|
||||
| Quick Access Toolbar customization | yes | no | P1 partial |
|
||||
|
||||
## File tab (Backstage)
|
||||
|
||||
Observed black-box in PowerPoint for Windows (Microsoft 365, Version 2609, October 2026) on a
|
||||
synthetic presentation. Clicking **File** (or Alt+F) replaces the ribbon, slides and status bar with
|
||||
a full-window view; the title bar stays. A side menu 160 points wide, a step darker than the page,
|
||||
lists Back (a circled arrow), Home, New, Open, Share, a rule, Info, Save, Save As, Print, Export,
|
||||
Close and, at the bottom under a rule, Account and Options. Entries are 40 points tall with an
|
||||
outline icon; the current page is drawn in the accent colour and outlined. Esc goes back. Alt+F opens
|
||||
on Home with the keyboard in the side menu; Up and Down move through it and a page shows as soon as
|
||||
its entry has the focus. PowerPoint's start screen is the same Home page, with only Home, New and
|
||||
Open at the top. DeckCraft follows all of this (`crates/ui-egui/src/backstage.rs`), with the page and
|
||||
menu colours taken from the theme tokens (menu `chrome`, page `card`).
|
||||
|
||||
| Page | PowerPoint | DeckCraft |
|
||||
|---|---|---|
|
||||
| Home | Prompt box (Copilot), New row of themes with More themes, Recent / Favorites / Shared with Me with a file search, More presentations | DeckCraft's mark, Blank Presentation and the first themes with More themes →, Recent with a file search and More presentations → (to Open). No Copilot prompt, Favorites or Shared with Me |
|
||||
| New | Blank Presentation, template search with suggested searches, gallery of online templates and themes | Blank Presentation, search box, the built-in themes and the sample deck (`file.new {theme}`). No online templates |
|
||||
| Open | Recent, Shared with Me, OneDrive, This PC, Add a Place, Browse; Presentations / Folders; Recover Unsaved Presentations | Recent and Browse (`app.openDialog`), recent presentations with a search box (`file.open`), Recover Unsaved Presentations (`file.recovery.open`). No cloud places or Folders tab |
|
||||
| Info | Name and path, Upload / Share / Copy path / Open file location; Protect, Inspect, Version History, Manage Presentation; Properties, Related Dates, Related People | Name and path, Copy path, Open file location; Inspect Presentation (Accessibility Checker), Manage Presentation (Revert); Size, Slides, Hidden slides and editable Title, Subject, Tags, Categories, Company, Comments, Author (`file.info`, `file.properties`). No Protect, Version History or dates |
|
||||
| Save | Saves; a presentation never saved goes to Save As | Same (`file.save`) |
|
||||
| Save As | Places, folder, file name, type, Save, More options | Type (PowerPoint, DeckCraft, PDF, Outline, PNG, JPEG) and Save, which opens the system Save dialog (web: Download). .pptx/.deckcraft take the new file; the others are copies (`file.saveAs`, `file.export`) |
|
||||
| Print | Print and Copies, Printer, Print All / Selection / Current / Custom Range, Slides box, layout (Full Page Slides, Notes Pages, Outline, Handouts 1–9), sides, collation, colour, Edit Header & Footer; page preview with pager and zoom | Print, Print All Slides / Current Slide / Custom Range, Slides box (`1-3, 5`), Full Page Slides / Notes Pages / Handouts 1–9, Print Hidden Slides, Frame Slides, Edit Header & Footer; preview with pager. DeckCraft prints through a PDF (`file.export`) opened in the system viewer, which has the printers (web: downloaded). No printer list, copies, sides or colour |
|
||||
| Share | Cloud sharing | Not offered |
|
||||
| Export | Create PDF/XPS Document, Create a Video, Create an Animated GIF, Package Presentation for CD, Create Handouts, Change File Type | Create PDF Document, Create Handouts (a PDF), Change File Type. No video, GIF or CD package |
|
||||
| Close | Closes; asks Save / Don't Save / Cancel about unsaved changes | Same (`file.close` after the prompt); Ctrl+W asks too |
|
||||
| Account | User information, sign-in, Office Background and Office Theme, product information, About | Appearance (light/dark, `view.dark`), product information, About DeckCraft. No accounts to sign in to |
|
||||
| Options | Opens the PowerPoint Options dialog | Opens Preferences |
|
||||
|
||||
## Panes and dialogs
|
||||
|
||||
| Pane / dialog | PowerPoint | DeckCraft |
|
||||
@@ -109,7 +138,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
|
||||
| Native macOS menu bar from `menu_tree()`, filled out to PowerPoint's menu items | 3–5 |
|
||||
| Format Shape pane depth | 6–10 |
|
||||
| Missing dialogs (Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 8–14 |
|
||||
| Start screen, window chrome, system appearance, palette fixes (#17, #41, #71, #76, #79) | 4–7 |
|
||||
| Window chrome, system appearance, palette fixes (#41, #71, #79) | 3–5 |
|
||||
| Grid Options, edit points UI, crop depth | 4–7 |
|
||||
| Multiple windows | 3–5 |
|
||||
| A screenshot comparison pass on the most-used tabs | 2–4 |
|
||||
@@ -119,6 +148,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home |
|
||||
| 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) |
|
||||
| 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock |
|
||||
| 2026-10-12 | minor | Text editing keys: Ctrl+arrows, Ctrl+Backspace/Delete, Ctrl+Up/Down, Home/End on wrapped lines, Shift+F3, font size keys, as observed in PowerPoint for Windows (#268) |
|
||||
|
||||
Reference in New Issue
Block a user