Charts: keep the embedded workbook when a chart is saved unchanged (#335)

PowerPoint stores chart data in ppt/embeddings. Saving used to drop that part and the chart relationship, so the deck still rendered but the workbook was gone. An edit still omits the workbook rather than writing numbers that no longer match it.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nikita
2026-10-13 10:40:59 +03:00
co-authored by Cursor
parent 1660982e58
commit 8788f994ef
10 changed files with 328 additions and 15 deletions
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (unchanged charts keep their embedded workbook, #335) · **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
@@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-13 | Saving a presentation keeps the embedded workbook behind an unchanged chart, and the chart part's relationships (#335). Editing the chart drops that workbook instead of writing numbers that no longer match it |
| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) |
| 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) |
| 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes |
@@ -147,6 +148,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | trivial | Progress log: chart workbook round-trip (#335) |
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials |
+30
View File
@@ -162,6 +162,35 @@ pub struct Chart {
/// Original chart XML part kept for round-trip of unmodelled options.
#[serde(skip_serializing_if = "Option::is_none")]
pub raw: Option<String>,
/// 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,
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<u8>,
}
/// Sidecar for an unchanged chart: relationships and the parts they reach.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ChartKept {
pub rels: Vec<ChartRel>,
pub parts: Vec<ChartPart>,
}
impl Default for Chart {
@@ -187,6 +216,7 @@ impl Default for Chart {
y_min: None,
y_max: None,
raw: None,
kept: ChartKept::default(),
}
}
}
+40 -2
View File
@@ -104,8 +104,16 @@ impl Package {
for r in doc.root.children_named("Relationship") {
let (Some(id), Some(ty), Some(target)) = (r.attr("Id"), r.attr("Type"), r.attr("Target")) else { continue };
let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("external"));
let target = if external { target.to_string() } else { resolve(&base, target) };
out.items.push(Rel { id: id.to_string(), kind: ty.rsplit('/').next().unwrap_or(ty).to_string(), target, external });
let target_raw = target.to_string();
let target = if external { target_raw.clone() } else { resolve(&base, target) };
out.items.push(Rel {
id: id.to_string(),
kind: ty.rsplit('/').next().unwrap_or(ty).to_string(),
ty: ty.to_string(),
target_raw,
target,
external,
});
}
out
}
@@ -134,6 +142,10 @@ pub struct Rel {
pub id: String,
/// Last segment of the relationship type (`slide`, `image`, `media`…).
pub kind: String,
/// Full relationship type URI.
pub ty: String,
/// `Target` as written in the rels part (relative path or URL).
pub target_raw: String,
/// Absolute part name for internal targets; the URL for external ones.
pub target: String,
pub external: bool,
@@ -163,6 +175,28 @@ pub fn norm(name: &str) -> String {
name.trim_start_matches('/').replace('\\', "/").to_ascii_lowercase()
}
/// A part a chart may carry along beside its own `chartN.xml` (workbook, style, drawing, picture).
/// Chart documents themselves are rewritten, and nothing outside these folders is copied: a relationship
/// must not be able to replace `ppt/slides/slide1.xml` on save.
pub fn sidecar_part(name: &str) -> bool {
let n = norm(name);
let segs: Vec<&str> = n.split('/').filter(|s| !s.is_empty()).collect();
if segs.iter().any(|s| *s == "." || *s == "..") {
return false;
}
let (Some(root), Some(area)) = (segs.first().copied(), segs.get(1).copied()) else { return false };
if root != "ppt" || !matches!(area, "embeddings" | "charts" | "drawings" | "media") {
return false;
}
let file = segs.last().copied().unwrap_or("");
let stem = file.strip_prefix("chart").unwrap_or("");
let num = stem.strip_suffix(".xml.rels").or_else(|| stem.strip_suffix(".xml")).unwrap_or("");
if stem != file && !num.is_empty() && num.chars().all(|c| c.is_ascii_digit()) {
return false;
}
true
}
pub fn dir_of(part: &str) -> String {
let p = part.trim_start_matches('/');
match p.rfind('/') {
@@ -311,6 +345,10 @@ impl PackageOut {
}
self.files.push((rels_path(part), rels.to_xml(), true));
}
pub fn part_data(&self, name: &str) -> Option<&[u8]> {
let n = norm(name);
self.files.iter().find(|(f, _, _)| norm(f) == n).map(|(_, d, _)| d.as_slice())
}
/// Add a binary part; its extension gets a Default content type.
pub fn binary(&mut self, name: &str, content_type: &str, data: Vec<u8>) {
let ext = name.rsplit('.').next().unwrap_or("bin").to_ascii_lowercase();
+56
View File
@@ -225,3 +225,59 @@ pub(crate) fn chart_from(root: &El) -> Option<Chart> {
}
Some(c)
}
const MAX_KEPT_PARTS: usize = 256;
const MAX_KEPT_DEPTH: usize = 8;
/// Relationships and parts a chart points at, so a later save can write them back.
pub(crate) fn capture_kept(pkg: &crate::opc::Package, chart_part: &str) -> deckcraft_model::chart::ChartKept {
use deckcraft_model::chart::{ChartKept, ChartRel};
let mut kept = ChartKept::default();
let mut seen = std::collections::HashSet::new();
seen.insert(crate::opc::norm(chart_part));
for r in pkg.rels(chart_part).items {
kept.rels.push(ChartRel { id: r.id, rel_type: r.ty, target: r.target_raw, external: r.external });
if !r.external {
copy_part(pkg, &r.target, &mut kept.parts, &mut seen, 0);
}
}
kept
}
fn copy_part(
pkg: &crate::opc::Package,
part: &str,
out: &mut Vec<deckcraft_model::chart::ChartPart>,
seen: &mut std::collections::HashSet<String>,
depth: usize,
) {
use deckcraft_model::chart::ChartPart;
if depth > MAX_KEPT_DEPTH || out.len() >= MAX_KEPT_PARTS {
log::warn!("pptx: chart part closure stopped at {part}");
return;
}
let key = crate::opc::norm(part);
if !seen.insert(key) {
return;
}
let Some(bytes) = pkg.get(part) else {
log::warn!("pptx: chart relationship target {part} is missing");
return;
};
let name = pkg.original_name(part).unwrap_or(part).trim_start_matches('/').to_string();
let content_type = pkg.content_type(part).unwrap_or_default();
out.push(ChartPart { name, content_type, bytes: bytes.to_vec() });
let rels_name = crate::opc::rels_path(part);
if seen.insert(crate::opc::norm(&rels_name))
&& let Some(rels_bytes) = pkg.get(&rels_name)
{
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() });
let targets: Vec<String> = 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);
}
}
}
+2 -1
View File
@@ -368,8 +368,9 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
if let Some(c) = gd.child("chart")
&& let Some(rel) = part.rels.get(c.attr("r:id").unwrap_or(""))
&& let Some(bytes) = imp.pkg.get(&rel.target)
&& let Some(chart) = super::read_chart_xml(bytes)
&& let Some(mut chart) = super::read_chart_xml(bytes)
{
chart.kept = super::chart::capture_kept(imp.pkg, &rel.target);
s.kind = ShapeKind::Chart(Box::new(chart));
return s;
}
+46 -6
View File
@@ -1,4 +1,5 @@
//! Chart parts: a `c:chartSpace` with cached categories and values (no embedded workbook).
//! Chart parts: a `c:chartSpace` with cached categories and values.
//! An unchanged chart also keeps the embedded workbook and the other parts it points at.
use deckcraft_model::chart::{Chart, ChartType, Series};
use deckcraft_model::style::Fill;
@@ -362,8 +363,8 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)])
w.close(tag);
}
/// The chart part XML.
pub fn chart_xml(c: &Chart) -> Vec<u8> {
/// The chart part XML, and whether it is the original part (so its relationships still apply).
pub fn chart_xml(c: &Chart) -> (Vec<u8>, bool) {
// Reuse the kept part when the chart is unchanged since it was read.
if let Some(raw) = &c.raw
&& let Ok(doc) = crate::xml::parse(raw.as_bytes())
@@ -374,12 +375,17 @@ pub fn chart_xml(c: &Chart) -> Vec<u8> {
if let Some(back) = crate::read::chart_from_el(&root) {
let mut me = c.clone();
me.raw = None;
// The sidecar is not modelled chart state. Comparing it would throw away an unchanged chart.
me.kept = Default::default();
if back == me {
let mut keep = doc.root;
keep.remove_all(&["externalData", "userShapes"]);
// Without the parts those elements point at, PowerPoint repairs the file.
if c.kept.rels.is_empty() {
keep.remove_all(&["externalData", "userShapes"]);
}
let mut w = W::new();
w.raw(&keep.to_xml());
return w.finish();
return (w.finish(), true);
}
}
}
@@ -477,5 +483,39 @@ pub fn chart_xml(c: &Chart) -> Vec<u8> {
sp_pr(&mut w, Some(f), None);
}
w.close("c:chartSpace");
w.finish()
(w.finish(), false)
}
/// Write the chart's own relationships and the parts they reach.
pub fn write_kept(pkg: &mut crate::opc::PackageOut, chart_name: &str, c: &Chart) {
if c.kept.rels.is_empty() && c.kept.parts.is_empty() {
return;
}
let mut rels = crate::opc::RelsOut::default();
for r in &c.kept.rels {
rels.items.push((r.id.clone(), r.rel_type.clone(), r.target.clone(), r.external));
}
pkg.rels(chart_name, &rels);
for p in &c.kept.parts {
match pkg.part_data(&p.name) {
Some(existing) if existing != p.bytes.as_slice() => {
log::warn!("pptx: chart part {} already written with different bytes; keeping the first", p.name);
continue;
}
Some(_) => continue,
None => {}
}
if !crate::opc::sidecar_part(&p.name) {
log::warn!("pptx: not writing chart part {}", p.name);
continue;
}
let owned;
let ct = if !p.content_type.is_empty() {
p.content_type.as_str()
} else {
owned = crate::mime_for(&p.name).to_string();
owned.as_str()
};
pkg.binary(&p.name, ct, p.bytes.clone());
}
}
+7 -1
View File
@@ -161,7 +161,13 @@ impl Exp<'_> {
pub fn chart_rel(&mut self, o: &mut Out, c: &deckcraft_model::Chart) -> String {
self.n_chart += 1;
let name = format!("ppt/charts/chart{}.xml", self.n_chart);
self.pkg.xml_part(&name, CT_CHART, chart::chart_xml(c));
let (xml, preserved) = chart::chart_xml(c);
self.pkg.xml_part(&name, CT_CHART, xml);
if preserved {
chart::write_kept(&mut self.pkg, &name, c);
} else if !c.kept.parts.is_empty() {
log::warn!("pptx: chart data changed; the embedded workbook was not written back");
}
o.rels.add_new("chart", &opc::relative(&o.name, &name), false)
}
}
+137
View File
@@ -0,0 +1,137 @@
//! An unchanged chart keeps the embedded workbook its `c:externalData` points at (#335).
use std::io::{Cursor, Read, Write};
use std::sync::Arc;
use deckcraft_geom::Xfrm;
use deckcraft_model::chart::{Chart, ChartType};
use deckcraft_model::{LayoutType, Shape, ShapeId, ShapeKind, defaults};
const WORKBOOK: &[u8] = b"chart-workbook-bytes-v1";
const EMBED: &str = "ppt/embeddings/Microsoft_Excel_Worksheet1.xlsx";
fn chart_pptx() -> Vec<u8> {
let mut p = defaults::new_presentation(None);
p.slides.clear();
let layout = defaults::layout_of_kind(&p, LayoutType::Blank).unwrap_or_default();
let mut slide = defaults::new_slide(&mut p, layout);
let mut shape = Shape { id: ShapeId(p.alloc_id()), name: "Pie".into(), xfrm: Some(Xfrm::new(40.0, 40.0, 400.0, 300.0)), ..Default::default() };
shape.kind = ShapeKind::Chart(Box::new(Chart::sample(ChartType::Pie)));
slide.shapes.push(shape);
p.slides.push(Arc::new(slide));
deckcraft_pptx::export(&p).expect("export")
}
/// Add an embedded workbook and point the chart part at it, the way PowerPoint writes a chart.
fn with_workbook(bytes: &[u8]) -> Vec<u8> {
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
let opts = zip::write::SimpleFileOptions::default();
let mut chart_part = None;
for i in 0..zip.len() {
let mut f = zip.by_index(i).expect("entry");
let name = f.name().to_string();
let mut data = vec![];
f.read_to_end(&mut data).expect("read");
if name.starts_with("ppt/charts/chart") && name.ends_with(".xml") && !name.contains("_rels") {
let xml = String::from_utf8(data).expect("chart xml");
assert!(xml.contains("</c:chartSpace>"), "{name}");
data = xml
.replacen("</c:chartSpace>", r#"<c:externalData r:id="rId1"><c:autoUpdate val="0"/></c:externalData></c:chartSpace>"#, 1)
.into_bytes();
chart_part = Some(name.clone());
} else if name == "[Content_Types].xml" {
let xml = String::from_utf8(data).expect("content types");
data = xml
.replacen(
"</Types>",
r#"<Override PartName="/ppt/embeddings/Microsoft_Excel_Worksheet1.xlsx" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"/></Types>"#,
1,
)
.into_bytes();
}
out.start_file(&name, opts).expect("start");
out.write_all(&data).expect("write");
}
let chart_part = chart_part.expect("chart part");
let rels = format!("ppt/charts/_rels/{}.rels", chart_part.rsplit('/').next().unwrap_or("chart1.xml"));
let rels_xml = br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" Target="../embeddings/Microsoft_Excel_Worksheet1.xlsx"/></Relationships>"#;
out.start_file(rels, opts).expect("rels");
out.write_all(rels_xml).expect("rels bytes");
out.start_file(EMBED, opts).expect("embed");
out.write_all(WORKBOOK).expect("workbook");
out.finish().expect("finish").into_inner()
}
fn parts(bytes: &[u8]) -> Vec<(String, Vec<u8>)> {
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
let mut out = vec![];
for i in 0..zip.len() {
let mut f = zip.by_index(i).expect("entry");
let name = f.name().to_string();
let mut data = vec![];
f.read_to_end(&mut data).expect("read");
out.push((name, data));
}
out
}
fn chart_of(bytes: &[u8]) -> deckcraft_model::Chart {
let p = deckcraft_pptx::import(bytes).expect("import");
p.slides
.iter()
.flat_map(|s| s.shapes.iter())
.find_map(|s| match &s.kind {
ShapeKind::Chart(c) if s.name == "Pie" => Some((**c).clone()),
_ => None,
})
.expect("pie chart")
}
#[test]
fn an_unchanged_chart_keeps_its_embedded_workbook() {
let src = with_workbook(&chart_pptx());
let chart = chart_of(&src);
assert_eq!(chart.kept.parts.iter().filter(|p| p.name == EMBED).count(), 1);
assert_eq!(chart.kept.parts.iter().find(|p| p.name == EMBED).map(|p| p.bytes.as_slice()), Some(WORKBOOK));
let saved = deckcraft_pptx::export(&deckcraft_pptx::import(&src).expect("import")).expect("export");
let saved_parts = parts(&saved);
let workbook = saved_parts.iter().find(|(n, _)| n == EMBED).map(|(_, d)| d.as_slice());
assert_eq!(workbook, Some(WORKBOOK), "the embedding part is dropped on save");
let rels = saved_parts
.iter()
.find(|(n, _)| n.starts_with("ppt/charts/_rels/chart") && n.ends_with(".xml.rels"))
.map(|(_, d)| String::from_utf8_lossy(d).into_owned())
.expect("chart relationships");
assert!(rels.contains("../embeddings/Microsoft_Excel_Worksheet1.xlsx"), "{rels}");
let xml = saved_parts
.iter()
.find(|(n, _)| n.starts_with("ppt/charts/chart") && n.ends_with(".xml"))
.map(|(_, d)| String::from_utf8_lossy(d).into_owned())
.expect("chart xml");
assert!(xml.contains("externalData"), "{xml}");
}
#[test]
fn an_edited_chart_does_not_write_a_stale_workbook() {
let src = with_workbook(&chart_pptx());
let mut p = deckcraft_pptx::import(&src).expect("import");
let slide = Arc::make_mut(p.slides.get_mut(0).expect("slide"));
let chart = slide.shapes.iter_mut().find_map(|s| match &mut s.kind {
ShapeKind::Chart(c) if s.name == "Pie" => Some(c),
_ => None,
});
let chart = chart.expect("pie");
let value = chart.series.get_mut(0).and_then(|s| s.values.get_mut(0)).expect("value");
*value = Some(99.0);
let saved = parts(&deckcraft_pptx::export(&p).expect("export"));
assert!(saved.iter().all(|(n, _)| n != EMBED), "a stale workbook was written after the chart changed");
let xml = saved
.iter()
.find(|(n, _)| n.starts_with("ppt/charts/chart") && n.ends_with(".xml"))
.map(|(_, d)| String::from_utf8_lossy(d).into_owned())
.expect("chart xml");
assert!(!xml.contains("externalData"), "{xml}");
}
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity with PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); previously major: created; formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (an unchanged chart keeps its embedded workbook on save, #335) · **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**
@@ -72,7 +72,7 @@ the round trip even though DeckCraft doesn't model it.
| Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip |
| Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | |
| Tables with styles | yes | yes | |
| Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw` |
| Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw`. An unchanged chart also keeps its embedded workbook and the other parts its relationships point at (#335); editing the chart drops that workbook instead of writing a stale one |
| Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, box) | no | no | |
| SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save |
| Equations (OMML, `a14:m`) | no | no | PR #55 open |
@@ -99,6 +99,7 @@ the round trip even though DeckCraft doesn't model it.
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | trivial | Unchanged charts keep the embedded workbook (`ppt/embeddings/`) and chart relationships (#335) |
| 2026-10-12 | minor | EMF/WMF text and pattern brushes drawn; OLE (VML) and ActiveX previews read |
| 2026-10-12 | minor | Text warps rendered; their adjust values read and written |
| 2026-10-12 | trivial | Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233) |
+4 -2
View File
@@ -1,6 +1,6 @@
# Where DeckCraft falls short of PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (unchanged chart workbooks round-trip, #335) · **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
@@ -146,7 +146,8 @@ Detail in [file-format-parity.md](file-format-parity.md).
### Charts: data grid, elements and the newer chart types
- **Evidence:** checklist rows "Edit chart data" (P0, partial), "Chart elements" (partial), "Other
chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, box &
whisker) aren't read.
whisker) aren't read. An unchanged chart's embedded workbook now survives save (#335); editing
the chart still drops it rather than writing a stale spreadsheet.
- **Impact:** most business decks have charts; editing their data is routine.
- **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h).
@@ -255,6 +256,7 @@ Detail in [localization-parity.md](localization-parity.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | trivial | Chart workbook round-trip (#335) noted under the charts gap; editing chart data is still open |
| 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines |
| 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions |
| 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles |