From 121882e1776539d7a6ba07d7025ac0e92935cddf Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 18 Oct 2026 01:20:12 -0400 Subject: [PATCH 1/4] docs: drop references to competing commercial software --- README.md | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 82903b44..a8634c52 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,12 @@

DeckCraft

- Presentations and slide shows; an open-source, clean-room reimplementation of Microsoft PowerPoint, rebuilt in pure Rust. + Presentations and slide shows; an open-source, modern presentation and slide designer, built in pure Rust.

Build decks with themes, layouts, shapes, pictures, tables, charts, transitions and animations, - open and save PowerPoint files, and present them full screen. Native on macOS, Windows, Linux + open and save .pptx presentation files, and present them full screen. Native on macOS, Windows, Linux and FreeBSD, and in the browser. Every button is also a command that the CLI and AI agents (through MCP) can drive.

@@ -74,7 +74,7 @@ Today it can: audio and video (MP3, AAC, FLAC, ALAC, Ogg, Opus, WAV, AIFF; H.264, HEVC, VP9, AV1). - **Present**: transitions (including Morph), entrance/emphasis/exit and motion-path animations, by-paragraph builds, full-screen slide show, presenter view, pen, rehearse timings, media playback. -- **Exchange files**: open and save PowerPoint `.pptx`, export PDF (slides, notes pages, handouts +- **Exchange files**: open and save .pptx presentations, export PDF (slides, notes pages, handouts with selectable text), PNG/JPEG, an animated GIF of the deck and outlines. Unsaved work is kept by AutoRecover. - **Automate**: 200+ commands with undo, all scriptable from the CLI, a JSON control channel and an MCP server for AI agents. @@ -132,7 +132,7 @@ settings directory, beside `ui.json` and `prefs.json` (Linux `~/.config/deckcraf `$XDG_CONFIG_HOME/deckcraft/logs/`; macOS `~/Library/Application Support/DeckCraft/logs/`; Windows `%APPDATA%\DeckCraft\logs\`). A start from a desktop menu or the Dock has no terminal, so this file is what to attach to a bug report: the crash guard's panic report, a failed crash-recovery save, -PowerPoint parts skipped on import and audio output problems land there. Each launch moves the +Parts skipped on import and audio output problems land there. Each launch moves the previous log to `deckcraft.1.log` (and that one to `deckcraft.2.log`), so the log of a run that crashed survives the next start. The file stops growing at 16 MiB. `--version` writes no file. @@ -158,8 +158,8 @@ deckcraft-cli mcp --automation-read-root DIR --automation-write-root DIR # con ## Roadmap -DeckCraft is in **alpha**. It covers **84% of PowerPoint's features by breadth** (92% of the core -ones, measured) and is about **55% of the way to replacing PowerPoint for real work** (estimated). +DeckCraft is in **alpha**. It covers **84% of standard presentation features by breadth** (92% of the core +ones, measured) and is about **55% of the way to full professional readiness** (estimated). See [ROADMAP.md](ROADMAP.md) for the stage, numbers by dimension and what's next, [docs/target-app-parity.md](docs/target-app-parity.md) for the full assessment, [docs/gaps.md](docs/gaps.md) for every known shortfall and @@ -279,15 +279,13 @@ Bundled fonts, icons, images and other assets keep their own open licenses; each with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md). DeckCraft's themes, layouts, shape geometry, icons and sample decks are original work, drawn or -generated in code; no Microsoft artwork, fonts, themes or templates are used. +generated in code; no proprietary artwork, fonts, themes or templates are used. The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as part of this repository and DeckCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and modified versions must remove them. -Microsoft and PowerPoint are trademarks of the Microsoft group of companies. Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. DeckCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Microsoft Corporation or Adobe Inc.; these names are used only to describe the workflows it is compatible with. -

ArtCraft
Made by the ArtCraft team and community. From 19fadde1cce975faa353ffe4747b30f764211982 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 18 Oct 2026 10:47:26 -0400 Subject: [PATCH 2/4] docs: clarify that feature percentage is measured against tracked features in docs/target-app-parity.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a8634c52..eeaf1cf6 100644 --- a/README.md +++ b/README.md @@ -158,8 +158,8 @@ deckcraft-cli mcp --automation-read-root DIR --automation-write-root DIR # con ## Roadmap -DeckCraft is in **alpha**. It covers **84% of standard presentation features by breadth** (92% of the core -ones, measured) and is about **55% of the way to full professional readiness** (estimated). +DeckCraft is in **alpha**. It covers **84% of the features tracked in [`docs/target-app-parity.md`](docs/target-app-parity.md) by breadth** (92% of the core +ones, measured) and is about **55% ready for real work across all tracked capabilities** (estimated). See [ROADMAP.md](ROADMAP.md) for the stage, numbers by dimension and what's next, [docs/target-app-parity.md](docs/target-app-parity.md) for the full assessment, [docs/gaps.md](docs/gaps.md) for every known shortfall and From 010941ec60f81f1eece31ec1ea83825c9a396217 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 18 Oct 2026 14:31:16 -0400 Subject: [PATCH 3/4] ci: configure Dependabot for Cargo and GitHub Actions Co-authored-by: Dylan M. Taylor --- .github/dependabot.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..2c4b8cb1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +version: 2 +updates: + # Maintain Rust / Cargo dependencies + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + # Consolidate updates into a single PR rather than one PR per crate + groups: + cargo-dependencies: + patterns: + - "*" + commit-message: + prefix: "chore(deps)" + + # Maintain GitHub Actions workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + groups: + actions: + patterns: + - "*" + commit-message: + prefix: "ci(deps)" From f34f4121c8ee720ca08e5d47b912155a33504e69 Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:44:16 +0200 Subject: [PATCH 4/4] Format: retain imported chart parts through native saves (#851) * M2.1: preserve chart parts through native saves Serialize retained chart relationships and payloads in native files. Add native and headless file-command round-trip regressions. Contribution on behalf of @MMexicann. * Format: bound opaque chart parts as native ZIP entries Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. * Model: track transient native chart part entries Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. * PPTX: initialize native chart entry metadata Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. * Engine: adapt chart native roundtrip fixtures Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. * Docs: describe bounded chart sidecars and validation Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. * Roadmap: record bounded chart persistence evidence Address maintainer feedback on the existing PR. Preserve upstream work and document tested scope and validation limits. --------- Co-authored-by: skylar taylor-barrick --- ROADMAP.md | 6 +- crates/engine/tests/chart_native.rs | 129 ++ crates/format/src/chart_parts.rs | 112 + crates/format/src/chart_parts_tests.rs | 234 ++ crates/format/src/lib.rs | 86 +- crates/model/src/chart.rs | 2896 ++++++++++++------------ crates/pptx/src/read/chart.rs | 9 +- docs/architecture.md | 9 +- docs/file-format-parity.md | 20 +- docs/gaps.md | 8 +- docs/parity-checklist.md | 2 +- docs/roadmap.md | 4 +- 12 files changed, 2049 insertions(+), 1466 deletions(-) create mode 100644 crates/engine/tests/chart_native.rs create mode 100644 crates/format/src/chart_parts.rs create mode 100644 crates/format/src/chart_parts_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 933bc7d1..93bc931e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -131,10 +131,12 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members | | 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort | | 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape | | 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) | | 2026-10-17 | While a placeholder's text is being edited, a click on a shape in front of it selects that shape instead of moving the caret, as in PowerPoint (#931) | +| 2026-10-15 | Native saves keep an imported chart's embedded workbook, drawings and relationships; headless PPTX → edit → native save/reopen → PPTX tests verify the payloads and stale-workbook rejection | | 2026-10-15 | PPTX trigger sequences keep separate On Click steps, including paragraph builds; synthetic import/edit/save/reopen and headless show regressions | | 2026-10-16 | Export: PNG with a transparent background — the slide background is left out, only the slide's content is drawn (Export… dialog, File › Export, web download, `file.export`/`file.render` `transparentBackground`, `deckcraft-cli render --transparent`) (#784) | | 2026-10-16 | Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns to the slide and several to each other until the other choice is ticked; the choice holds for that selection and also drives Distribute | @@ -237,6 +239,8 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | +| 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged | | 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged | | 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) | | 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) | diff --git a/crates/engine/tests/chart_native.rs b/crates/engine/tests/chart_native.rs new file mode 100644 index 00000000..957be0ad --- /dev/null +++ b/crates/engine/tests/chart_native.rs @@ -0,0 +1,129 @@ +//! Native saves must not discard an imported chart's opaque package parts. + +use std::io::{Cursor, Read}; +use std::sync::Arc; + +use deckcraft_engine::Session; +use deckcraft_engine::cmd::{base64_decode, base64_encode}; +use deckcraft_geom::Xfrm; +use deckcraft_model::chart::{Chart, ChartKept, ChartPart, ChartRel, ChartType}; +use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind, defaults}; +use serde_json::json; + +const WORKBOOK: &[u8] = b"original-synthetic-workbook\0\xff\x80"; +const DRAWING: &[u8] = br#""#; +const RT: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + +fn chart(p: &Presentation) -> &Chart { + let ShapeKind::Chart(c) = &p.slides[0].shapes[0].kind else { panic!("chart") }; + c +} + +fn source() -> Vec { + let mut p = defaults::new_presentation(None); + let id = ShapeId(p.alloc_id().expect("id")); + Arc::make_mut(&mut p.slides[0]).shapes = vec![Shape { + id, + name: "Owned chart".into(), + xfrm: Some(Xfrm::new(40.0, 40.0, 400.0, 300.0)), + kind: ShapeKind::Chart(Box::new(Chart::sample(ChartType::Pie))), + ..Default::default() + }]; + // Start with our own chart XML, then give it two original, synthetic package parts. + let mut p = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("import"); + let ShapeKind::Chart(c) = &mut Arc::make_mut(&mut p.slides[0]).shapes[0].kind else { panic!("chart") }; + c.raw = c + .raw + .as_ref() + .map(|raw| raw.replace("", r#""#)); + c.kept = ChartKept { + rels: vec![ + ChartRel { + id: "rWorkbook".into(), + rel_type: format!("{RT}/package"), + target: "../embeddings/owned.xlsx".into(), + part: "ppt/embeddings/owned.xlsx".into(), + external: false, + }, + ChartRel { + id: "rDrawing".into(), + rel_type: format!("{RT}/chartUserShapes"), + target: "../drawings/owned.xml".into(), + part: "ppt/drawings/owned.xml".into(), + external: false, + }, + ], + parts: vec![ + ChartPart { + name: "ppt/embeddings/owned.xlsx".into(), + content_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".into(), + bytes: WORKBOOK.to_vec(), + native_entry: None, + }, + ChartPart { + name: "ppt/drawings/owned.xml".into(), + content_type: "application/vnd.openxmlformats-officedocument.drawingml.chartshapes+xml".into(), + bytes: DRAWING.to_vec(), + native_entry: None, + }, + ], + }; + deckcraft_pptx::export(&p).expect("PPTX fixture") +} + +fn open(bytes: &[u8], name: &str) -> Session { + let mut s = Session::new(); + s.execute("file.openBytes", &json!({"name":name,"data":base64_encode(bytes)})).expect("open command"); + s +} + +fn save(s: &mut Session, format: &str) -> Vec { + let out = s.execute("file.saveBytes", &json!({"format":format})).expect("save command"); + base64_decode(out["data"].as_str().expect("data")).expect("base64") +} + +#[test] +fn pptx_native_edit_save_reopen_keeps_chart_parts() { + let mut s = open(&source(), "owned.pptx"); + let kept = chart(&s.doc().expect("doc").doc).kept.clone(); + assert_eq!(kept.rels.len(), 2, "fixture relationships must have been imported"); + assert_eq!(kept.parts.len(), 2); + s.execute("file.properties", &json!({"title":"Edited title"})).expect("edit command"); + for _ in 0..2 { + s = open(&save(&mut s, "deckcraft"), "owned.deckcraft"); + let p = &s.doc().expect("doc").doc; + assert_eq!(p.props.title, "Edited title"); + assert_eq!(chart(p).kept, kept); + } + let bytes = save(&mut s, "pptx"); + let mut z = zip::ZipArchive::new(Cursor::new(&bytes)).expect("zip"); + let mut payloads = vec![]; + for i in 0..z.len() { + let mut f = z.by_index(i).expect("entry"); + let mut data = vec![]; + f.read_to_end(&mut data).expect("read"); + payloads.push((f.name().to_string(), data)); + } + assert!(payloads.iter().any(|(n, d)| n.starts_with("ppt/embeddings/") && d == WORKBOOK)); + assert!(payloads.iter().any(|(n, d)| n.starts_with("ppt/drawings/") && d == DRAWING)); + let back = deckcraft_pptx::import(&bytes).expect("reopen PPTX"); + assert_eq!(chart(&back).kept.rels.len(), 2); + for part in &kept.parts { + assert!(chart(&back).kept.parts.iter().any(|p| p.content_type == part.content_type && p.bytes == part.bytes)); + } +} + +#[test] +fn editing_chart_after_native_reopen_does_not_export_a_stale_workbook() { + let mut s = open(&source(), "owned.pptx"); + s = open(&save(&mut s, "deckcraft"), "owned.deckcraft"); + let doc = s.doc_mut().expect("doc"); + let p = Arc::make_mut(&mut doc.doc); + let ShapeKind::Chart(c) = &mut Arc::make_mut(&mut p.slides[0]).shapes[0].kind else { panic!("chart") }; + assert_eq!(c.kept.rels.len(), 2); + c.series[0].values[0] = Some(99.0); + let bytes = save(&mut s, "pptx"); + let back = deckcraft_pptx::import(&bytes).expect("PPTX"); + assert_eq!(chart(&back).series[0].values[0], Some(99.0)); + assert!(chart(&back).kept.parts.is_empty(), "stale source workbook must not be attached to edited chart data"); +} diff --git a/crates/format/src/chart_parts.rs b/crates/format/src/chart_parts.rs new file mode 100644 index 00000000..f57c0867 --- /dev/null +++ b/crates/format/src/chart_parts.rs @@ -0,0 +1,112 @@ +//! Keep opaque chart payloads out of the JSON manifest and within the media load budget. +use std::collections::HashSet; +use std::io::Cursor; +use std::sync::Arc; + +use deckcraft_model::chart::ChartPart; +use deckcraft_model::{Master, Presentation, Shape, ShapeKind}; + +use crate::{FormatError, MAX_ENTRY, Result, read_entry_max}; + +const PREFIX: &str = "chart-parts/"; +type Payload = (String, String, Vec); + +pub(super) fn separate(p: &mut Presentation) -> Result> { + let mut payloads = Vec::new(); + visit_parts(p, |part| { + // The native namespace is independent of the original OPC names, which may repeat + // when charts from several decks are combined. No user-supplied path is extracted. + let entry = format!("{PREFIX}{}", payloads.len()); + part.native_entry = Some(entry.clone()); + payloads.push((entry, part.content_type.clone(), std::mem::take(&mut part.bytes))); + Ok(()) + })?; + Ok(payloads) +} + +pub(super) fn restore( + p: &mut Presentation, + z: &mut zip::ZipArchive>, + left: &mut u64, + mut items: usize, + max_items: usize, + damaged: &mut Vec, +) -> Result<()> { + let mut seen = HashSet::new(); + visit_parts(p, |part| { + if items >= max_items { + return Err(FormatError::Damaged(format!("media and chart parts exceed {max_items} items"))); + } + items += 1; + if let Some(entry) = part.native_entry.take() { + // Accept only names produced by separate(), and inflate each member once. + let canonical = entry.strip_prefix(PREFIX).and_then(|s| s.parse::().ok()).is_some_and(|n| entry == format!("{PREFIX}{n}")); + if !canonical || !seen.insert(entry.clone()) || !part.bytes.is_empty() { + return Err(FormatError::Damaged("invalid or ambiguous chart part reference".into())); + } + if z.index_for_name(&entry).is_none() { + // Missing members consume no inflation work and can be reported for recovery. + damaged.push(format!("{entry}: missing chart part")); + return Ok(()); + } + if z.by_name(&entry).is_ok_and(|f| f.size() > *left) { + return Err(FormatError::Damaged("embedded media and chart parts are larger than DeckCraft can open".into())); + } + // A CRC/decode error may already have inflated bytes. Stop on that first error + // rather than letting later corrupt members reuse the unchanged byte budget. + let bytes = read_entry_max(z, &entry, (*left).min(MAX_ENTRY))?; + *left -= bytes.len() as u64; + part.bytes = bytes; + } else { + // Older manifests used JSON byte arrays. Charge those too, after the existing + // bounded manifest parser has read them, so resaving migrates them losslessly. + let size = part.bytes.len() as u64; + if size > (*left).min(MAX_ENTRY) { + return Err(FormatError::Damaged("inline chart part is larger than DeckCraft can open".into())); + } + *left -= size; + } + Ok(()) + }) +} + +fn visit_parts(p: &mut Presentation, mut visit: impl FnMut(&mut ChartPart) -> Result<()>) -> Result<()> { + // Use an explicit stack for groups. Charts can also live on masters and their layouts, + // including the notes and handout masters; none of these owners may leak inline bytes. + for slide in &mut p.slides { + shapes(&mut Arc::make_mut(slide).shapes, &mut visit)?; + } + for master in &mut p.masters { + master_parts(Arc::make_mut(master), &mut visit)?; + } + for master in [&mut p.notes_master, &mut p.handout_master].into_iter().flatten() { + master_parts(Arc::make_mut(master), &mut visit)?; + } + Ok(()) +} + +fn master_parts(master: &mut Master, visit: &mut impl FnMut(&mut ChartPart) -> Result<()>) -> Result<()> { + shapes(&mut master.shapes, visit)?; + for layout in &mut master.layouts { + shapes(&mut layout.shapes, visit)?; + } + Ok(()) +} + +fn shapes(root: &mut [Shape], visit: &mut impl FnMut(&mut ChartPart) -> Result<()>) -> Result<()> { + let mut pending = vec![root]; + while let Some(shapes) = pending.pop() { + for shape in shapes { + match &mut shape.kind { + ShapeKind::Group { children, .. } => pending.push(children), + ShapeKind::Chart(chart) => { + for part in &mut chart.kept.parts { + visit(part)?; + } + } + _ => {} + } + } + } + Ok(()) +} diff --git a/crates/format/src/chart_parts_tests.rs b/crates/format/src/chart_parts_tests.rs new file mode 100644 index 00000000..61a00dc9 --- /dev/null +++ b/crates/format/src/chart_parts_tests.rs @@ -0,0 +1,234 @@ +use super::*; +use deckcraft_model::chart::{Chart, ChartPart, ChartRel, ChartType}; +use deckcraft_model::{Master, Shape, ShapeId}; +use serde_json::{Value, json}; + +const PART: &str = "ppt/embeddings/owned.xlsx"; + +fn fixture(payload: Vec) -> Presentation { + // Construct through the old manifest representation: this also exercises compatibility. + let mut part: ChartPart = serde_json::from_value(json!({ + "name": PART, "contentType": "application/zip", "bytes": [] + })) + .unwrap(); + part.bytes = payload; + let mut chart = Chart::sample(ChartType::Pie); + chart.kept.rels.push(ChartRel { + id: "rWorkbook".into(), + rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package".into(), + target: "../embeddings/owned.xlsx".into(), + part: PART.into(), + external: false, + }); + chart.kept.parts.push(part); + let mut p = Presentation::default(); + let child = Shape { id: ShapeId(p.alloc_id().unwrap()), kind: ShapeKind::Chart(Box::new(chart)), ..Default::default() }; + let group = Shape { + id: ShapeId(p.alloc_id().unwrap()), + kind: ShapeKind::Group { children: vec![child], child: Default::default() }, + ..Default::default() + }; + Arc::make_mut(&mut p.slides[0]).shapes = vec![group]; + p +} + +fn part_json(value: &mut Value) -> Option<&mut Value> { + if value.get("name").and_then(Value::as_str) == Some(PART) && value.get("contentType").is_some() { + return Some(value); + } + match value { + Value::Array(values) => values.iter_mut().find_map(part_json), + Value::Object(values) => values.values_mut().find_map(part_json), + _ => None, + } +} + +fn rewrite_manifest(bytes: &[u8], edit: impl FnOnce(&mut Value)) -> Vec { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); + let mut json: Value = serde_json::from_slice(&read_entry(&mut z, "presentation.json").unwrap()).unwrap(); + edit(&mut json); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..z.len() { + let f = z.by_index(i).unwrap(); + if f.name() == "presentation.json" { + out.start_file("presentation.json", zip::write::SimpleFileOptions::default()).unwrap(); + out.write_all(&serde_json::to_vec(&json).unwrap()).unwrap(); + } else { + out.raw_copy_file(f).unwrap(); + } + } + out.finish().unwrap().into_inner() +} + +#[test] +fn large_chart_payload_is_a_referenced_zip_entry() { + let payload = vec![200; 3 * 1024 * 1024]; + let p = fixture(payload.clone()); + let bytes = save(&p).unwrap(); + let mut z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap(); + assert!(z.by_name("presentation.json").unwrap().size() < 128 * 1024, "binary chart bytes must not become JSON numbers"); + let mut manifest: Value = serde_json::from_slice(&read_entry(&mut z, "presentation.json").unwrap()).unwrap(); + let part = part_json(&mut manifest).unwrap(); + assert!(part.get("bytes").is_none(), "new manifests keep only metadata and a reference"); + let entry = part["nativeEntry"].as_str().unwrap(); + assert!(entry.starts_with("chart-parts/")); + assert_eq!(read_entry(&mut z, entry).unwrap(), payload); + assert!(read_entry_max(&mut z, entry, payload.len() as u64 - 1).is_err()); + assert_eq!(load(&bytes).unwrap(), p); +} + +#[test] +fn chart_parts_share_media_byte_and_item_budgets() { + let mut p = fixture(vec![7; 600]); + p.add_media("a.bin", "application/octet-stream", vec![8; 600]).unwrap(); + let bytes = save(&p).unwrap(); + assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok()); + assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err(), "chart payloads share the cumulative media budget"); + assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err(), "chart parts share the media item budget"); + let duplicated = rewrite_manifest(&bytes, |manifest| { + let media = manifest["presentation"]["media"].as_array_mut().unwrap(); + media.push(media[0].clone()); + }); + assert!(load_within(&duplicated, Budget { total: 1200, items: 2 }).is_err(), "duplicate media IDs still count as listed items"); +} + +#[test] +fn legacy_inline_chart_bytes_are_budgeted_and_preserved() { + let mut p = fixture(vec![9; 600]); + p.add_media("a.bin", "application/octet-stream", vec![8; 600]).unwrap(); + let legacy = rewrite_manifest(&save(&p).unwrap(), |manifest| { + let part = part_json(manifest).unwrap().as_object_mut().unwrap(); + part.remove("nativeEntry"); + part.insert("bytes".into(), json!(vec![9_u8; 600])); + }); + assert_eq!(load_within(&legacy, Budget { total: 1200, items: 2 }).unwrap().0, p); + assert!(load_within(&legacy, Budget { total: 1199, items: 2 }).is_err()); + assert!(load_within(&legacy, Budget { total: 1200, items: 1 }).is_err()); +} + +#[test] +fn chart_payloads_in_every_shape_owner_get_distinct_entries() { + let mut p = fixture(vec![42; 4096]); + let shapes = p.slides[0].shapes.clone(); + let master = Arc::make_mut(&mut p.masters[0]); + master.shapes = shapes.clone(); + master.layouts[0].shapes = shapes.clone(); + p.notes_master = Some(Arc::new(Master { shapes: shapes.clone(), ..Default::default() })); + p.handout_master = Some(Arc::new(Master { shapes, ..Default::default() })); + let bytes = save(&p).unwrap(); + let z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap(); + assert_eq!(z.file_names().filter(|name| name.starts_with("chart-parts/")).count(), 5); + assert_eq!(load(&bytes).unwrap(), p); +} + +#[test] +fn missing_or_corrupt_chart_payload_is_reported() { + let p = fixture(b"SYNTHETIC-CHART-PAYLOAD-0123456789".to_vec()); + let bytes = save(&p).unwrap(); + let mut z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap(); + let mut json: Value = serde_json::from_slice(&read_entry(&mut z, "presentation.json").unwrap()).unwrap(); + let name = part_json(&mut json).unwrap()["nativeEntry"].as_str().unwrap().to_owned(); + let start = z.by_name(&name).unwrap().data_start() as usize; + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + for i in 0..z.len() { + let f = z.by_index(i).unwrap(); + if f.name() != name { + out.raw_copy_file(f).unwrap(); + } + } + let missing = out.finish().unwrap().into_inner(); + let (q, damaged) = load_checked(&missing).unwrap(); + assert_eq!(damaged.len(), 1, "{damaged:?}"); + let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") }; + let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") }; + assert!(chart.kept.parts[0].bytes.is_empty()); + let mut corrupt = bytes.clone(); + corrupt[start] ^= 0xff; + assert!(load_checked(&corrupt).is_err()); +} + +#[test] +fn chart_references_cannot_alias_other_members_or_mix_inline_bytes() { + let p = fixture(vec![3; 10]); + let bytes = save(&p).unwrap(); + for entry in ["media/1", "chart-parts/../0", "chart-parts/00", "chart-parts/-1"] { + let changed = rewrite_manifest(&bytes, |manifest| { + part_json(manifest).unwrap()["nativeEntry"] = json!(entry); + }); + assert!(load(&changed).is_err(), "accepted {entry}"); + } + let ambiguous = rewrite_manifest(&bytes, |manifest| { + part_json(manifest).unwrap()["bytes"] = json!([3]); + }); + assert!(load(&ambiguous).is_err()); + + let mut p = p; + let ShapeKind::Group { children, .. } = &mut Arc::make_mut(&mut p.slides[0]).shapes[0].kind else { panic!("group") }; + let ShapeKind::Chart(chart) = &mut children[0].kind else { panic!("chart") }; + chart.kept.parts.push(chart.kept.parts[0].clone()); + let aliased = rewrite_manifest(&save(&p).unwrap(), |manifest| { + fn alias(value: &mut Value) { + if let Some(entry) = value.get_mut("nativeEntry") { + *entry = json!("chart-parts/0"); + } + match value { + Value::Array(values) => values.iter_mut().for_each(alias), + Value::Object(values) => values.values_mut().for_each(alias), + _ => {} + } + } + alias(manifest); + }); + assert!(load(&aliased).is_err(), "one ZIP member must not be inflated repeatedly"); +} + +fn with_two_parts() -> Presentation { + let mut p = fixture(vec![3; 10]); + let ShapeKind::Group { children, .. } = &mut Arc::make_mut(&mut p.slides[0]).shapes[0].kind else { panic!("group") }; + let ShapeKind::Chart(chart) = &mut children[0].kind else { panic!("chart") }; + let mut second = chart.kept.parts[0].clone(); + second.bytes = vec![4; 10]; + chart.kept.parts.push(second); + p +} + +#[test] +fn corrupt_chart_members_stop_before_reusing_the_inflation_budget() { + let mut bytes = save(&with_two_parts()).unwrap(); + let offsets: Vec<_> = { + let mut z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap(); + (0..2).map(|i| z.by_name(&format!("chart-parts/{i}")).unwrap().data_start() as usize).collect() + }; + for offset in offsets { + bytes[offset] ^= 0xff; + } + // A failed CRC may already have inflated bytes. Continuing with the same remaining + // budget would let each corrupt member consume that work budget again. + let result = load_within(&bytes, Budget { total: 10, items: 2 }); + assert!(result.is_err(), "two corrupt 10-byte members must not reuse a 10-byte budget"); + assert!(result.unwrap_err().to_string().contains("chart-parts/0")); +} + +#[test] +fn duplicate_physical_zip_names_are_indexed_and_inflated_once() { + let mut bytes = save(&with_two_parts()).unwrap(); + // ZipWriter refuses duplicate names. Forge equal-length local/central-directory names + // instead, to exercise ZipArchive's duplicate-name indexing on an untrusted archive. + let original = b"chart-parts/1"; + let mut replaced = 0; + for offset in 0..=bytes.len() - original.len() { + if &bytes[offset..offset + original.len()] == original { + bytes[offset + original.len() - 1] = b'0'; + replaced += 1; + } + } + assert_eq!(replaced, 2); + let z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap(); + assert_eq!(z.file_names().filter(|n| n.starts_with("chart-parts/")).count(), 1); + let (q, damaged) = load_within(&bytes, Budget { total: 10, items: 2 }).unwrap(); + assert_eq!(damaged.len(), 1, "the second reference is missing, not inflated again"); + let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") }; + let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") }; + assert_eq!(chart.kept.parts[0].bytes, vec![4; 10]); + assert!(chart.kept.parts[1].bytes.is_empty()); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 5701a1ec..093cbaa6 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -1,11 +1,15 @@ //! The native `.deckcraft` format: a zip holding `presentation.json` (the model, minus media -//! bytes), `media/` files with the embedded media, and `mimetype`. Readers ignore unknown +//! bytes), `media/` and `chart-parts/` binary entries, and `mimetype`. Readers ignore unknown //! fields, so newer files open in older builds with what those builds understand. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod chart_parts; mod finite; +#[cfg(test)] +mod chart_parts_tests; + use std::io::{Cursor, Read, Write}; use std::sync::Arc; @@ -20,9 +24,9 @@ pub const EXTENSION: &str = "deckcraft"; pub const VERSION: u32 = 1; /// Largest entry we will inflate (guards against zip bombs). const MAX_ENTRY: u64 = 2 << 30; -/// All embedded media together, inflated (#393). +/// All embedded media and chart sidecars together, inflated (#393). const MAX_TOTAL_MEDIA: u64 = 4 << 30; -/// Media items one file may list. +/// Media items and chart sidecar parts one file may list. const MAX_MEDIA_ITEMS: usize = 100_000; /// What one load may inflate. @@ -62,12 +66,10 @@ pub fn save(p: &Presentation) -> Result> { let w = |e: zip::result::ZipError| FormatError::Write(e.to_string()); z.start_file("mimetype", stored).map_err(w)?; z.write_all(MIMETYPE.as_bytes()).map_err(|e| FormatError::Write(e.to_string()))?; - let m = Manifest { - format: "deckcraft".into(), - version: VERSION, - generator: format!("DeckCraft {}", env!("CARGO_PKG_VERSION")), - presentation: p.clone(), - }; + let mut presentation = p.clone(); + let parts = chart_parts::separate(&mut presentation)?; + let m = + Manifest { format: "deckcraft".into(), version: VERSION, generator: format!("DeckCraft {}", env!("CARGO_PKG_VERSION")), presentation }; let json = serde_json::to_vec(&finite::Finite(&m)).map_err(|e| FormatError::Write(e.to_string()))?; z.start_file("presentation.json", deflate).map_err(w)?; z.write_all(&json).map_err(|e| FormatError::Write(e.to_string()))?; @@ -80,6 +82,11 @@ pub fn save(p: &Presentation) -> Result> { z.start_file(format!("media/{}", item.id.0), opts).map_err(w)?; z.write_all(&item.data).map_err(|e| FormatError::Write(e.to_string()))?; } + for (name, content_type, bytes) in parts { + let opts = if is_compressed(&content_type) { stored } else { deflate }; + z.start_file(name, opts).map_err(w)?; + z.write_all(&bytes).map_err(|e| FormatError::Write(e.to_string()))?; + } z.finish().map_err(w)?; } Ok(buf.into_inner()) @@ -119,14 +126,15 @@ fn read_entry_max(z: &mut zip::ZipArchive>, name: &str, max: u64) Ok(out) } -/// Read a presentation. Embedded media that is missing or damaged is left empty; use -/// [`load_checked`] to learn which. +/// Read a presentation. Missing media/chart parts are left empty; use [`load_checked`] to +/// learn which. A damaged chart member refuses the file before further chart inflation. pub fn load(bytes: &[u8]) -> Result { load_checked(bytes).map(|(p, _)| p) } -/// Read a presentation and list the embedded media that couldn't be read (missing, too large -/// or failing its CRC), one message each. Linked media saved without its bytes isn't damage. +/// Read a presentation and list media that couldn't be read, plus missing chart members. +/// A chart member that is present but fails decoding, CRC or its entry limit refuses the file. +/// Linked media saved without its bytes isn't damage. pub fn load_checked(bytes: &[u8]) -> Result<(Presentation, Vec)> { load_within(bytes, Budget { total: MAX_TOTAL_MEDIA, items: MAX_MEDIA_ITEMS }) } @@ -140,7 +148,8 @@ fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec budget.items { + let items = p.media.len(); + if items > budget.items { return Err(FormatError::Damaged(format!("{} media items (at most {})", p.media.len(), budget.items))); } // A repeated id names the same member: keep the first item, so the member is inflated once @@ -163,6 +172,7 @@ fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec damaged.push(e.to_string()), } } + chart_parts::restore(&mut p, &mut z, &mut left, items, budget.items, &mut damaged)?; repair(&mut p); Ok((p, damaged)) } @@ -663,6 +673,54 @@ pub fn slides_to_outline(p: &Presentation) -> String { mod tests { use super::*; + #[test] + fn chart_sidecars_are_optional_for_older_native_files() { + use deckcraft_model::chart::{Chart, ChartType}; + let chart = Chart::sample(ChartType::Pie); + let mut json = serde_json::to_value(&chart).expect("chart JSON"); + assert!(json.get("kept").is_none(), "empty sidecars should not enlarge ordinary files"); + assert_eq!(serde_json::from_value::(json.clone()).expect("old chart without kept"), chart); + json["kept"] = serde_json::json!({}); + assert_eq!(serde_json::from_value::(json).expect("empty sidecar"), chart); + } + + #[test] + fn native_save_keeps_chart_relationships_and_payloads() { + use deckcraft_model::chart::{Chart, ChartKept, ChartPart, ChartRel, ChartType}; + use deckcraft_model::{Shape, ShapeId, ShapeKind}; + let kept = ChartKept { + rels: vec![ChartRel { + id: "rWorkbook".into(), + rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package".into(), + target: "../embeddings/data.xlsx".into(), + part: "ppt/embeddings/data.xlsx".into(), + external: false, + }], + parts: vec![ChartPart { + name: "ppt/embeddings/data.xlsx".into(), + content_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".into(), + bytes: vec![0, 255, 128, 42], + native_entry: None, + }], + }; + let mut p = defaults::new_presentation(None); + let chart = Chart { kept: kept.clone(), ..Chart::sample(ChartType::Pie) }; + let s = Shape { id: ShapeId(p.alloc_id().expect("id")), kind: ShapeKind::Chart(Box::new(chart)), ..Default::default() }; + // Serialization must reach charts inside groups as well as top-level charts. + Arc::make_mut(&mut p.slides[0]).shapes = vec![Shape { + id: ShapeId(p.alloc_id().expect("group id")), + kind: ShapeKind::Group { children: vec![s], child: Default::default() }, + ..Default::default() + }]; + let mut q = load(&save(&p).expect("save")).expect("load"); + for _ in 0..2 { + let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") }; + let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") }; + assert_eq!(chart.kept, kept); + q = load(&save(&q).expect("save again")).expect("load again"); + } + } + /// The same package without the entries `drop` names. fn without(bytes: &[u8], drop: &str) -> Vec { let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap(); diff --git a/crates/model/src/chart.rs b/crates/model/src/chart.rs index 36dd9708..f5aeb1ee 100644 --- a/crates/model/src/chart.rs +++ b/crates/model/src/chart.rs @@ -1,1441 +1,1455 @@ -//! Charts: an embedded data grid plus type and element options. - -use serde::{Deserialize, Serialize}; - -use crate::style::{ColorRef, Fill, Line}; - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ChartType { - #[default] - Column, - StackedColumn, - PercentColumn, - Bar, - StackedBar, - PercentBar, - Line, - LineMarkers, - StackedLine, - Area, - StackedArea, - Pie, - Doughnut, - Scatter, - Bubble, - Radar, - FilledRadar, - Stock, - Surface, - Histogram, - Pareto, - BoxWhisker, - Waterfall, - Funnel, - Treemap, - Sunburst, - /// A filled (choropleth) map of regions coloured by value, kept and drawn from its data; it - /// is not offered in the chart menus (PowerPoint needs an online map service to make one). - RegionMap, - Combo, -} - -impl ChartType { - /// The Office 2016 (`chartEx`) types, offered after [`ChartType::MAIN`] in chart menus. - pub const OFFICE_2016: [ChartType; 7] = [ - ChartType::Waterfall, - ChartType::Funnel, - ChartType::Histogram, - ChartType::Pareto, - ChartType::BoxWhisker, - ChartType::Treemap, - ChartType::Sunburst, - ]; - - /// Whether the type is saved as a `chartEx` part (with a plain chart as the fallback). - pub fn is_chartex(self) -> bool { - Self::OFFICE_2016.contains(&self) || self == ChartType::RegionMap - } - - pub const MAIN: [ChartType; 12] = [ - ChartType::Column, - ChartType::Bar, - ChartType::Line, - ChartType::Area, - ChartType::Pie, - ChartType::Doughnut, - ChartType::Scatter, - ChartType::Radar, - ChartType::Waterfall, - ChartType::Funnel, - ChartType::Histogram, - ChartType::Treemap, - ]; - pub fn label(self) -> &'static str { - match self { - ChartType::Column => "Clustered Column", - ChartType::StackedColumn => "Stacked Column", - ChartType::PercentColumn => "100% Stacked Column", - ChartType::Bar => "Clustered Bar", - ChartType::StackedBar => "Stacked Bar", - ChartType::PercentBar => "100% Stacked Bar", - ChartType::Line => "Line", - ChartType::LineMarkers => "Line with Markers", - ChartType::StackedLine => "Stacked Line", - ChartType::Area => "Area", - ChartType::StackedArea => "Stacked Area", - ChartType::Pie => "Pie", - ChartType::Doughnut => "Doughnut", - ChartType::Scatter => "Scatter", - ChartType::Bubble => "Bubble", - ChartType::Radar => "Radar", - ChartType::FilledRadar => "Filled Radar", - ChartType::Stock => "Stock", - ChartType::Surface => "Surface", - ChartType::Histogram => "Histogram", - ChartType::Pareto => "Pareto", - ChartType::BoxWhisker => "Box and Whisker", - ChartType::Waterfall => "Waterfall", - ChartType::Funnel => "Funnel", - ChartType::Treemap => "Treemap", - ChartType::Sunburst => "Sunburst", - ChartType::RegionMap => "Map", - ChartType::Combo => "Combo", - } - } - pub fn is_bar_like(self) -> bool { - matches!( - self, - ChartType::Column - | ChartType::StackedColumn - | ChartType::PercentColumn - | ChartType::Bar - | ChartType::StackedBar - | ChartType::PercentBar - | ChartType::Histogram - | ChartType::Pareto - | ChartType::Waterfall - ) - } - pub fn horizontal(self) -> bool { - matches!(self, ChartType::Bar | ChartType::StackedBar | ChartType::PercentBar | ChartType::Funnel) - } - pub fn stacked(self) -> bool { - matches!( - self, - ChartType::StackedColumn - | ChartType::StackedBar - | ChartType::StackedLine - | ChartType::StackedArea - | ChartType::PercentColumn - | ChartType::PercentBar - ) - } - pub fn percent(self) -> bool { - matches!(self, ChartType::PercentColumn | ChartType::PercentBar) - } -} - -/// `c:trendlineType`: the six trendlines DrawingML charts store. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum TrendlineType { - Exp, - #[default] - Linear, - Log, - MovingAvg, - Poly, - Power, -} - -impl TrendlineType { - pub fn xml(self) -> &'static str { - match self { - TrendlineType::Exp => "exp", - TrendlineType::Linear => "linear", - TrendlineType::Log => "log", - TrendlineType::MovingAvg => "movingAvg", - TrendlineType::Poly => "poly", - TrendlineType::Power => "power", - } - } - pub fn from_xml(s: &str) -> Option { - Some(match s { - "exp" => TrendlineType::Exp, - "linear" => TrendlineType::Linear, - "log" => TrendlineType::Log, - "movingAvg" => TrendlineType::MovingAvg, - "poly" => TrendlineType::Poly, - "power" => TrendlineType::Power, - _ => return None, - }) - } -} - -/// One `c:trendline` on a series. Polynomials use `order` (2–6); moving averages use `period`. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct Trendline { - pub kind: TrendlineType, - /// `c:name`, when the file sets one. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - /// Polynomial order (`c:order`). - #[serde(skip_serializing_if = "Option::is_none")] - pub order: Option, - /// Moving-average period (`c:period`). - #[serde(skip_serializing_if = "Option::is_none")] - pub period: Option, - /// Forecast forward and backward, in category units (`c:forward`, `c:backward`). - #[serde(skip_serializing_if = "Option::is_none")] - pub forward: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub backward: Option, - /// Cross the value axis at this value (`c:intercept`). - #[serde(skip_serializing_if = "Option::is_none")] - pub intercept: Option, - /// Show the equation (`c:dispEq`). - pub disp_eq: bool, - /// Show R² (`c:dispRSqr`). - pub disp_r_sqr: bool, -} - -impl Default for Trendline { - fn default() -> Self { - Trendline { - kind: TrendlineType::Linear, - name: None, - order: None, - period: None, - forward: None, - backward: None, - intercept: None, - disp_eq: false, - disp_r_sqr: false, - } - } -} - -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct Series { - pub name: String, - pub values: Vec>, - /// X values for scatter/bubble. - pub x_values: Vec>, - pub sizes: Vec>, - pub fill: Option, - pub line: Option, - /// Per-point fill overrides (pie slices). - pub point_fills: Vec<(usize, Fill)>, - pub data_labels: Option, - pub smooth: bool, - pub marker: Option, - /// Combo charts: this series' own type. - pub kind: Option, - 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, - /// Trendlines (`c:trendline`), in file order. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub trendlines: Vec, - /// `c:errBars` on the values (y bars; a bar chart's run along its value axis). - #[serde(skip_serializing_if = "Option::is_none")] - pub error_bars: Option, -} - -/// Error bars (`c:errBars`): how far each point's bar reaches below and above it. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct ErrorBars { - /// `both`, `plus` or `minus`. - pub bar_type: String, - /// `fixedVal`, `percentage`, `stdDev`, `stdErr` or `cust`. - pub val_type: String, - pub value: f64, - /// `cust`: the per-point plus and minus amounts. - pub plus: Vec>, - pub minus: Vec>, - pub end_cap: bool, -} - -impl Default for ErrorBars { - fn default() -> Self { - ErrorBars { bar_type: "both".into(), val_type: "fixedVal".into(), value: 1.0, plus: vec![], minus: vec![], end_cap: true } - } -} - -/// Count, mean and sample standard deviation of a series' finite values (for error bars). -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub struct SeriesStats { - pub n: f64, - pub mean: f64, - pub sd: f64, -} - -impl SeriesStats { - pub fn of(values: &[Option]) -> Self { - let vals = || values.iter().flatten().copied().filter(|v| v.is_finite()); - let n = vals().count() as f64; - let mean = vals().sum::() / n.max(1.0); - let sd = if n > 1.0 { (vals().map(|x| (x - mean).powi(2)).sum::() / (n - 1.0)).sqrt() } else { 0.0 }; - SeriesStats { n, mean, sd } - } -} - -impl ErrorBars { - /// Where point `i` (value `v`) of a series with `values` has its bar: (low, high) in data units. - /// Standard deviation bars stand around the series' mean, as Excel draws them; the others - /// around the point. `None` for an amount that isn't a finite number. - pub fn range(&self, values: &[Option], i: usize, v: f64) -> Option<(f64, f64)> { - self.range_in(&SeriesStats::of(values), i, v) - } - - /// [`ErrorBars::range`] with the series' statistics worked out once ([`SeriesStats::of`]), so - /// drawing every point of a long series stays linear. - pub fn range_in(&self, stats: &SeriesStats, i: usize, v: f64) -> Option<(f64, f64)> { - let SeriesStats { n, mean, sd } = *stats; - let (centre, minus, plus) = match self.val_type.as_str() { - "percentage" => (v, (v * self.value / 100.0).abs(), (v * self.value / 100.0).abs()), - "stdDev" => (mean, sd * self.value, sd * self.value), - "stdErr" => (v, sd / n.max(1.0).sqrt(), sd / n.max(1.0).sqrt()), - "cust" => (v, self.minus.get(i).copied().flatten().unwrap_or(0.0), self.plus.get(i).copied().flatten().unwrap_or(0.0)), - _ => (v, self.value, self.value), - }; - let (lo, hi) = match self.bar_type.as_str() { - "plus" => (centre, centre + plus), - "minus" => (centre - minus, centre), - _ => (centre - minus, centre + plus), - }; - (lo.is_finite() && hi.is_finite()).then_some((lo, hi)) - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default, rename_all = "camelCase")] -pub struct Chart { - pub kind: ChartType, - pub title: Option, - pub categories: Vec, - pub series: Vec, - pub legend: Option, - /// `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, - /// Title text size in points (its rich text or `c:txPr`). - #[serde(skip_serializing_if = "Option::is_none")] - pub title_size: Option, - pub data_labels: bool, - pub gridlines: bool, - /// `c:minorGridlines` on the value axis: five to a major unit, between the major ones. - pub minor_gridlines: bool, - pub axis_titles: (Option, Option), - /// Chart style number (1–48) and colour palette (`colorful1`, `monochrome3`…). - pub style: u32, - pub palette: String, - pub plot_fill: Option, - pub chart_fill: Option, - pub text_color: Option, - pub hole_size: f64, - pub gap_width: f64, - pub overlap: f64, - pub vary_colors: bool, - pub y_min: Option, - pub y_max: Option, - /// The value axis' major unit, when the chart fixes it. - pub y_major: Option, - /// `c:scaling/c:logBase`: the value axis is logarithmic in this base (2–1000). - pub y_log_base: Option, - /// The value axis' number format (an Excel format code such as `#,##0` or `0.0%`). - pub y_format: Option, - /// The value axis is deleted (`c:delete`): no labels, though gridlines may stay. - pub y_hidden: bool, - /// The category axis runs backwards (`c:orientation val="maxMin"`): right to left, or on a - /// horizontal bar chart top to bottom (the default puts the first category at the bottom). - pub cat_reversed: bool, - /// A combo chart's secondary value axis: bounds, major unit and number format. - pub y2_min: Option, - pub y2_max: Option, - pub y2_major: Option, - pub y2_format: Option, - pub y2_hidden: bool, - /// The chart's language (`c:lang`, e.g. `ru-RU`): digit grouping and decimal mark. - pub lang: Option, - /// A waterfall's total points (`cx:subtotals`): bars that stand on the axis and restart - /// the running total, instead of floating. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub subtotals: Vec, - /// Outer category levels of a treemap or sunburst, innermost first (the leaves' parents, - /// then theirs…), each with one label per category. A blank label continues the one above it. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub category_levels: Vec>, - /// Box and whisker quartiles by the inclusive method (QUARTILE.INC) instead of exclusive. - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub inclusive_quartiles: bool, - /// A stock chart's up/down bars (`c:upDownBars`): open-high-low-close, the box between - /// the first and last series, hollow when the price rose. - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub up_down_bars: bool, - /// A histogram's bin count (`cx:binCount`); with neither this nor `bin_size` the bins are - /// automatic (Scott's rule, as Excel does). - #[serde(skip_serializing_if = "Option::is_none")] - pub bin_count: Option, - /// A histogram's bin width (`cx:binSize`). - #[serde(skip_serializing_if = "Option::is_none")] - pub bin_size: Option, - /// Original chart XML part kept for round-trip of unmodelled options. - #[serde(skip_serializing_if = "Option::is_none")] - pub raw: Option, - /// Package parts the original chart still points at (embedded workbook, styles, drawings). - /// The writer clears this before comparing modelled state. A native save does not store the bytes. - #[serde(skip)] - pub kept: ChartKept, -} - -/// One relationship of a chart part, with the target string as it was written. -#[derive(Clone, Debug, PartialEq)] -pub struct ChartRel { - pub id: String, - pub rel_type: String, - pub target: String, - /// Absolute name of the part an internal relationship reaches (matches a `ChartPart::name` - /// up to case); empty for external ones. The writer renames kept parts and uses this to - /// point the relationship at the new name. - pub part: String, - pub external: bool, -} - -/// A part reached from a chart (the workbook, a drawing, an image it uses). -#[derive(Clone, Debug, PartialEq)] -pub struct ChartPart { - /// Absolute part name (`ppt/embeddings/Workbook.xlsx`). - pub name: String, - pub content_type: String, - pub bytes: Vec, -} - -/// Sidecar for an unchanged chart: relationships and the parts they reach. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct ChartKept { - pub rels: Vec, - pub parts: Vec, -} - -impl Default for Chart { - fn default() -> Self { - Chart { - kind: ChartType::Column, - title: Some("Chart Title".into()), - categories: vec![], - series: vec![], - legend: Some("b".into()), - legend_overlay: false, - text_size: None, - title_size: None, - data_labels: false, - gridlines: true, - minor_gridlines: false, - axis_titles: (None, None), - style: 2, - palette: "colorful1".into(), - plot_fill: None, - chart_fill: None, - text_color: None, - hole_size: 0.5, - gap_width: 2.19, - overlap: -0.27, - vary_colors: false, - y_min: None, - y_max: None, - y_major: None, - y_log_base: None, - y_format: None, - y_hidden: false, - cat_reversed: false, - y2_min: None, - y2_max: None, - y2_major: None, - y2_format: None, - y2_hidden: false, - lang: None, - subtotals: vec![], - category_levels: vec![], - inclusive_quartiles: false, - up_down_bars: false, - bin_count: None, - bin_size: None, - raw: None, - kept: ChartKept::default(), - } - } -} - -impl Chart { - /// The sample data a new chart starts with (our own numbers). - pub fn sample(kind: ChartType) -> Self { - let cats = ["Q1", "Q2", "Q3", "Q4"]; - let data = [[4.2, 2.6, 2.1], [2.8, 4.1, 2.4], [3.6, 1.9, 3.3], [4.7, 3.0, 5.1]]; - let mut c = Chart { kind, categories: cats.iter().map(|s| s.to_string()).collect(), ..Default::default() }; - if matches!(kind, ChartType::Treemap | ChartType::Sunburst) { - // A two-level hierarchy: months inside quarters. - c.categories = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"].iter().map(|s| s.to_string()).collect(); - c.category_levels = vec![["Q1", "Q1", "Q1", "Q2", "Q2", "Q2", "Q3", "Q3"].iter().map(|s| s.to_string()).collect()]; - let v = [4.2, 2.8, 3.6, 4.7, 2.6, 4.1, 1.9, 3.0]; - c.series.push(Series { name: "Series 1".into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }); - c.vary_colors = true; - return c; - } - if matches!(kind, ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) { - // These plot raw observations: a histogram bins one series, a box chart summarises - // each category's points. - let obs = [12.0, 15.0, 17.0, 18.0, 21.0, 22.0, 22.0, 24.0, 25.0, 27.0, 28.0, 31.0, 33.0, 36.0, 41.0, 48.0]; - let n_series = if kind == ChartType::BoxWhisker { 2 } else { 1 }; - c.categories = if kind == ChartType::BoxWhisker { - (0..obs.len()).map(|i| if i % 2 == 0 { "Group A" } else { "Group B" }.to_string()).collect() - } else { - vec![] - }; - for s in 0..n_series { - c.series.push(Series { - name: format!("Series {}", s + 1), - values: obs.iter().map(|v| Some(v + s as f64 * 6.0)).collect(), - ..Default::default() - }); - } - c.gridlines = true; - c.legend = (kind == ChartType::BoxWhisker).then(|| "b".to_string()); - return c; - } - if kind == ChartType::Stock { - // High-low-close over five days, the series in the order PowerPoint expects. - c.categories = ["Mon", "Tue", "Wed", "Thu", "Fri"].iter().map(|s| s.to_string()).collect(); - let hlc = [("High", [55.0, 57.0, 57.0, 58.0, 58.0]), ("Low", [11.0, 12.0, 13.0, 11.0, 25.0]), ("Close", [25.0, 38.0, 50.0, 35.0, 43.0])]; - for (name, v) in hlc { - c.series.push(Series { name: name.into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }); - } - return c; - } - let n_series = - if matches!(kind, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Waterfall | ChartType::Treemap) { 1 } else { 3 }; - for s in 0..n_series { - c.series.push(Series { - name: format!("Series {}", s + 1), - values: data.iter().map(|row| row.get(s).copied()).collect(), - x_values: if kind == ChartType::Scatter { (1..=4).map(|x| Some(x as f64)).collect() } else { vec![] }, - ..Default::default() - }); - } - c.vary_colors = n_series == 1; - if kind == ChartType::Combo { - // Columns with the last series as a line, as PowerPoint's combo starts. - for (i, s) in c.series.iter_mut().enumerate() { - s.kind = Some(if i + 1 == n_series { ChartType::Line } else { ChartType::Column }); - } - } - 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 (lo, hi) = self.plotted_extent(secondary)?; - let (lo, hi) = (lo.min(0.0), hi.max(0.0)); - Some(if secondary { (lo, hi) } else { (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) }) - } - - /// The smallest and largest value plotted against one value axis: plain values as they are, - /// stacked series as running sums from zero, 100 % series as 0–1. `(0, 0)` without finite - /// values; `None` when no series uses the axis. - fn plotted_extent(&self, secondary: bool) -> Option<(f64, f64)> { - if self.kind == ChartType::Waterfall { - 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 (!secondary && self.kind != ChartType::Combo).then_some((0.0, 0.0)); - } - let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); - 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, mut stacked) = (0.0, 0.0, false); - 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() { - stacked = true; - if v >= 0.0 { p += v } else { m += v } - } else { - hi = hi.max(v); - lo = lo.min(v); - } - } - if stacked { - hi = hi.max(p); - lo = lo.min(m); - } - } - if !percent { - for s in &ser { - if self.series_kind(s).stacked() { - continue; - } - for y in s.trendline_ys(crate::trendline::series_is_scatter(self.series_kind(s))) { - hi = hi.max(y); - lo = lo.min(y); - } - } - } - if percent { - lo = lo.min(0.0); - hi = hi.max(1.0); - } - Some(if lo <= hi { (lo, hi) } else { (0.0, 0.0) }) - } - - /// A waterfall's bars as (from, to) on the value axis: each point floats from the running - /// total, a total point stands on zero and sets it. - pub fn waterfall_bars(&self) -> Vec<(f64, f64)> { - let Some(s) = self.series.first() else { return vec![] }; - let mut run = 0.0; - s.values - .iter() - .enumerate() - .map(|(i, v)| { - let v = v.unwrap_or(0.0); - if self.subtotals.contains(&i) { - run = v; - (0.0, v) - } else { - let from = run; - run += v; - (from, run) - } - }) - .collect() - } - - /// A value axis' scale — (min, max, major unit) — with at most `max_intervals` intervals: - /// the bounds and unit the chart fixes, else Office's automatic ones ([`axis_scale`](fn@axis_scale)). - /// `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.plotted_extent(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 logarithmic value axis' scale in exponents — (min, max, unit), so its labels are - /// `base^min`, `base^(min+unit)`… — covering the positive values: whole powers, as PowerPoint - /// and LibreOffice draw them. `None` when the axis isn't logarithmic or nothing is positive. - pub fn log_axis_scale(&self, max_intervals: usize) -> Option<(f64, f64, f64)> { - let b = self.y_log_base.filter(|b| b.is_finite() && *b >= 2.0)?; - let vals = self.series.iter().filter(|s| !self.on_secondary(s)).flat_map(|s| s.values.iter().flatten().copied()); - let (lo, hi) = vals - .filter(|v| v.is_finite() && *v > 0.0) - .fold(None, |r: Option<(f64, f64)>, v| Some(r.map_or((v, v), |(a, z)| (a.min(v), z.max(v)))))?; - let e = |v: f64| v.log(b); - let lo = self.y_min.filter(|v| *v > 0.0).map_or(e(lo).floor(), e); - let hi = self.y_max.filter(|v| *v > 0.0).map_or(e(hi).ceil(), e).max(lo + 1.0); - let unit = ((hi - lo) / max_intervals.max(1) as f64).ceil().max(1.0); - Some((lo, lo + ((hi - lo) / unit).ceil() * unit, unit)) - } - - /// 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 }; - // A 100 % stacked chart's value axis counts in percent unless the file says otherwise. - let code = match code.as_deref() { - None | Some("General") if !secondary && self.kind.percent() => "0%", - c => c.unwrap_or("General"), - }; - crate::numfmt::format(code, 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; - let mut hi: f64 = 0.0; - if self.kind == ChartType::Waterfall { - for (a, b) in self.waterfall_bars() { - lo = lo.min(a.min(b)); - hi = hi.max(a.max(b)); - } - return (lo, hi); - } - let n = self.categories.len().max(self.series.iter().map(|s| s.values.len()).max().unwrap_or(0)); - if self.kind.stacked() { - for i in 0..n { - let (mut p, mut m) = (0.0, 0.0); - for s in &self.series { - let v = s.values.get(i).copied().flatten().unwrap_or(0.0); - if v >= 0.0 { p += v } else { m += v } - } - hi = hi.max(p); - lo = lo.min(m); - } - } else { - for s in &self.series { - for v in s.values.iter().flatten() { - if v.is_finite() { - hi = hi.max(*v); - lo = lo.min(*v); - } - } - } - } - if self.kind.percent() { - return (0.0, 1.0); - } - if !self.kind.stacked() { - for s in &self.series { - for y in s.trendline_ys(crate::trendline::series_is_scatter(self.series_kind(s))) { - hi = hi.max(y); - lo = lo.min(y); - } - } - } - (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) - } -} - -/// The (min, max, major unit) of an axis over the data range [lo, hi]: `fmin`/`fmax`/`major` -/// where the chart fixes them; otherwise Office's automatic limits, rounded out to the smallest -/// 1-2-5 unit giving at most `max_intervals` intervals: -/// -/// - **Maximum:** `hi + (hi − lo)/20` (with `headroom`), rounded up to the unit. -/// - **Minimum:** zero when every value is ≥ 0 and `lo` is below 5/6 of `hi`; otherwise -/// `lo − (hi − lo)/20`, rounded down to the unit, so values bunched high get an axis that starts -/// near them. -/// - All-negative values mirror this: the axis ends at zero when `hi` is above 5/6 of `lo`. -/// Mixed signs get headroom at both ends. -pub fn axis_scale( - lo: f64, - hi: f64, - fmin: Option, - fmax: Option, - major: Option, - max_intervals: usize, - headroom: bool, -) -> (f64, f64, f64) { - // Values past ±1e100 aren't axis values; clamping keeps the arithmetic finite. - let fin = |v: Option| 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) }; - // Office's automatic limits ("How chart axis limits are determined", as Jon Peltier - // summarises it): 5 % of the data range past the data, and zero when the values don't - // sit within the top sixth of their largest magnitude. A single value is measured from zero. - let flat = hi - lo < 1e-12; - let pad = if !headroom { - 0.0 - } else if flat { - hi.abs() / 20.0 - } else { - (hi - lo) / 20.0 - }; - let auto_lo = if lo >= 0.0 && (flat || lo < hi * 5.0 / 6.0) { 0.0 } else { lo - pad }; - let auto_hi = if hi <= 0.0 && (flat || hi > lo * 5.0 / 6.0) { 0.0 } else { hi + pad }; - let mut lo = fmin.unwrap_or(auto_lo); - let mut hi = fmax.unwrap_or(auto_hi); - if hi - lo < 1e-12 { - // A fixed limit on the far side of the data: the automatic end gives way. - match (fmin, fmax) { - (None, Some(_)) => lo = if hi > 0.0 { 0.0 } else { hi - hi.abs().max(1.0) }, - (Some(_), None) if lo < 0.0 => hi = 0.0, - _ => hi = lo + lo.abs().max(1.0), - } - } - 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) }; - let span = if hi - lo < 1e-12 { hi.abs().max(1.0) } else { hi - lo }; - let raw = span / 5.0; - let mag = 10f64.powf(raw.log10().floor()); - let norm = raw / mag; - let step = if norm <= 1.0 { - 1.0 - } else if norm <= 2.0 { - 2.0 - } else if norm <= 2.5 { - 2.5 - } else if norm <= 5.0 { - 5.0 - } else { - 10.0 - } * mag; - let min = (lo / step).floor() * step; - let max = (hi / step).ceil() * step; - let max = if (max - min).abs() < 1e-12 { min + step } else { max }; - (min, max, step) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn log_axes_run_over_whole_powers() { - let mut c = Chart::sample(ChartType::Line); - c.series.truncate(1); - c.series[0].values = vec![Some(1.0), Some(10.0), Some(1000.0), Some(50000.0)]; - assert_eq!(c.log_axis_scale(8), None); - c.y_log_base = Some(10.0); - assert_eq!(c.log_axis_scale(8), Some((0.0, 5.0, 1.0))); - assert_eq!(c.log_axis_scale(2), Some((0.0, 6.0, 3.0))); - } - - #[test] - fn error_bar_ranges() { - let vals = [Some(3.0), Some(5.0), Some(2.0), Some(4.0)]; - let mut e = ErrorBars::default(); - assert_eq!(e.range(&vals, 0, 3.0), Some((2.0, 4.0))); - e.val_type = "percentage".into(); - e.value = 20.0; - assert_eq!(e.range(&vals, 1, 5.0), Some((4.0, 6.0))); - e.bar_type = "plus".into(); - assert_eq!(e.range(&vals, 1, 5.0), Some((5.0, 6.0))); - e = ErrorBars { val_type: "cust".into(), plus: vec![Some(0.5)], minus: vec![Some(1.5)], ..Default::default() }; - assert_eq!(e.range(&vals, 0, 3.0), Some((1.5, 3.5))); - e = ErrorBars { val_type: "stdDev".into(), ..Default::default() }; - let (lo, hi) = e.range(&vals, 0, 3.0).unwrap(); - assert!((lo + hi - 7.0).abs() < 1e-9 && (hi - lo - 2.0 * 1.2909944487358056).abs() < 1e-9); - } - - #[test] - fn nice_scale_covers() { - let (a, b, s) = nice_scale(0.0, 5.1); - assert!(a <= 0.0 && b >= 5.1 && s > 0.0); - let (a, b, _) = nice_scale(f64::NAN, 3.0); - assert!(a.is_finite() && b.is_finite()); - let (a, b, _) = nice_scale(7.0, 7.0); - 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}"); - } - } - - fn column(values: &[f64]) -> Chart { - let series = Series { name: "Sales".into(), values: values.iter().map(|v| Some(*v)).collect(), ..Default::default() }; - Chart { categories: (0..values.len()).map(|i| format!("C{i}")).collect(), series: vec![series], ..Default::default() } - } - - /// The primary axis of a chart with room for ten intervals. - fn scale(c: &Chart) -> (f64, f64, f64) { - c.axis_scale(false, 10).unwrap_or_default() - } - - /// Office's documented automatic limits (#144), with room for ten intervals. - #[test] - fn automatic_axis_matches_office() { - // #144: 42.1 / 18.7 / 6.3 → 0–45 in steps of 5, not 0–50 in steps of 10. - assert_eq!(scale(&column(&[42.1, 18.7, 6.3])), (0.0, 45.0, 5.0)); - // PowerPoint's default chart data (max 5): 0–6 in steps of 1. - let mut c = column(&[4.3, 2.5, 3.5, 4.5]); - c.series.push(Series { values: [2.4, 4.4, 1.8, 2.8].map(Some).to_vec(), ..Default::default() }); - c.series.push(Series { values: [2.0, 2.0, 3.0, 5.0].map(Some).to_vec(), ..Default::default() }); - assert_eq!(scale(&c), (0.0, 6.0, 1.0)); - // A maximum of 100 (or 10) gets 5 % headroom, which rounds up to the next major unit. - assert_eq!(scale(&column(&[100.0, 40.0])), (0.0, 120.0, 20.0)); - assert_eq!(scale(&column(&[10.0, 3.0])), (0.0, 12.0, 2.0)); - // Peltier's example: 2.95 + 2.95/20 ≈ 3.1 rounds up to 3.5. - assert_eq!(scale(&column(&[2.95, 0.0])), (0.0, 3.5, 0.5)); - } - - #[test] - fn automatic_axis_minimum() { - // Below 5/6 of the maximum the axis starts at zero; at or above it, a unit below - // min − (max − min)/20. - assert_eq!(scale(&column(&[59.0, 49.0])).0, 0.0); - assert_eq!(scale(&column(&[59.0, 50.0])), (48.0, 60.0, 2.0)); - assert_eq!(scale(&column(&[85.0, 92.0, 97.0])), (84.0, 98.0, 2.0)); - // All-negative data mirrors it: the axis ends at zero, or near the data. - assert_eq!(scale(&column(&[-42.1, -18.7, -6.3])), (-45.0, 0.0, 5.0)); - assert_eq!(scale(&column(&[-59.0, -49.0])).1, 0.0); - assert_eq!(scale(&column(&[-59.0, -50.0])), (-60.0, -48.0, 2.0)); - // Mixed signs get headroom at both ends. - assert_eq!(scale(&column(&[42.1, -18.7])), (-30.0, 50.0, 10.0)); - // Stacked bars build up from zero, so their axis keeps it. - let mut s = column(&[85.0, 92.0]); - s.kind = ChartType::StackedColumn; - assert_eq!(scale(&s).0, 0.0); - // A combo chart's secondary axis follows the same rule. - let mut c = Chart::sample(ChartType::Combo); - c.series.truncate(2); - 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(7_000.0), Some(7_400.0)]; - c.series[1].secondary_axis = true; - assert_eq!(c.axis_scale(false, 10), Some((0.0, 350_000.0, 50_000.0))); - assert_eq!(c.axis_scale(true, 10), Some((6_950.0, 7_450.0, 50.0))); - } - - #[test] - fn fixed_axis_limits_are_kept() { - let mut c = column(&[42.1, 18.7, 6.3]); - c.y_max = Some(50.0); - assert_eq!(scale(&c), (0.0, 50.0, 5.0)); - c.y_min = Some(10.0); - c.y_max = None; - assert_eq!(scale(&c), (10.0, 45.0, 5.0)); - // A fixed limit beyond the data: the automatic end gives way to it. - let mut c = column(&[85.0, 92.0, 97.0]); - c.y_max = Some(50.0); - assert_eq!(scale(&c), (0.0, 50.0, 5.0)); - let mut c = column(&[-50.0, -48.0]); - c.y_min = Some(-10.0); - assert_eq!(scale(&c), (-10.0, 0.0, 1.0)); - } - - #[test] - fn percent_axis_is_zero_to_one() { - let mut p = column(&[42.1, 18.7, 6.3]); - p.kind = ChartType::PercentColumn; - assert_eq!(scale(&p), (0.0, 1.0, 0.1)); - assert_eq!(p.axis_scale(false, 5), Some((0.0, 1.0, 0.2))); - } - - #[test] - fn degenerate_axes_are_finite() { - for (a, b) in [(0.0, 0.0), (7.0, 7.0), (-7.0, -7.0), (f64::NAN, 3.0), (f64::INFINITY, 1.0), (-f64::MAX, f64::MAX), (0.0, 1e-320)] { - let (min, max, step) = scale(&column(&[a, b])); - assert!(min.is_finite() && max > min && step > 0.0, "{a} {b}: {min} {max} {step}"); - } - let (min, max, _) = scale(&column(&[7.0, 7.0])); - assert!(min == 0.0 && max > 7.0); - assert_eq!(scale(&column(&[])), (0.0, 1.0, 0.1)); - for (fmin, fmax) in [(Some(5.0), Some(5.0)), (Some(5.0), Some(1.0)), (Some(f64::NAN), Some(f64::INFINITY)), (Some(-1e300), None)] { - let (a, b, s) = axis_scale(0.0, 1.0, fmin, fmax, None, 10, true); - assert!(a.is_finite() && b.is_finite() && b > a && s > 0.0, "{fmin:?} {fmax:?}: {a} {b} {s}"); - } - } - - #[test] - fn general_labels_keep_every_digit_the_data_has() { - // #324: labels were rounded to one decimal ("100000.0" for 99999.99). - for kind in [ChartType::Column, ChartType::Pie, ChartType::Doughnut, ChartType::Line, ChartType::Scatter] { - let c = Chart::sample(kind); - let s = &c.series[0]; - for (v, want) in - [(1220.82, "1220.82"), (99999.99, "99999.99"), (1500.05, "1500.05"), (1500.0, "1500"), (1234.567, "1234.567"), (999.99, "999.99")] - { - assert_eq!(c.data_label(s, v), want, "{kind:?}"); - } - } - // Small ranges keep distinct value-axis ticks (0.123 used to give 0, 0.0, 0.1, 0.1…). - let c = Chart::sample(ChartType::Column); - let ticks: Vec = (0..=5).map(|i| c.axis_label(false, f64::from(i) * 0.025)).collect(); - assert_eq!(ticks, ["0", "0.025", "0.05", "0.075", "0.1", "0.125"]); - } - - #[test] - fn a_forecast_grows_the_value_axis() { - let mut c = Chart::sample(ChartType::Line); - c.series.truncate(1); - c.series[0].values = vec![Some(3.0), Some(5.0), Some(7.0), Some(9.0)]; - let before = c.value_range().1; - c.series[0].trendlines.push(Trendline { forward: Some(1.0), ..Default::default() }); - let after = c.value_range().1; - assert!(after > before && after >= 11.0 - 1e-6, "{before} {after}"); - } - - #[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 full_percent_axes_count_in_percent() { - let mut c = Chart::sample(ChartType::PercentColumn); - assert_eq!(c.axis_label(false, 0.5), "50%"); - c.y_format = Some("0.0%".into()); - assert_eq!(c.axis_label(false, 0.5), "50.0%"); - let c = Chart::sample(ChartType::Column); - assert_eq!(c.axis_label(false, 0.5), "0.5"); - } - - #[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); - let (_, hi) = c.value_range(); - assert!(hi > 10.0); - assert_eq!(Chart::sample(ChartType::Pie).series.len(), 1); - } -} - -/// One histogram bin: `[lo, hi]` (the first bin is closed on both sides, later ones open on the -/// left, as Excel bins) and how many observations fall in it. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Bin { - pub lo: f64, - pub hi: f64, - pub count: usize, -} - -/// Bins a histogram has at most (a hostile bin size cannot make millions). -pub const MAX_BINS: usize = 1000; - -/// A box and whisker summary of one group: quartiles by Excel's exclusive method, whiskers at -/// the furthest points within 1.5 IQR of the box, and the points beyond them. -#[derive(Clone, Debug, PartialEq)] -pub struct BoxStats { - pub low: f64, - pub q1: f64, - pub median: f64, - pub q3: f64, - pub high: f64, - pub mean: f64, - pub outliers: Vec, -} - -impl Chart { - /// The first series' finite observations. - fn observations(&self) -> Vec { - self.series.first().map(|s| s.values.iter().flatten().copied().filter(|v| v.is_finite()).collect()).unwrap_or_default() - } - - /// The histogram's bins over the first series: by `bin_size`, else `bin_count`, else - /// Scott's rule (3.49·σ·n^(-1/3)). Empty without observations. - pub fn histogram_bins(&self) -> Vec { - let mut v = self.observations(); - if v.is_empty() { - return vec![]; - } - v.sort_by(f64::total_cmp); - let (lo, hi) = (v[0], v[v.len() - 1]); - let span = hi - lo; - let n = v.len() as f64; - let width = if let Some(w) = self.bin_size.filter(|w| w.is_finite() && *w > 0.0) { - w - } else if let Some(k) = self.bin_count.filter(|k| *k > 0) { - span / f64::from(k) - } else { - let mean = v.iter().sum::() / n; - let sd = (v.iter().map(|x| (x - mean).powi(2)).sum::() / (n - 1.0).max(1.0)).sqrt(); - 3.49 * sd / n.cbrt() - }; - let count = if span <= 0.0 || !width.is_finite() || width <= 0.0 { 1 } else { ((span / width).ceil() as usize).clamp(1, MAX_BINS) }; - let width = if count == 1 && (span <= 0.0 || !width.is_finite() || width <= 0.0) { span.max(1.0) } else { width.max(span / MAX_BINS as f64) }; - let mut bins: Vec = (0..count).map(|i| Bin { lo: lo + width * i as f64, hi: lo + width * (i + 1) as f64, count: 0 }).collect(); - for x in v { - // (lo, hi] bins, the first one also holding the minimum. - let i = (((x - lo) / width).ceil() as usize).saturating_sub(1).min(count - 1); - if let Some(b) = bins.get_mut(i) { - b.count += 1; - } - } - bins - } - - /// A histogram (or Pareto) as the chart that draws it: a column chart of the bin counts with - /// touching columns, labelled by bin range. A Pareto sorts the bins by count and adds the - /// cumulative percentage as a line on a 0–100 % secondary axis. - pub fn histogram_view(&self) -> Chart { - let mut bins = self.histogram_bins(); - let pareto = self.kind == ChartType::Pareto; - if pareto { - bins.sort_by_key(|b| std::cmp::Reverse(b.count)); - } - let fmt = |v: f64| { - let r = (v * 100.0).round() / 100.0; - if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") } - }; - let first_lo = bins.iter().map(|b| b.lo).fold(f64::INFINITY, f64::min); - let mut v = Chart { - kind: if pareto { ChartType::Combo } else { ChartType::Column }, - categories: bins - .iter() - .map(|b| if b.lo <= first_lo { format!("[{}, {}]", fmt(b.lo), fmt(b.hi)) } else { format!("({}, {}]", fmt(b.lo), fmt(b.hi)) }) - .collect(), - series: vec![], - gap_width: 0.0, - raw: None, - subtotals: vec![], - ..self.clone() - }; - let name = self.series.first().map(|s| s.name.clone()).unwrap_or_default(); - let base = self.series.first().cloned().unwrap_or_default(); - v.series.push(Series { - name, - values: bins.iter().map(|b| Some(b.count as f64)).collect(), - kind: pareto.then_some(ChartType::Column), - number_format: None, - ..base - }); - if pareto { - let total = bins.iter().map(|b| b.count).sum::().max(1) as f64; - let mut run = 0.0; - v.series.push(Series { - name: "Cumulative %".into(), - values: bins - .iter() - .map(|b| { - run += b.count as f64; - Some(run / total) - }) - .collect(), - kind: Some(ChartType::Line), - secondary_axis: true, - ..Default::default() - }); - v.y2_min = Some(0.0); - v.y2_max = Some(1.0); - v.y2_major = Some(0.2); - v.y2_format = Some("0%".into()); - } - v.vary_colors = false; - v - } - - /// The box chart's groups (distinct category labels in order of appearance; one unnamed - /// group without categories) and, per series, each group's summary (None when it has no - /// points). - pub fn box_groups(&self) -> (Vec, Vec>>) { - let mut groups: Vec = Vec::new(); - for c in &self.categories { - if !groups.contains(c) { - groups.push(c.clone()); - } - } - if groups.is_empty() { - groups.push(String::new()); - } - let stats = self - .series - .iter() - .map(|s| { - groups - .iter() - .map(|g| { - let pts: Vec = s - .values - .iter() - .enumerate() - .filter(|(i, _)| self.categories.get(*i).map_or(g.is_empty() || self.categories.is_empty(), |c| c == g)) - .filter_map(|(_, v)| *v) - .filter(|v| v.is_finite()) - .collect(); - box_stats_by(pts, self.inclusive_quartiles) - }) - .collect() - }) - .collect(); - (groups, stats) - } -} - -/// Excel's QUARTILE.EXC position `p·(n+1)` (1-based) on sorted data, clamped to the ends. -fn quantile_exc(v: &[f64], p: f64) -> f64 { - let n = v.len(); - let pos = (p * (n as f64 + 1.0)).clamp(1.0, n as f64); - let i = pos.floor() as usize; - let frac = pos - i as f64; - let a = v[i.saturating_sub(1).min(n - 1)]; - let b = v[i.min(n - 1)]; - a + (b - a) * frac -} - -/// Excel's QUARTILE.INC position `p·(n-1)` (0-based) on sorted data. -fn quantile_inc(v: &[f64], p: f64) -> f64 { - let n = v.len(); - let pos = (p * (n as f64 - 1.0)).clamp(0.0, (n - 1) as f64); - let i = pos.floor() as usize; - let a = v[i.min(n - 1)]; - let b = v[(i + 1).min(n - 1)]; - a + (b - a) * (pos - i as f64) -} - -/// The summary of one group's points, with exclusive quartiles. -pub fn box_stats(pts: Vec) -> Option { - box_stats_by(pts, false) -} - -/// The summary of one group's points; `inclusive` picks QUARTILE.INC over QUARTILE.EXC. -pub fn box_stats_by(mut pts: Vec, inclusive: bool) -> Option { - if pts.is_empty() { - return None; - } - pts.sort_by(f64::total_cmp); - let q = |p: f64| if inclusive { quantile_inc(&pts, p) } else { quantile_exc(&pts, p) }; - let (q1, median, q3) = (q(0.25), q(0.5), q(0.75)); - let iqr = q3 - q1; - let (lo_fence, hi_fence) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr); - let inside: Vec = pts.iter().copied().filter(|v| *v >= lo_fence && *v <= hi_fence).collect(); - let low = inside.first().copied().unwrap_or(q1).min(q1); - let high = inside.last().copied().unwrap_or(q3).max(q3); - let mean = pts.iter().sum::() / pts.len() as f64; - let outliers = pts.iter().copied().filter(|v| *v < lo_fence || *v > hi_fence).collect(); - Some(BoxStats { low, q1, median, q3, high, mean, outliers }) -} - -/// A node of a treemap or sunburst hierarchy: its label, the total of the leaves under it, and -/// its children (none for a leaf, which names its category index). -#[derive(Clone, Debug, PartialEq)] -pub struct TreeNode { - pub name: String, - pub value: f64, - pub leaf: Option, - pub children: Vec, -} - -/// Levels of hierarchy a chart may have (a hostile file cannot nest deeper). -pub const MAX_LEVELS: usize = 16; - -impl Chart { - /// The first series as a hierarchy: top-level nodes, each holding the consecutive - /// categories that share its label, down to the leaves. Without outer levels every - /// category is a top-level leaf. Negative and missing values count as zero. - pub fn category_tree(&self) -> Vec { - let Some(s) = self.series.first() else { return vec![] }; - let n = self.categories.len().max(s.values.len()); - let levels: Vec<&Vec> = self.category_levels.iter().take(MAX_LEVELS).collect(); - // Each leaf's path from the top level down, blanks filled from the row above. - let mut last: Vec = vec![String::new(); levels.len()]; - let paths: Vec> = (0..n) - .map(|i| { - let mut path = Vec::with_capacity(levels.len() + 1); - for (d, lv) in levels.iter().enumerate().rev() { - let l = lv.get(i).map(|x| x.trim()).unwrap_or(""); - if !l.is_empty() { - last[d] = l.to_string(); - // A new outer label starts fresh inner groups. - for inner in last.iter_mut().take(d) { - inner.clear(); - } - } - path.push(last[d].clone()); - } - path.push(self.categories.get(i).cloned().unwrap_or_default()); - path - }) - .collect(); - let value = |i: usize| s.values.get(i).copied().flatten().filter(|v| v.is_finite()).unwrap_or(0.0).max(0.0); - fn build(rows: &[(usize, &[String])], value: &dyn Fn(usize) -> f64) -> Vec { - let mut out: Vec = Vec::new(); - let mut i = 0; - while i < rows.len() { - let (idx, path) = rows[i]; - if path.len() <= 1 { - out.push(TreeNode { name: path.first().cloned().unwrap_or_default(), value: value(idx), leaf: Some(idx), children: vec![] }); - i += 1; - continue; - } - let head = &path[0]; - let mut j = i; - while j < rows.len() && rows[j].1.len() > 1 && &rows[j].1[0] == head { - j += 1; - } - let sub: Vec<(usize, &[String])> = rows[i..j].iter().map(|(k, p)| (*k, &p[1..])).collect(); - let children = build(&sub, value); - out.push(TreeNode { name: head.clone(), value: children.iter().map(|c| c.value).sum(), leaf: None, children }); - i = j; - } - out - } - let rows: Vec<(usize, &[String])> = paths.iter().enumerate().map(|(i, p)| (i, p.as_slice())).collect(); - build(&rows, &value) - } -} - -#[cfg(test)] -mod chartex_tests { - use super::*; - - fn obs(v: &[f64]) -> Chart { - Chart { - kind: ChartType::Histogram, - series: vec![Series { values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }], - ..Default::default() - } - } - - #[test] - fn histogram_bins_by_count_size_and_scott() { - let mut c = obs(&[1.0, 2.0, 2.0, 3.0, 4.0, 5.0, 9.0]); - c.bin_count = Some(4); - let b = c.histogram_bins(); - assert_eq!(b.len(), 4); - assert_eq!((b[0].lo, b[0].hi), (1.0, 3.0)); - // (lo, hi] bins with the minimum in the first: 1, 2, 2, 3 | 4, 5 | - | 9. - assert_eq!(b.iter().map(|b| b.count).collect::>(), vec![4, 2, 0, 1]); - c.bin_count = None; - c.bin_size = Some(5.0); - assert_eq!(c.histogram_bins().iter().map(|b| b.count).collect::>(), vec![6, 1]); - c.bin_size = None; - let auto = c.histogram_bins(); - assert!(!auto.is_empty() && auto.iter().map(|b| b.count).sum::() == 7); - // A tiny bin size is capped; one value gives one bin. - c.bin_size = Some(1e-12); - assert!(c.histogram_bins().len() <= MAX_BINS); - assert_eq!(obs(&[3.0]).histogram_bins(), vec![Bin { lo: 3.0, hi: 4.0, count: 1 }]); - assert!(obs(&[]).histogram_bins().is_empty()); - } - - #[test] - fn pareto_view_sorts_bins_and_adds_the_cumulative_line() { - let mut c = obs(&[1.0, 5.0, 5.0, 5.0, 9.0, 9.0]); - c.kind = ChartType::Pareto; - c.bin_count = Some(3); - let v = c.histogram_view(); - assert_eq!(v.kind, ChartType::Combo); - assert_eq!(v.series[0].values, vec![Some(3.0), Some(2.0), Some(1.0)]); - let cum = &v.series[1]; - assert!(cum.secondary_axis && cum.kind == Some(ChartType::Line)); - assert_eq!(cum.values.last(), Some(&Some(1.0))); - assert_eq!(v.categories[0], "(3.67, 6.33]"); - } - - #[test] - fn box_stats_use_exclusive_quartiles_and_fence_outliers() { - let b = box_stats(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0]).unwrap(); - // QUARTILE.EXC on 8 points: positions 2.25, 4.5, 6.75. - assert_eq!((b.q1, b.median, b.q3), (2.25, 4.5, 6.75)); - assert_eq!(b.outliers, vec![100.0]); - assert_eq!((b.low, b.high), (1.0, 7.0)); - assert!(box_stats(vec![]).is_none()); - let one = box_stats(vec![5.0]).unwrap(); - assert_eq!((one.low, one.q1, one.median, one.q3, one.high), (5.0, 5.0, 5.0, 5.0, 5.0)); - } - - #[test] - fn inclusive_quartiles_follow_quartile_inc() { - let b = box_stats_by(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0], true).unwrap(); - // QUARTILE.INC on 8 points: positions 1.75, 3.5, 5.25 (0-based). - assert_eq!((b.q1, b.median, b.q3), (2.75, 4.5, 6.25)); - } - - #[test] - fn category_tree_groups_runs_and_fills_blank_parents() { - let mut c = Chart::sample(ChartType::Treemap); - let t = c.category_tree(); - assert_eq!(t.iter().map(|n| n.name.as_str()).collect::>(), ["Q1", "Q2", "Q3"]); - assert_eq!(t[0].children.len(), 3); - assert_eq!(t[0].children[1].leaf, Some(1)); - assert!((t[0].value - (4.2 + 2.8 + 3.6)).abs() < 1e-9); - // Blank parents continue the label above; two outer levels nest. - c.category_levels = vec![ - vec!["a".into(), "".into(), "b".into(), "".into(), "c".into(), "".into(), "".into(), "".into()], - vec!["H1".into(), "".into(), "".into(), "".into(), "H2".into(), "".into(), "".into(), "".into()], - ]; - let t = c.category_tree(); - assert_eq!(t.len(), 2); - assert_eq!(t[0].children.iter().map(|n| n.name.as_str()).collect::>(), ["a", "b"]); - assert_eq!(t[1].children[0].children.len(), 4); - // No levels: every category is a leaf. - c.category_levels.clear(); - assert!(c.category_tree().iter().all(|n| n.leaf.is_some())); - } - - #[test] - fn box_groups_follow_category_labels() { - let c = Chart::sample(ChartType::BoxWhisker); - let (groups, stats) = c.box_groups(); - assert_eq!(groups, vec!["Group A".to_string(), "Group B".to_string()]); - assert_eq!(stats.len(), 2); - assert!(stats.iter().all(|s| s.len() == 2 && s.iter().all(Option::is_some))); - } -} +//! Charts: an embedded data grid plus type and element options. + +use serde::{Deserialize, Serialize}; + +use crate::style::{ColorRef, Fill, Line}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ChartType { + #[default] + Column, + StackedColumn, + PercentColumn, + Bar, + StackedBar, + PercentBar, + Line, + LineMarkers, + StackedLine, + Area, + StackedArea, + Pie, + Doughnut, + Scatter, + Bubble, + Radar, + FilledRadar, + Stock, + Surface, + Histogram, + Pareto, + BoxWhisker, + Waterfall, + Funnel, + Treemap, + Sunburst, + /// A filled (choropleth) map of regions coloured by value, kept and drawn from its data; it + /// is not offered in the chart menus (PowerPoint needs an online map service to make one). + RegionMap, + Combo, +} + +impl ChartType { + /// The Office 2016 (`chartEx`) types, offered after [`ChartType::MAIN`] in chart menus. + pub const OFFICE_2016: [ChartType; 7] = [ + ChartType::Waterfall, + ChartType::Funnel, + ChartType::Histogram, + ChartType::Pareto, + ChartType::BoxWhisker, + ChartType::Treemap, + ChartType::Sunburst, + ]; + + /// Whether the type is saved as a `chartEx` part (with a plain chart as the fallback). + pub fn is_chartex(self) -> bool { + Self::OFFICE_2016.contains(&self) || self == ChartType::RegionMap + } + + pub const MAIN: [ChartType; 12] = [ + ChartType::Column, + ChartType::Bar, + ChartType::Line, + ChartType::Area, + ChartType::Pie, + ChartType::Doughnut, + ChartType::Scatter, + ChartType::Radar, + ChartType::Waterfall, + ChartType::Funnel, + ChartType::Histogram, + ChartType::Treemap, + ]; + pub fn label(self) -> &'static str { + match self { + ChartType::Column => "Clustered Column", + ChartType::StackedColumn => "Stacked Column", + ChartType::PercentColumn => "100% Stacked Column", + ChartType::Bar => "Clustered Bar", + ChartType::StackedBar => "Stacked Bar", + ChartType::PercentBar => "100% Stacked Bar", + ChartType::Line => "Line", + ChartType::LineMarkers => "Line with Markers", + ChartType::StackedLine => "Stacked Line", + ChartType::Area => "Area", + ChartType::StackedArea => "Stacked Area", + ChartType::Pie => "Pie", + ChartType::Doughnut => "Doughnut", + ChartType::Scatter => "Scatter", + ChartType::Bubble => "Bubble", + ChartType::Radar => "Radar", + ChartType::FilledRadar => "Filled Radar", + ChartType::Stock => "Stock", + ChartType::Surface => "Surface", + ChartType::Histogram => "Histogram", + ChartType::Pareto => "Pareto", + ChartType::BoxWhisker => "Box and Whisker", + ChartType::Waterfall => "Waterfall", + ChartType::Funnel => "Funnel", + ChartType::Treemap => "Treemap", + ChartType::Sunburst => "Sunburst", + ChartType::RegionMap => "Map", + ChartType::Combo => "Combo", + } + } + pub fn is_bar_like(self) -> bool { + matches!( + self, + ChartType::Column + | ChartType::StackedColumn + | ChartType::PercentColumn + | ChartType::Bar + | ChartType::StackedBar + | ChartType::PercentBar + | ChartType::Histogram + | ChartType::Pareto + | ChartType::Waterfall + ) + } + pub fn horizontal(self) -> bool { + matches!(self, ChartType::Bar | ChartType::StackedBar | ChartType::PercentBar | ChartType::Funnel) + } + pub fn stacked(self) -> bool { + matches!( + self, + ChartType::StackedColumn + | ChartType::StackedBar + | ChartType::StackedLine + | ChartType::StackedArea + | ChartType::PercentColumn + | ChartType::PercentBar + ) + } + pub fn percent(self) -> bool { + matches!(self, ChartType::PercentColumn | ChartType::PercentBar) + } +} + +/// `c:trendlineType`: the six trendlines DrawingML charts store. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TrendlineType { + Exp, + #[default] + Linear, + Log, + MovingAvg, + Poly, + Power, +} + +impl TrendlineType { + pub fn xml(self) -> &'static str { + match self { + TrendlineType::Exp => "exp", + TrendlineType::Linear => "linear", + TrendlineType::Log => "log", + TrendlineType::MovingAvg => "movingAvg", + TrendlineType::Poly => "poly", + TrendlineType::Power => "power", + } + } + pub fn from_xml(s: &str) -> Option { + Some(match s { + "exp" => TrendlineType::Exp, + "linear" => TrendlineType::Linear, + "log" => TrendlineType::Log, + "movingAvg" => TrendlineType::MovingAvg, + "poly" => TrendlineType::Poly, + "power" => TrendlineType::Power, + _ => return None, + }) + } +} + +/// One `c:trendline` on a series. Polynomials use `order` (2–6); moving averages use `period`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Trendline { + pub kind: TrendlineType, + /// `c:name`, when the file sets one. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Polynomial order (`c:order`). + #[serde(skip_serializing_if = "Option::is_none")] + pub order: Option, + /// Moving-average period (`c:period`). + #[serde(skip_serializing_if = "Option::is_none")] + pub period: Option, + /// Forecast forward and backward, in category units (`c:forward`, `c:backward`). + #[serde(skip_serializing_if = "Option::is_none")] + pub forward: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub backward: Option, + /// Cross the value axis at this value (`c:intercept`). + #[serde(skip_serializing_if = "Option::is_none")] + pub intercept: Option, + /// Show the equation (`c:dispEq`). + pub disp_eq: bool, + /// Show R² (`c:dispRSqr`). + pub disp_r_sqr: bool, +} + +impl Default for Trendline { + fn default() -> Self { + Trendline { + kind: TrendlineType::Linear, + name: None, + order: None, + period: None, + forward: None, + backward: None, + intercept: None, + disp_eq: false, + disp_r_sqr: false, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Series { + pub name: String, + pub values: Vec>, + /// X values for scatter/bubble. + pub x_values: Vec>, + pub sizes: Vec>, + pub fill: Option, + pub line: Option, + /// Per-point fill overrides (pie slices). + pub point_fills: Vec<(usize, Fill)>, + pub data_labels: Option, + pub smooth: bool, + pub marker: Option, + /// Combo charts: this series' own type. + pub kind: Option, + 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, + /// Trendlines (`c:trendline`), in file order. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub trendlines: Vec, + /// `c:errBars` on the values (y bars; a bar chart's run along its value axis). + #[serde(skip_serializing_if = "Option::is_none")] + pub error_bars: Option, +} + +/// Error bars (`c:errBars`): how far each point's bar reaches below and above it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ErrorBars { + /// `both`, `plus` or `minus`. + pub bar_type: String, + /// `fixedVal`, `percentage`, `stdDev`, `stdErr` or `cust`. + pub val_type: String, + pub value: f64, + /// `cust`: the per-point plus and minus amounts. + pub plus: Vec>, + pub minus: Vec>, + pub end_cap: bool, +} + +impl Default for ErrorBars { + fn default() -> Self { + ErrorBars { bar_type: "both".into(), val_type: "fixedVal".into(), value: 1.0, plus: vec![], minus: vec![], end_cap: true } + } +} + +/// Count, mean and sample standard deviation of a series' finite values (for error bars). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SeriesStats { + pub n: f64, + pub mean: f64, + pub sd: f64, +} + +impl SeriesStats { + pub fn of(values: &[Option]) -> Self { + let vals = || values.iter().flatten().copied().filter(|v| v.is_finite()); + let n = vals().count() as f64; + let mean = vals().sum::() / n.max(1.0); + let sd = if n > 1.0 { (vals().map(|x| (x - mean).powi(2)).sum::() / (n - 1.0)).sqrt() } else { 0.0 }; + SeriesStats { n, mean, sd } + } +} + +impl ErrorBars { + /// Where point `i` (value `v`) of a series with `values` has its bar: (low, high) in data units. + /// Standard deviation bars stand around the series' mean, as Excel draws them; the others + /// around the point. `None` for an amount that isn't a finite number. + pub fn range(&self, values: &[Option], i: usize, v: f64) -> Option<(f64, f64)> { + self.range_in(&SeriesStats::of(values), i, v) + } + + /// [`ErrorBars::range`] with the series' statistics worked out once ([`SeriesStats::of`]), so + /// drawing every point of a long series stays linear. + pub fn range_in(&self, stats: &SeriesStats, i: usize, v: f64) -> Option<(f64, f64)> { + let SeriesStats { n, mean, sd } = *stats; + let (centre, minus, plus) = match self.val_type.as_str() { + "percentage" => (v, (v * self.value / 100.0).abs(), (v * self.value / 100.0).abs()), + "stdDev" => (mean, sd * self.value, sd * self.value), + "stdErr" => (v, sd / n.max(1.0).sqrt(), sd / n.max(1.0).sqrt()), + "cust" => (v, self.minus.get(i).copied().flatten().unwrap_or(0.0), self.plus.get(i).copied().flatten().unwrap_or(0.0)), + _ => (v, self.value, self.value), + }; + let (lo, hi) = match self.bar_type.as_str() { + "plus" => (centre, centre + plus), + "minus" => (centre - minus, centre), + _ => (centre - minus, centre + plus), + }; + (lo.is_finite() && hi.is_finite()).then_some((lo, hi)) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Chart { + pub kind: ChartType, + pub title: Option, + pub categories: Vec, + pub series: Vec, + pub legend: Option, + /// `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, + /// Title text size in points (its rich text or `c:txPr`). + #[serde(skip_serializing_if = "Option::is_none")] + pub title_size: Option, + pub data_labels: bool, + pub gridlines: bool, + /// `c:minorGridlines` on the value axis: five to a major unit, between the major ones. + pub minor_gridlines: bool, + pub axis_titles: (Option, Option), + /// Chart style number (1–48) and colour palette (`colorful1`, `monochrome3`…). + pub style: u32, + pub palette: String, + pub plot_fill: Option, + pub chart_fill: Option, + pub text_color: Option, + pub hole_size: f64, + pub gap_width: f64, + pub overlap: f64, + pub vary_colors: bool, + pub y_min: Option, + pub y_max: Option, + /// The value axis' major unit, when the chart fixes it. + pub y_major: Option, + /// `c:scaling/c:logBase`: the value axis is logarithmic in this base (2–1000). + pub y_log_base: Option, + /// The value axis' number format (an Excel format code such as `#,##0` or `0.0%`). + pub y_format: Option, + /// The value axis is deleted (`c:delete`): no labels, though gridlines may stay. + pub y_hidden: bool, + /// The category axis runs backwards (`c:orientation val="maxMin"`): right to left, or on a + /// horizontal bar chart top to bottom (the default puts the first category at the bottom). + pub cat_reversed: bool, + /// A combo chart's secondary value axis: bounds, major unit and number format. + pub y2_min: Option, + pub y2_max: Option, + pub y2_major: Option, + pub y2_format: Option, + pub y2_hidden: bool, + /// The chart's language (`c:lang`, e.g. `ru-RU`): digit grouping and decimal mark. + pub lang: Option, + /// A waterfall's total points (`cx:subtotals`): bars that stand on the axis and restart + /// the running total, instead of floating. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub subtotals: Vec, + /// Outer category levels of a treemap or sunburst, innermost first (the leaves' parents, + /// then theirs…), each with one label per category. A blank label continues the one above it. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub category_levels: Vec>, + /// Box and whisker quartiles by the inclusive method (QUARTILE.INC) instead of exclusive. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub inclusive_quartiles: bool, + /// A stock chart's up/down bars (`c:upDownBars`): open-high-low-close, the box between + /// the first and last series, hollow when the price rose. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub up_down_bars: bool, + /// A histogram's bin count (`cx:binCount`); with neither this nor `bin_size` the bins are + /// automatic (Scott's rule, as Excel does). + #[serde(skip_serializing_if = "Option::is_none")] + pub bin_count: Option, + /// A histogram's bin width (`cx:binSize`). + #[serde(skip_serializing_if = "Option::is_none")] + pub bin_size: Option, + /// Original chart XML part kept for round-trip of unmodelled options. + #[serde(skip_serializing_if = "Option::is_none")] + pub raw: Option, + /// Package parts the original chart still points at (embedded workbook, styles, drawings). + /// The PPTX writer clears this before comparing modelled state. Native saves keep it too. + #[serde(skip_serializing_if = "ChartKept::is_empty")] + pub kept: ChartKept, +} + +/// One relationship of a chart part, with the target string as it was written. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChartRel { + pub id: String, + pub rel_type: String, + pub target: String, + /// Absolute name of the part an internal relationship reaches (matches a `ChartPart::name` + /// up to case); empty for external ones. The writer renames kept parts and uses this to + /// point the relationship at the new name. + pub part: String, + pub external: bool, +} + +/// A part reached from a chart (the workbook, a drawing, an image it uses). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChartPart { + /// Absolute part name (`ppt/embeddings/Workbook.xlsx`). + pub name: String, + pub content_type: String, + /// Runtime bytes; older native files stored these inline in the manifest. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub bytes: Vec, + /// Native ZIP member while encoding/decoding. The native reader consumes this reference. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native_entry: Option, +} + +/// Sidecar for an unchanged chart: relationships and the parts they reach. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ChartKept { + pub rels: Vec, + pub parts: Vec, +} + +impl ChartKept { + fn is_empty(&self) -> bool { + self.rels.is_empty() && self.parts.is_empty() + } +} + +impl Default for Chart { + fn default() -> Self { + Chart { + kind: ChartType::Column, + title: Some("Chart Title".into()), + categories: vec![], + series: vec![], + legend: Some("b".into()), + legend_overlay: false, + text_size: None, + title_size: None, + data_labels: false, + gridlines: true, + minor_gridlines: false, + axis_titles: (None, None), + style: 2, + palette: "colorful1".into(), + plot_fill: None, + chart_fill: None, + text_color: None, + hole_size: 0.5, + gap_width: 2.19, + overlap: -0.27, + vary_colors: false, + y_min: None, + y_max: None, + y_major: None, + y_log_base: None, + y_format: None, + y_hidden: false, + cat_reversed: false, + y2_min: None, + y2_max: None, + y2_major: None, + y2_format: None, + y2_hidden: false, + lang: None, + subtotals: vec![], + category_levels: vec![], + inclusive_quartiles: false, + up_down_bars: false, + bin_count: None, + bin_size: None, + raw: None, + kept: ChartKept::default(), + } + } +} + +impl Chart { + /// The sample data a new chart starts with (our own numbers). + pub fn sample(kind: ChartType) -> Self { + let cats = ["Q1", "Q2", "Q3", "Q4"]; + let data = [[4.2, 2.6, 2.1], [2.8, 4.1, 2.4], [3.6, 1.9, 3.3], [4.7, 3.0, 5.1]]; + let mut c = Chart { kind, categories: cats.iter().map(|s| s.to_string()).collect(), ..Default::default() }; + if matches!(kind, ChartType::Treemap | ChartType::Sunburst) { + // A two-level hierarchy: months inside quarters. + c.categories = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"].iter().map(|s| s.to_string()).collect(); + c.category_levels = vec![["Q1", "Q1", "Q1", "Q2", "Q2", "Q2", "Q3", "Q3"].iter().map(|s| s.to_string()).collect()]; + let v = [4.2, 2.8, 3.6, 4.7, 2.6, 4.1, 1.9, 3.0]; + c.series.push(Series { name: "Series 1".into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }); + c.vary_colors = true; + return c; + } + if matches!(kind, ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) { + // These plot raw observations: a histogram bins one series, a box chart summarises + // each category's points. + let obs = [12.0, 15.0, 17.0, 18.0, 21.0, 22.0, 22.0, 24.0, 25.0, 27.0, 28.0, 31.0, 33.0, 36.0, 41.0, 48.0]; + let n_series = if kind == ChartType::BoxWhisker { 2 } else { 1 }; + c.categories = if kind == ChartType::BoxWhisker { + (0..obs.len()).map(|i| if i % 2 == 0 { "Group A" } else { "Group B" }.to_string()).collect() + } else { + vec![] + }; + for s in 0..n_series { + c.series.push(Series { + name: format!("Series {}", s + 1), + values: obs.iter().map(|v| Some(v + s as f64 * 6.0)).collect(), + ..Default::default() + }); + } + c.gridlines = true; + c.legend = (kind == ChartType::BoxWhisker).then(|| "b".to_string()); + return c; + } + if kind == ChartType::Stock { + // High-low-close over five days, the series in the order PowerPoint expects. + c.categories = ["Mon", "Tue", "Wed", "Thu", "Fri"].iter().map(|s| s.to_string()).collect(); + let hlc = [("High", [55.0, 57.0, 57.0, 58.0, 58.0]), ("Low", [11.0, 12.0, 13.0, 11.0, 25.0]), ("Close", [25.0, 38.0, 50.0, 35.0, 43.0])]; + for (name, v) in hlc { + c.series.push(Series { name: name.into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }); + } + return c; + } + let n_series = + if matches!(kind, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Waterfall | ChartType::Treemap) { 1 } else { 3 }; + for s in 0..n_series { + c.series.push(Series { + name: format!("Series {}", s + 1), + values: data.iter().map(|row| row.get(s).copied()).collect(), + x_values: if kind == ChartType::Scatter { (1..=4).map(|x| Some(x as f64)).collect() } else { vec![] }, + ..Default::default() + }); + } + c.vary_colors = n_series == 1; + if kind == ChartType::Combo { + // Columns with the last series as a line, as PowerPoint's combo starts. + for (i, s) in c.series.iter_mut().enumerate() { + s.kind = Some(if i + 1 == n_series { ChartType::Line } else { ChartType::Column }); + } + } + 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 (lo, hi) = self.plotted_extent(secondary)?; + let (lo, hi) = (lo.min(0.0), hi.max(0.0)); + Some(if secondary { (lo, hi) } else { (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) }) + } + + /// The smallest and largest value plotted against one value axis: plain values as they are, + /// stacked series as running sums from zero, 100 % series as 0–1. `(0, 0)` without finite + /// values; `None` when no series uses the axis. + fn plotted_extent(&self, secondary: bool) -> Option<(f64, f64)> { + if self.kind == ChartType::Waterfall { + 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 (!secondary && self.kind != ChartType::Combo).then_some((0.0, 0.0)); + } + let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); + 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, mut stacked) = (0.0, 0.0, false); + 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() { + stacked = true; + if v >= 0.0 { p += v } else { m += v } + } else { + hi = hi.max(v); + lo = lo.min(v); + } + } + if stacked { + hi = hi.max(p); + lo = lo.min(m); + } + } + if !percent { + for s in &ser { + if self.series_kind(s).stacked() { + continue; + } + for y in s.trendline_ys(crate::trendline::series_is_scatter(self.series_kind(s))) { + hi = hi.max(y); + lo = lo.min(y); + } + } + } + if percent { + lo = lo.min(0.0); + hi = hi.max(1.0); + } + Some(if lo <= hi { (lo, hi) } else { (0.0, 0.0) }) + } + + /// A waterfall's bars as (from, to) on the value axis: each point floats from the running + /// total, a total point stands on zero and sets it. + pub fn waterfall_bars(&self) -> Vec<(f64, f64)> { + let Some(s) = self.series.first() else { return vec![] }; + let mut run = 0.0; + s.values + .iter() + .enumerate() + .map(|(i, v)| { + let v = v.unwrap_or(0.0); + if self.subtotals.contains(&i) { + run = v; + (0.0, v) + } else { + let from = run; + run += v; + (from, run) + } + }) + .collect() + } + + /// A value axis' scale — (min, max, major unit) — with at most `max_intervals` intervals: + /// the bounds and unit the chart fixes, else Office's automatic ones ([`axis_scale`](fn@axis_scale)). + /// `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.plotted_extent(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 logarithmic value axis' scale in exponents — (min, max, unit), so its labels are + /// `base^min`, `base^(min+unit)`… — covering the positive values: whole powers, as PowerPoint + /// and LibreOffice draw them. `None` when the axis isn't logarithmic or nothing is positive. + pub fn log_axis_scale(&self, max_intervals: usize) -> Option<(f64, f64, f64)> { + let b = self.y_log_base.filter(|b| b.is_finite() && *b >= 2.0)?; + let vals = self.series.iter().filter(|s| !self.on_secondary(s)).flat_map(|s| s.values.iter().flatten().copied()); + let (lo, hi) = vals + .filter(|v| v.is_finite() && *v > 0.0) + .fold(None, |r: Option<(f64, f64)>, v| Some(r.map_or((v, v), |(a, z)| (a.min(v), z.max(v)))))?; + let e = |v: f64| v.log(b); + let lo = self.y_min.filter(|v| *v > 0.0).map_or(e(lo).floor(), e); + let hi = self.y_max.filter(|v| *v > 0.0).map_or(e(hi).ceil(), e).max(lo + 1.0); + let unit = ((hi - lo) / max_intervals.max(1) as f64).ceil().max(1.0); + Some((lo, lo + ((hi - lo) / unit).ceil() * unit, unit)) + } + + /// 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 }; + // A 100 % stacked chart's value axis counts in percent unless the file says otherwise. + let code = match code.as_deref() { + None | Some("General") if !secondary && self.kind.percent() => "0%", + c => c.unwrap_or("General"), + }; + crate::numfmt::format(code, 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; + let mut hi: f64 = 0.0; + if self.kind == ChartType::Waterfall { + for (a, b) in self.waterfall_bars() { + lo = lo.min(a.min(b)); + hi = hi.max(a.max(b)); + } + return (lo, hi); + } + let n = self.categories.len().max(self.series.iter().map(|s| s.values.len()).max().unwrap_or(0)); + if self.kind.stacked() { + for i in 0..n { + let (mut p, mut m) = (0.0, 0.0); + for s in &self.series { + let v = s.values.get(i).copied().flatten().unwrap_or(0.0); + if v >= 0.0 { p += v } else { m += v } + } + hi = hi.max(p); + lo = lo.min(m); + } + } else { + for s in &self.series { + for v in s.values.iter().flatten() { + if v.is_finite() { + hi = hi.max(*v); + lo = lo.min(*v); + } + } + } + } + if self.kind.percent() { + return (0.0, 1.0); + } + if !self.kind.stacked() { + for s in &self.series { + for y in s.trendline_ys(crate::trendline::series_is_scatter(self.series_kind(s))) { + hi = hi.max(y); + lo = lo.min(y); + } + } + } + (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) + } +} + +/// The (min, max, major unit) of an axis over the data range [lo, hi]: `fmin`/`fmax`/`major` +/// where the chart fixes them; otherwise Office's automatic limits, rounded out to the smallest +/// 1-2-5 unit giving at most `max_intervals` intervals: +/// +/// - **Maximum:** `hi + (hi − lo)/20` (with `headroom`), rounded up to the unit. +/// - **Minimum:** zero when every value is ≥ 0 and `lo` is below 5/6 of `hi`; otherwise +/// `lo − (hi − lo)/20`, rounded down to the unit, so values bunched high get an axis that starts +/// near them. +/// - All-negative values mirror this: the axis ends at zero when `hi` is above 5/6 of `lo`. +/// Mixed signs get headroom at both ends. +pub fn axis_scale( + lo: f64, + hi: f64, + fmin: Option, + fmax: Option, + major: Option, + max_intervals: usize, + headroom: bool, +) -> (f64, f64, f64) { + // Values past ±1e100 aren't axis values; clamping keeps the arithmetic finite. + let fin = |v: Option| 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) }; + // Office's automatic limits ("How chart axis limits are determined", as Jon Peltier + // summarises it): 5 % of the data range past the data, and zero when the values don't + // sit within the top sixth of their largest magnitude. A single value is measured from zero. + let flat = hi - lo < 1e-12; + let pad = if !headroom { + 0.0 + } else if flat { + hi.abs() / 20.0 + } else { + (hi - lo) / 20.0 + }; + let auto_lo = if lo >= 0.0 && (flat || lo < hi * 5.0 / 6.0) { 0.0 } else { lo - pad }; + let auto_hi = if hi <= 0.0 && (flat || hi > lo * 5.0 / 6.0) { 0.0 } else { hi + pad }; + let mut lo = fmin.unwrap_or(auto_lo); + let mut hi = fmax.unwrap_or(auto_hi); + if hi - lo < 1e-12 { + // A fixed limit on the far side of the data: the automatic end gives way. + match (fmin, fmax) { + (None, Some(_)) => lo = if hi > 0.0 { 0.0 } else { hi - hi.abs().max(1.0) }, + (Some(_), None) if lo < 0.0 => hi = 0.0, + _ => hi = lo + lo.abs().max(1.0), + } + } + 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) }; + let span = if hi - lo < 1e-12 { hi.abs().max(1.0) } else { hi - lo }; + let raw = span / 5.0; + let mag = 10f64.powf(raw.log10().floor()); + let norm = raw / mag; + let step = if norm <= 1.0 { + 1.0 + } else if norm <= 2.0 { + 2.0 + } else if norm <= 2.5 { + 2.5 + } else if norm <= 5.0 { + 5.0 + } else { + 10.0 + } * mag; + let min = (lo / step).floor() * step; + let max = (hi / step).ceil() * step; + let max = if (max - min).abs() < 1e-12 { min + step } else { max }; + (min, max, step) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn log_axes_run_over_whole_powers() { + let mut c = Chart::sample(ChartType::Line); + c.series.truncate(1); + c.series[0].values = vec![Some(1.0), Some(10.0), Some(1000.0), Some(50000.0)]; + assert_eq!(c.log_axis_scale(8), None); + c.y_log_base = Some(10.0); + assert_eq!(c.log_axis_scale(8), Some((0.0, 5.0, 1.0))); + assert_eq!(c.log_axis_scale(2), Some((0.0, 6.0, 3.0))); + } + + #[test] + fn error_bar_ranges() { + let vals = [Some(3.0), Some(5.0), Some(2.0), Some(4.0)]; + let mut e = ErrorBars::default(); + assert_eq!(e.range(&vals, 0, 3.0), Some((2.0, 4.0))); + e.val_type = "percentage".into(); + e.value = 20.0; + assert_eq!(e.range(&vals, 1, 5.0), Some((4.0, 6.0))); + e.bar_type = "plus".into(); + assert_eq!(e.range(&vals, 1, 5.0), Some((5.0, 6.0))); + e = ErrorBars { val_type: "cust".into(), plus: vec![Some(0.5)], minus: vec![Some(1.5)], ..Default::default() }; + assert_eq!(e.range(&vals, 0, 3.0), Some((1.5, 3.5))); + e = ErrorBars { val_type: "stdDev".into(), ..Default::default() }; + let (lo, hi) = e.range(&vals, 0, 3.0).unwrap(); + assert!((lo + hi - 7.0).abs() < 1e-9 && (hi - lo - 2.0 * 1.2909944487358056).abs() < 1e-9); + } + + #[test] + fn nice_scale_covers() { + let (a, b, s) = nice_scale(0.0, 5.1); + assert!(a <= 0.0 && b >= 5.1 && s > 0.0); + let (a, b, _) = nice_scale(f64::NAN, 3.0); + assert!(a.is_finite() && b.is_finite()); + let (a, b, _) = nice_scale(7.0, 7.0); + 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}"); + } + } + + fn column(values: &[f64]) -> Chart { + let series = Series { name: "Sales".into(), values: values.iter().map(|v| Some(*v)).collect(), ..Default::default() }; + Chart { categories: (0..values.len()).map(|i| format!("C{i}")).collect(), series: vec![series], ..Default::default() } + } + + /// The primary axis of a chart with room for ten intervals. + fn scale(c: &Chart) -> (f64, f64, f64) { + c.axis_scale(false, 10).unwrap_or_default() + } + + /// Office's documented automatic limits (#144), with room for ten intervals. + #[test] + fn automatic_axis_matches_office() { + // #144: 42.1 / 18.7 / 6.3 → 0–45 in steps of 5, not 0–50 in steps of 10. + assert_eq!(scale(&column(&[42.1, 18.7, 6.3])), (0.0, 45.0, 5.0)); + // PowerPoint's default chart data (max 5): 0–6 in steps of 1. + let mut c = column(&[4.3, 2.5, 3.5, 4.5]); + c.series.push(Series { values: [2.4, 4.4, 1.8, 2.8].map(Some).to_vec(), ..Default::default() }); + c.series.push(Series { values: [2.0, 2.0, 3.0, 5.0].map(Some).to_vec(), ..Default::default() }); + assert_eq!(scale(&c), (0.0, 6.0, 1.0)); + // A maximum of 100 (or 10) gets 5 % headroom, which rounds up to the next major unit. + assert_eq!(scale(&column(&[100.0, 40.0])), (0.0, 120.0, 20.0)); + assert_eq!(scale(&column(&[10.0, 3.0])), (0.0, 12.0, 2.0)); + // Peltier's example: 2.95 + 2.95/20 ≈ 3.1 rounds up to 3.5. + assert_eq!(scale(&column(&[2.95, 0.0])), (0.0, 3.5, 0.5)); + } + + #[test] + fn automatic_axis_minimum() { + // Below 5/6 of the maximum the axis starts at zero; at or above it, a unit below + // min − (max − min)/20. + assert_eq!(scale(&column(&[59.0, 49.0])).0, 0.0); + assert_eq!(scale(&column(&[59.0, 50.0])), (48.0, 60.0, 2.0)); + assert_eq!(scale(&column(&[85.0, 92.0, 97.0])), (84.0, 98.0, 2.0)); + // All-negative data mirrors it: the axis ends at zero, or near the data. + assert_eq!(scale(&column(&[-42.1, -18.7, -6.3])), (-45.0, 0.0, 5.0)); + assert_eq!(scale(&column(&[-59.0, -49.0])).1, 0.0); + assert_eq!(scale(&column(&[-59.0, -50.0])), (-60.0, -48.0, 2.0)); + // Mixed signs get headroom at both ends. + assert_eq!(scale(&column(&[42.1, -18.7])), (-30.0, 50.0, 10.0)); + // Stacked bars build up from zero, so their axis keeps it. + let mut s = column(&[85.0, 92.0]); + s.kind = ChartType::StackedColumn; + assert_eq!(scale(&s).0, 0.0); + // A combo chart's secondary axis follows the same rule. + let mut c = Chart::sample(ChartType::Combo); + c.series.truncate(2); + 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(7_000.0), Some(7_400.0)]; + c.series[1].secondary_axis = true; + assert_eq!(c.axis_scale(false, 10), Some((0.0, 350_000.0, 50_000.0))); + assert_eq!(c.axis_scale(true, 10), Some((6_950.0, 7_450.0, 50.0))); + } + + #[test] + fn fixed_axis_limits_are_kept() { + let mut c = column(&[42.1, 18.7, 6.3]); + c.y_max = Some(50.0); + assert_eq!(scale(&c), (0.0, 50.0, 5.0)); + c.y_min = Some(10.0); + c.y_max = None; + assert_eq!(scale(&c), (10.0, 45.0, 5.0)); + // A fixed limit beyond the data: the automatic end gives way to it. + let mut c = column(&[85.0, 92.0, 97.0]); + c.y_max = Some(50.0); + assert_eq!(scale(&c), (0.0, 50.0, 5.0)); + let mut c = column(&[-50.0, -48.0]); + c.y_min = Some(-10.0); + assert_eq!(scale(&c), (-10.0, 0.0, 1.0)); + } + + #[test] + fn percent_axis_is_zero_to_one() { + let mut p = column(&[42.1, 18.7, 6.3]); + p.kind = ChartType::PercentColumn; + assert_eq!(scale(&p), (0.0, 1.0, 0.1)); + assert_eq!(p.axis_scale(false, 5), Some((0.0, 1.0, 0.2))); + } + + #[test] + fn degenerate_axes_are_finite() { + for (a, b) in [(0.0, 0.0), (7.0, 7.0), (-7.0, -7.0), (f64::NAN, 3.0), (f64::INFINITY, 1.0), (-f64::MAX, f64::MAX), (0.0, 1e-320)] { + let (min, max, step) = scale(&column(&[a, b])); + assert!(min.is_finite() && max > min && step > 0.0, "{a} {b}: {min} {max} {step}"); + } + let (min, max, _) = scale(&column(&[7.0, 7.0])); + assert!(min == 0.0 && max > 7.0); + assert_eq!(scale(&column(&[])), (0.0, 1.0, 0.1)); + for (fmin, fmax) in [(Some(5.0), Some(5.0)), (Some(5.0), Some(1.0)), (Some(f64::NAN), Some(f64::INFINITY)), (Some(-1e300), None)] { + let (a, b, s) = axis_scale(0.0, 1.0, fmin, fmax, None, 10, true); + assert!(a.is_finite() && b.is_finite() && b > a && s > 0.0, "{fmin:?} {fmax:?}: {a} {b} {s}"); + } + } + + #[test] + fn general_labels_keep_every_digit_the_data_has() { + // #324: labels were rounded to one decimal ("100000.0" for 99999.99). + for kind in [ChartType::Column, ChartType::Pie, ChartType::Doughnut, ChartType::Line, ChartType::Scatter] { + let c = Chart::sample(kind); + let s = &c.series[0]; + for (v, want) in + [(1220.82, "1220.82"), (99999.99, "99999.99"), (1500.05, "1500.05"), (1500.0, "1500"), (1234.567, "1234.567"), (999.99, "999.99")] + { + assert_eq!(c.data_label(s, v), want, "{kind:?}"); + } + } + // Small ranges keep distinct value-axis ticks (0.123 used to give 0, 0.0, 0.1, 0.1…). + let c = Chart::sample(ChartType::Column); + let ticks: Vec = (0..=5).map(|i| c.axis_label(false, f64::from(i) * 0.025)).collect(); + assert_eq!(ticks, ["0", "0.025", "0.05", "0.075", "0.1", "0.125"]); + } + + #[test] + fn a_forecast_grows_the_value_axis() { + let mut c = Chart::sample(ChartType::Line); + c.series.truncate(1); + c.series[0].values = vec![Some(3.0), Some(5.0), Some(7.0), Some(9.0)]; + let before = c.value_range().1; + c.series[0].trendlines.push(Trendline { forward: Some(1.0), ..Default::default() }); + let after = c.value_range().1; + assert!(after > before && after >= 11.0 - 1e-6, "{before} {after}"); + } + + #[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 full_percent_axes_count_in_percent() { + let mut c = Chart::sample(ChartType::PercentColumn); + assert_eq!(c.axis_label(false, 0.5), "50%"); + c.y_format = Some("0.0%".into()); + assert_eq!(c.axis_label(false, 0.5), "50.0%"); + let c = Chart::sample(ChartType::Column); + assert_eq!(c.axis_label(false, 0.5), "0.5"); + } + + #[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); + let (_, hi) = c.value_range(); + assert!(hi > 10.0); + assert_eq!(Chart::sample(ChartType::Pie).series.len(), 1); + } +} + +/// One histogram bin: `[lo, hi]` (the first bin is closed on both sides, later ones open on the +/// left, as Excel bins) and how many observations fall in it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Bin { + pub lo: f64, + pub hi: f64, + pub count: usize, +} + +/// Bins a histogram has at most (a hostile bin size cannot make millions). +pub const MAX_BINS: usize = 1000; + +/// A box and whisker summary of one group: quartiles by Excel's exclusive method, whiskers at +/// the furthest points within 1.5 IQR of the box, and the points beyond them. +#[derive(Clone, Debug, PartialEq)] +pub struct BoxStats { + pub low: f64, + pub q1: f64, + pub median: f64, + pub q3: f64, + pub high: f64, + pub mean: f64, + pub outliers: Vec, +} + +impl Chart { + /// The first series' finite observations. + fn observations(&self) -> Vec { + self.series.first().map(|s| s.values.iter().flatten().copied().filter(|v| v.is_finite()).collect()).unwrap_or_default() + } + + /// The histogram's bins over the first series: by `bin_size`, else `bin_count`, else + /// Scott's rule (3.49·σ·n^(-1/3)). Empty without observations. + pub fn histogram_bins(&self) -> Vec { + let mut v = self.observations(); + if v.is_empty() { + return vec![]; + } + v.sort_by(f64::total_cmp); + let (lo, hi) = (v[0], v[v.len() - 1]); + let span = hi - lo; + let n = v.len() as f64; + let width = if let Some(w) = self.bin_size.filter(|w| w.is_finite() && *w > 0.0) { + w + } else if let Some(k) = self.bin_count.filter(|k| *k > 0) { + span / f64::from(k) + } else { + let mean = v.iter().sum::() / n; + let sd = (v.iter().map(|x| (x - mean).powi(2)).sum::() / (n - 1.0).max(1.0)).sqrt(); + 3.49 * sd / n.cbrt() + }; + let count = if span <= 0.0 || !width.is_finite() || width <= 0.0 { 1 } else { ((span / width).ceil() as usize).clamp(1, MAX_BINS) }; + let width = if count == 1 && (span <= 0.0 || !width.is_finite() || width <= 0.0) { span.max(1.0) } else { width.max(span / MAX_BINS as f64) }; + let mut bins: Vec = (0..count).map(|i| Bin { lo: lo + width * i as f64, hi: lo + width * (i + 1) as f64, count: 0 }).collect(); + for x in v { + // (lo, hi] bins, the first one also holding the minimum. + let i = (((x - lo) / width).ceil() as usize).saturating_sub(1).min(count - 1); + if let Some(b) = bins.get_mut(i) { + b.count += 1; + } + } + bins + } + + /// A histogram (or Pareto) as the chart that draws it: a column chart of the bin counts with + /// touching columns, labelled by bin range. A Pareto sorts the bins by count and adds the + /// cumulative percentage as a line on a 0–100 % secondary axis. + pub fn histogram_view(&self) -> Chart { + let mut bins = self.histogram_bins(); + let pareto = self.kind == ChartType::Pareto; + if pareto { + bins.sort_by_key(|b| std::cmp::Reverse(b.count)); + } + let fmt = |v: f64| { + let r = (v * 100.0).round() / 100.0; + if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") } + }; + let first_lo = bins.iter().map(|b| b.lo).fold(f64::INFINITY, f64::min); + let mut v = Chart { + kind: if pareto { ChartType::Combo } else { ChartType::Column }, + categories: bins + .iter() + .map(|b| if b.lo <= first_lo { format!("[{}, {}]", fmt(b.lo), fmt(b.hi)) } else { format!("({}, {}]", fmt(b.lo), fmt(b.hi)) }) + .collect(), + series: vec![], + gap_width: 0.0, + raw: None, + subtotals: vec![], + ..self.clone() + }; + let name = self.series.first().map(|s| s.name.clone()).unwrap_or_default(); + let base = self.series.first().cloned().unwrap_or_default(); + v.series.push(Series { + name, + values: bins.iter().map(|b| Some(b.count as f64)).collect(), + kind: pareto.then_some(ChartType::Column), + number_format: None, + ..base + }); + if pareto { + let total = bins.iter().map(|b| b.count).sum::().max(1) as f64; + let mut run = 0.0; + v.series.push(Series { + name: "Cumulative %".into(), + values: bins + .iter() + .map(|b| { + run += b.count as f64; + Some(run / total) + }) + .collect(), + kind: Some(ChartType::Line), + secondary_axis: true, + ..Default::default() + }); + v.y2_min = Some(0.0); + v.y2_max = Some(1.0); + v.y2_major = Some(0.2); + v.y2_format = Some("0%".into()); + } + v.vary_colors = false; + v + } + + /// The box chart's groups (distinct category labels in order of appearance; one unnamed + /// group without categories) and, per series, each group's summary (None when it has no + /// points). + pub fn box_groups(&self) -> (Vec, Vec>>) { + let mut groups: Vec = Vec::new(); + for c in &self.categories { + if !groups.contains(c) { + groups.push(c.clone()); + } + } + if groups.is_empty() { + groups.push(String::new()); + } + let stats = self + .series + .iter() + .map(|s| { + groups + .iter() + .map(|g| { + let pts: Vec = s + .values + .iter() + .enumerate() + .filter(|(i, _)| self.categories.get(*i).map_or(g.is_empty() || self.categories.is_empty(), |c| c == g)) + .filter_map(|(_, v)| *v) + .filter(|v| v.is_finite()) + .collect(); + box_stats_by(pts, self.inclusive_quartiles) + }) + .collect() + }) + .collect(); + (groups, stats) + } +} + +/// Excel's QUARTILE.EXC position `p·(n+1)` (1-based) on sorted data, clamped to the ends. +fn quantile_exc(v: &[f64], p: f64) -> f64 { + let n = v.len(); + let pos = (p * (n as f64 + 1.0)).clamp(1.0, n as f64); + let i = pos.floor() as usize; + let frac = pos - i as f64; + let a = v[i.saturating_sub(1).min(n - 1)]; + let b = v[i.min(n - 1)]; + a + (b - a) * frac +} + +/// Excel's QUARTILE.INC position `p·(n-1)` (0-based) on sorted data. +fn quantile_inc(v: &[f64], p: f64) -> f64 { + let n = v.len(); + let pos = (p * (n as f64 - 1.0)).clamp(0.0, (n - 1) as f64); + let i = pos.floor() as usize; + let a = v[i.min(n - 1)]; + let b = v[(i + 1).min(n - 1)]; + a + (b - a) * (pos - i as f64) +} + +/// The summary of one group's points, with exclusive quartiles. +pub fn box_stats(pts: Vec) -> Option { + box_stats_by(pts, false) +} + +/// The summary of one group's points; `inclusive` picks QUARTILE.INC over QUARTILE.EXC. +pub fn box_stats_by(mut pts: Vec, inclusive: bool) -> Option { + if pts.is_empty() { + return None; + } + pts.sort_by(f64::total_cmp); + let q = |p: f64| if inclusive { quantile_inc(&pts, p) } else { quantile_exc(&pts, p) }; + let (q1, median, q3) = (q(0.25), q(0.5), q(0.75)); + let iqr = q3 - q1; + let (lo_fence, hi_fence) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr); + let inside: Vec = pts.iter().copied().filter(|v| *v >= lo_fence && *v <= hi_fence).collect(); + let low = inside.first().copied().unwrap_or(q1).min(q1); + let high = inside.last().copied().unwrap_or(q3).max(q3); + let mean = pts.iter().sum::() / pts.len() as f64; + let outliers = pts.iter().copied().filter(|v| *v < lo_fence || *v > hi_fence).collect(); + Some(BoxStats { low, q1, median, q3, high, mean, outliers }) +} + +/// A node of a treemap or sunburst hierarchy: its label, the total of the leaves under it, and +/// its children (none for a leaf, which names its category index). +#[derive(Clone, Debug, PartialEq)] +pub struct TreeNode { + pub name: String, + pub value: f64, + pub leaf: Option, + pub children: Vec, +} + +/// Levels of hierarchy a chart may have (a hostile file cannot nest deeper). +pub const MAX_LEVELS: usize = 16; + +impl Chart { + /// The first series as a hierarchy: top-level nodes, each holding the consecutive + /// categories that share its label, down to the leaves. Without outer levels every + /// category is a top-level leaf. Negative and missing values count as zero. + pub fn category_tree(&self) -> Vec { + let Some(s) = self.series.first() else { return vec![] }; + let n = self.categories.len().max(s.values.len()); + let levels: Vec<&Vec> = self.category_levels.iter().take(MAX_LEVELS).collect(); + // Each leaf's path from the top level down, blanks filled from the row above. + let mut last: Vec = vec![String::new(); levels.len()]; + let paths: Vec> = (0..n) + .map(|i| { + let mut path = Vec::with_capacity(levels.len() + 1); + for (d, lv) in levels.iter().enumerate().rev() { + let l = lv.get(i).map(|x| x.trim()).unwrap_or(""); + if !l.is_empty() { + last[d] = l.to_string(); + // A new outer label starts fresh inner groups. + for inner in last.iter_mut().take(d) { + inner.clear(); + } + } + path.push(last[d].clone()); + } + path.push(self.categories.get(i).cloned().unwrap_or_default()); + path + }) + .collect(); + let value = |i: usize| s.values.get(i).copied().flatten().filter(|v| v.is_finite()).unwrap_or(0.0).max(0.0); + fn build(rows: &[(usize, &[String])], value: &dyn Fn(usize) -> f64) -> Vec { + let mut out: Vec = Vec::new(); + let mut i = 0; + while i < rows.len() { + let (idx, path) = rows[i]; + if path.len() <= 1 { + out.push(TreeNode { name: path.first().cloned().unwrap_or_default(), value: value(idx), leaf: Some(idx), children: vec![] }); + i += 1; + continue; + } + let head = &path[0]; + let mut j = i; + while j < rows.len() && rows[j].1.len() > 1 && &rows[j].1[0] == head { + j += 1; + } + let sub: Vec<(usize, &[String])> = rows[i..j].iter().map(|(k, p)| (*k, &p[1..])).collect(); + let children = build(&sub, value); + out.push(TreeNode { name: head.clone(), value: children.iter().map(|c| c.value).sum(), leaf: None, children }); + i = j; + } + out + } + let rows: Vec<(usize, &[String])> = paths.iter().enumerate().map(|(i, p)| (i, p.as_slice())).collect(); + build(&rows, &value) + } +} + +#[cfg(test)] +mod chartex_tests { + use super::*; + + fn obs(v: &[f64]) -> Chart { + Chart { + kind: ChartType::Histogram, + series: vec![Series { values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }], + ..Default::default() + } + } + + #[test] + fn histogram_bins_by_count_size_and_scott() { + let mut c = obs(&[1.0, 2.0, 2.0, 3.0, 4.0, 5.0, 9.0]); + c.bin_count = Some(4); + let b = c.histogram_bins(); + assert_eq!(b.len(), 4); + assert_eq!((b[0].lo, b[0].hi), (1.0, 3.0)); + // (lo, hi] bins with the minimum in the first: 1, 2, 2, 3 | 4, 5 | - | 9. + assert_eq!(b.iter().map(|b| b.count).collect::>(), vec![4, 2, 0, 1]); + c.bin_count = None; + c.bin_size = Some(5.0); + assert_eq!(c.histogram_bins().iter().map(|b| b.count).collect::>(), vec![6, 1]); + c.bin_size = None; + let auto = c.histogram_bins(); + assert!(!auto.is_empty() && auto.iter().map(|b| b.count).sum::() == 7); + // A tiny bin size is capped; one value gives one bin. + c.bin_size = Some(1e-12); + assert!(c.histogram_bins().len() <= MAX_BINS); + assert_eq!(obs(&[3.0]).histogram_bins(), vec![Bin { lo: 3.0, hi: 4.0, count: 1 }]); + assert!(obs(&[]).histogram_bins().is_empty()); + } + + #[test] + fn pareto_view_sorts_bins_and_adds_the_cumulative_line() { + let mut c = obs(&[1.0, 5.0, 5.0, 5.0, 9.0, 9.0]); + c.kind = ChartType::Pareto; + c.bin_count = Some(3); + let v = c.histogram_view(); + assert_eq!(v.kind, ChartType::Combo); + assert_eq!(v.series[0].values, vec![Some(3.0), Some(2.0), Some(1.0)]); + let cum = &v.series[1]; + assert!(cum.secondary_axis && cum.kind == Some(ChartType::Line)); + assert_eq!(cum.values.last(), Some(&Some(1.0))); + assert_eq!(v.categories[0], "(3.67, 6.33]"); + } + + #[test] + fn box_stats_use_exclusive_quartiles_and_fence_outliers() { + let b = box_stats(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0]).unwrap(); + // QUARTILE.EXC on 8 points: positions 2.25, 4.5, 6.75. + assert_eq!((b.q1, b.median, b.q3), (2.25, 4.5, 6.75)); + assert_eq!(b.outliers, vec![100.0]); + assert_eq!((b.low, b.high), (1.0, 7.0)); + assert!(box_stats(vec![]).is_none()); + let one = box_stats(vec![5.0]).unwrap(); + assert_eq!((one.low, one.q1, one.median, one.q3, one.high), (5.0, 5.0, 5.0, 5.0, 5.0)); + } + + #[test] + fn inclusive_quartiles_follow_quartile_inc() { + let b = box_stats_by(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0], true).unwrap(); + // QUARTILE.INC on 8 points: positions 1.75, 3.5, 5.25 (0-based). + assert_eq!((b.q1, b.median, b.q3), (2.75, 4.5, 6.25)); + } + + #[test] + fn category_tree_groups_runs_and_fills_blank_parents() { + let mut c = Chart::sample(ChartType::Treemap); + let t = c.category_tree(); + assert_eq!(t.iter().map(|n| n.name.as_str()).collect::>(), ["Q1", "Q2", "Q3"]); + assert_eq!(t[0].children.len(), 3); + assert_eq!(t[0].children[1].leaf, Some(1)); + assert!((t[0].value - (4.2 + 2.8 + 3.6)).abs() < 1e-9); + // Blank parents continue the label above; two outer levels nest. + c.category_levels = vec![ + vec!["a".into(), "".into(), "b".into(), "".into(), "c".into(), "".into(), "".into(), "".into()], + vec!["H1".into(), "".into(), "".into(), "".into(), "H2".into(), "".into(), "".into(), "".into()], + ]; + let t = c.category_tree(); + assert_eq!(t.len(), 2); + assert_eq!(t[0].children.iter().map(|n| n.name.as_str()).collect::>(), ["a", "b"]); + assert_eq!(t[1].children[0].children.len(), 4); + // No levels: every category is a leaf. + c.category_levels.clear(); + assert!(c.category_tree().iter().all(|n| n.leaf.is_some())); + } + + #[test] + fn box_groups_follow_category_labels() { + let c = Chart::sample(ChartType::BoxWhisker); + let (groups, stats) = c.box_groups(); + assert_eq!(groups, vec!["Group A".to_string(), "Group B".to_string()]); + assert_eq!(stats.len(), 2); + assert!(stats.iter().all(|s| s.len() == 2 && s.iter().all(Option::is_some))); + } +} diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 9055b2d9..73cc416c 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -473,7 +473,7 @@ fn copy_part( return false; } let content_type = pkg.content_type(part).unwrap_or_default(); - out.push(ChartPart { name, content_type, bytes: bytes.to_vec() }); + out.push(ChartPart { name, content_type, bytes: bytes.to_vec(), native_entry: None }); seen.insert(key, true); let rels_name = crate::opc::rels_path(part); if crate::opc::sidecar_part(&rels_name) @@ -482,7 +482,12 @@ fn copy_part( { seen.insert(crate::opc::norm(&rels_name), true); let name = pkg.original_name(&rels_name).unwrap_or(&rels_name).trim_start_matches('/').to_string(); - out.push(ChartPart { name, content_type: "application/vnd.openxmlformats-package.relationships+xml".into(), bytes: rels_bytes.to_vec() }); + out.push(ChartPart { + name, + content_type: "application/vnd.openxmlformats-package.relationships+xml".into(), + bytes: rels_bytes.to_vec(), + native_entry: None, + }); let targets: Vec = pkg.rels(part).items.into_iter().filter(|r| !r.external).map(|r| r.target).collect(); for target in targets { copy_part(pkg, &target, out, seen, depth + 1); diff --git a/docs/architecture.md b/docs/architecture.md index 31bac443..2a600e57 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # DeckCraft architecture -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (community PR wave 2026-10-14: 320 engine commands, 41 dialogs, the `metafile` crate, 1,641 tests; duplicated L2/L5/L6 rows and headers folded; previous: minor, transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: minor, community PR wave 2026-10-14: 320 engine commands, 41 dialogs, the `metafile` crate, 1,641 tests; duplicated L2/L5/L6 rows and headers folded; previous: minor, transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) How DeckCraft is built today. The binding rules (layering, everything is a command, thin UI, never break wasm, never crash) are in [AGENTS.md](../AGENTS.md) §3–§5; this document describes what @@ -64,8 +64,10 @@ overlays an invisible real-text layer with embedded font subsets. - **PPTX** (`crates/pptx`): lenient reader (unknown parts skipped, malformed parts logged and ignored, sizes and depths bounded, zip bombs capped), writer that PowerPoint opens without repair. Unknown elements round-trip through `Opaque` and `raw_ext` fields. -- **Native** (`crates/format`): zip with `mimetype`, `presentation.json` and `media/`; readers - ignore unknown fields; `.slidecraft` files from before the rename still open. +- **Native** (`crates/format`): zip with `mimetype`, `presentation.json`, `media/` and + `chart-parts/`. Chart relationships and payload references stay in the manifest; binary + chart parts use bounded ZIP reads and share the media byte/item budgets. Earlier inline chart + bytes still load and migrate on save; `.slidecraft` files from before the rename still open. - Formats and their gaps: [file-format-parity.md](file-format-parity.md). ## Quality gates @@ -79,6 +81,7 @@ macOS, Windows, Linux, FreeBSD and web artifacts. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | | 2026-10-14 | minor | Community PR wave folded: one L2/L5/L6 row each; command registry 320 (`cmd!` registrations in `cmd/*`), 41 dialogs (titled in `dialogs.rs`), 202k lines, 24 crates (`metafile` added at L0), 1,641 tests. The per-PR rows below give the counts each PR saw on its own branch | | 2026-10-14 | minor | Size and Position dialog (`shape.sizePosition`): position, size, rotation, scale and lock aspect in one undo step. 227 commands, 28 dialogs | | 2026-10-14 | trivial | Command registry: 228 (`comment.next`, `comment.prev`) | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b8bbedb4..87a36322 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-15 · **Change:** major (community PR wave 2026-10-14: seven owned PPTX corpus files, embedded fonts, EMF/WMF clip regions, threaded comments (#70), chart trendlines and workbooks (#335), SmartArt written as a diagram, tags and custom XML kept, picture export DPI; duplicated rows and headers folded; previous: trivial, lines in scaled groups, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: seven owned PPTX corpus files, embedded fonts, EMF/WMF clip regions, threaded comments (#70), chart trendlines and workbooks (#335), SmartArt written as a diagram, tags and custom XML kept, picture export DPI; duplicated rows and headers folded; previous: trivial, lines in scaled groups, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested. Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready** @@ -34,6 +34,22 @@ PowerPoint opens and saves; Viewer = opens only). ## Pictures and media inside decks +Native files retain a nonempty chart `kept` sidecar: relationships, part names, content types +and `nativeEntry` references live in `presentation.json`; exact binary payloads live in distinct +`chart-parts/` ZIP members. This keeps workbooks and drawings through a native save before +exporting back to PPTX without expanding workbook bytes into JSON numbers. Chart parts and +media share the 4 GiB inflated-byte and 100,000-item budgets; each member is read through the +existing 2 GiB entry limit. Missing chart members are reported by `load_checked` and left empty. +A present chart member that fails decoding, CRC or its entry limit refuses the file immediately, +so later corrupt members cannot reuse the remaining inflation budget. Invalid, reused or +ambiguous references are refused. A synthetic 3 MiB payload test +checks the ZIP layout and bounded reads, alongside shared-budget and all-owner round trips. + +Older native files without a sidecar still load. Earlier inline chart byte arrays are read +under the existing manifest-size limit, then charged to the shared budget; saving migrates them +to ZIP members. Builds predating chart-sidecar support ignore it and lose it on resave. The +intermediate build that required inline `bytes` cannot read the new referenced representation. + | Kind | PowerPoint | DeckCraft | Notes | |---|---|---|---| | PNG, JPEG, GIF (animated), BMP, TIFF, WebP | yes | yes | `image` 0.25; animated GIFs play since #39 | @@ -103,6 +119,8 @@ the round trip even though DeckCraft doesn't model it. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | +| 2026-10-15 | trivial | Native `.deckcraft` files serialize a nonempty chart `kept` sidecar (relationships, part names, content types and bytes). Older files without it still load; empty sidecars are omitted. Headless PPTX → edit → native save/reopen → PPTX checks keep the workbook and drawing bytes; editing chart data still drops the stale workbook | | 2026-10-15 | trivial | PPTX interactive timing sequences keep later On Click steps and paragraph click builds (`trigger_steps.rs`); mixed starts and independent buttons survive import/edit/save/reopen | | 2026-10-15 | minor | A `.pptx` slide that can't be read is reported instead of dropped in silence: `file.open` and `file.openBytes` return `lostSlides`, the app shows a message and opens a repaired copy (Save asks for a new name), and `deckcraft-cli` warns on stderr (#203) | | 2026-10-16 | minor | PNG slide export with a transparent background (#784) | diff --git a/docs/gaps.md b/docs/gaps.md index da39341f..57271c4e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every known shortfall, one entry each, ranked by how much it stops someone who presents with PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in @@ -180,7 +180,9 @@ Detail in [file-format-parity.md](file-format-parity.md). neither label. A combo chart's secondary value axis is read (`y2_*`, `Series.secondary_axis`) and is not written back when the chart is rebuilt. An unchanged chart's embedded workbook now survives save (#335); editing the chart still - drops it rather than writing a stale spreadsheet. + drops it rather than writing a stale spreadsheet. The workbook and other retained chart parts + also survive `.deckcraft` save/reopen (`crates/engine/tests/chart_native.rs`), with binary + payloads in bounded ZIP entries sharing the media load budget (`crates/format` tests). - **Impact:** most business decks have charts; editing their data is routine. - **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h). @@ -354,6 +356,8 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | +| 2026-10-15 | trivial | Native saves retain chart package parts and relationships, including charts in groups; edited chart data still does not export a stale source workbook. Chart editing gaps and scores remain unchanged | | 2026-10-15 | trivial | PPTX trigger sequences keep later On Click steps instead of converting them to After Previous; mixed starts, independent buttons and paragraph builds have synthetic round-trip regressions | | 2026-10-16 | trivial | AutoFormat superscripts ordinals (1st, 22nd) as you type, with an AutoCorrect option to turn it off; automatic bulleted lists remain | | 2026-10-14 | trivial | Fixed #718: remote MCP no longer accepts unrelated or buffered stale success; loopback regressions cover invalid envelopes, incomplete and oversized frames | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7f129fea..f83835d3 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -54,7 +54,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Application shell | Multiple open presentations | P1 | P | | Application shell | Preferences (autosave, units, defaults) | P2 | P | | Files | New blank presentation | P0 | D | -| Files | Native file format (open/save) | P0 | D | +| Files | Native file format (open/save; chart package parts retained, `crates/engine/tests/chart_native.rs`) | P0 | D | | Files | Open PowerPoint .pptx | P0 | D | | Files | Save as PowerPoint .pptx | P0 | D | | Files | Open/save .potx/.ppsx/.pptm variants (.potx/.ppsx open; saved with the .pptx content type; no .pptm) | P2 | P | diff --git a/docs/roadmap.md b/docs/roadmap.md index f6115ebf..235b44ae 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # DeckCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: PPTX corpus, presenter and show controls, review navigation, starter decks, Grid Options and New Window, WordArt gallery and handles, pen pressure; beta items 9 and 10 narrowed; duplicated rows and headers folded; previous: minor, core-workflow alpha gate added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: PPTX corpus, presenter and show controls, review navigation, starter decks, Grid Options and New Window, WordArt gallery and handles, pen pressure; beta items 9 and 10 narrowed; duplicated rows and headers folded; previous: minor, core-workflow alpha gate added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Forward-looking: where DeckCraft is going and in what order. The one-page summary is [ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in @@ -115,6 +115,8 @@ plan above. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | +| 2026-10-15 | trivial | Native save/reopen retains imported chart workbooks and related parts; headless command tests cover a PPTX → native → PPTX cycle and stale-workbook rejection. Milestone scope and scores unchanged | | 2026-10-15 | trivial | PPTX trigger click steps survive save, including by-paragraph builds. Interactive-deck gaps remain media actions, audio triggers and click-box geometry; milestone scope and feature scores unchanged | | 2026-10-14 | trivial | M13 reliability: remote MCP validates IDs and envelopes, caps replies and rejects incomplete frames; #718 | | 2026-10-14 | minor | Community PR wave folded: one Review gate row, one Current focus item 2, one beta row each for items 9 and 10 (timeline and timer controls are in), one after-beta paragraph, one M9 and M10 row, one corpus row in the 2026-10-07 history |