From 9f8c860f90169768a93c2136c807194ec018f5c9 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 00:52:25 +0300 Subject: [PATCH] PPTX corpus: themes, masters, effects and links; keep text Mouse Over actions New owned corpus test on PowerPoint-shaped XML: a custom-theme deck with shadow, glow, soft-edge and reflection effects and every common text and shape hyperlink, and a two-master deck with its own themes and layouts. The one loss it found: a:hlinkMouseOver on text runs was dropped; it is now read into RunProps::hover and written back. --- crates/model/src/text.rs | 3 + crates/pptx/src/read/dml.rs | 1 + crates/pptx/src/write/dml.rs | 7 +- crates/pptx/tests/corpus_themes.rs | 248 +++++++++++++++++++++++++++++ docs/gaps.md | 5 +- 5 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 crates/pptx/tests/corpus_themes.rs diff --git a/crates/model/src/text.rs b/crates/model/src/text.rs index 6a1a7527..fcfaf5eb 100644 --- a/crates/model/src/text.rs +++ b/crates/model/src/text.rs @@ -345,6 +345,8 @@ pub struct RunProps { pub outline: Option, pub highlight: Option, pub link: Option, + /// Action on mouse over (Insert ▸ Action ▸ Mouse Over on selected text, `a:hlinkMouseOver`). + pub hover: Option, pub lang: Option, pub shadow: Option, pub glow: Option, @@ -376,6 +378,7 @@ impl RunProps { outline, highlight, link, + hover, lang, shadow, glow, diff --git a/crates/pptx/src/read/dml.rs b/crates/pptx/src/read/dml.rs index 0aee935c..1c95df19 100644 --- a/crates/pptx/src/read/dml.rs +++ b/crates/pptx/src/read/dml.rs @@ -512,6 +512,7 @@ pub fn rpr(imp: &mut Imp, part: &Part, r: &El) -> RunProps { "cs" => p.font_cs = e.attr("typeface").filter(|t| !t.is_empty()).map(String::from), "sym" => p.font_sym = e.attr("typeface").filter(|t| !t.is_empty()).map(String::from), "hlinkClick" => p.link = hyperlink(imp, part, e), + "hlinkMouseOver" => p.hover = hyperlink(imp, part, e), _ => {} } } diff --git a/crates/pptx/src/write/dml.rs b/crates/pptx/src/write/dml.rs index 16a069fe..cfe5a73f 100644 --- a/crates/pptx/src/write/dml.rs +++ b/crates/pptx/src/write/dml.rs @@ -589,7 +589,8 @@ pub fn rpr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, r: &RunProps) { || r.font_ea.is_some() || r.font_cs.is_some() || r.font_sym.is_some() - || r.link.is_some(); + || r.link.is_some() + || r.hover.is_some(); if !has_kids { w.empty(tag, a); return; @@ -624,6 +625,10 @@ pub fn rpr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, r: &RunProps) { if let Some(h) = &r.link { hyperlink(w, x, o, "a:hlinkClick", h); } + // Schema order: hlinkClick, hlinkMouseOver. + if let Some(h) = &r.hover { + hyperlink(w, x, o, "a:hlinkMouseOver", h); + } w.close(tag); } diff --git a/crates/pptx/tests/corpus_themes.rs b/crates/pptx/tests/corpus_themes.rs new file mode 100644 index 00000000..80c41d20 --- /dev/null +++ b/crates/pptx/tests/corpus_themes.rs @@ -0,0 +1,248 @@ +//! Owned corpus: themes, several masters and layouts, shape effects (shadow, glow, soft edge, +//! reflection) and hyperlinks and actions on text and shapes. +//! +//! The decks are built as PowerPoint writes them: a DeckCraft package whose theme, slides and +//! relationships are replaced with PowerPoint-shaped XML, and a two-master deck built in the model. +//! The check is open → save → reopen with nothing lost. + +use std::io::{Cursor, Read, Write}; +use std::sync::Arc; + +use deckcraft_model::style::Effects; +use deckcraft_model::text::{Action, Hyperlink}; +use deckcraft_model::{LayoutType, Presentation, Shape, defaults}; + +/// Replace (or add) parts of a package. +fn repack(bytes: &[u8], parts: &[(&str, String)]) -> Vec { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(vec![])); + let opts = zip::write::SimpleFileOptions::default(); + for i in 0..z.len() { + let mut f = z.by_index(i).expect("entry"); + let name = f.name().to_string(); + if parts.iter().any(|(n, _)| *n == name) { + continue; + } + let mut data = vec![]; + f.read_to_end(&mut data).expect("read"); + out.start_file(name, opts).expect("start"); + out.write_all(&data).expect("write"); + } + for (n, xml) in parts { + out.start_file(*n, opts).expect("start"); + out.write_all(xml.as_bytes()).expect("write"); + } + out.finish().expect("finish").into_inner() +} + +fn part(bytes: &[u8], name: &str) -> String { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut s = String::new(); + z.by_name(name).expect(name).read_to_string(&mut s).expect("utf8"); + s +} + +fn round_trip(p: &Presentation) -> Presentation { + deckcraft_pptx::import(&deckcraft_pptx::export(p).expect("export")).expect("reimport") +} + +fn shapes_named<'a>(p: &'a Presentation, slide: usize) -> impl Fn(&str) -> &'a Shape { + move |n| p.slides[slide].shapes.iter().find(|s| s.name == n).unwrap_or_else(|| panic!("no shape {n}")) +} + +const NS: &str = r#"xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main""#; + +fn sp(id: u32, name: &str, nv_extra: &str, x: u32, effects: &str, body: &str) -> String { + format!( + r#"{nv_extra}{effects}{body}"# + ) +} + +/// A two-slide PowerPoint-style deck: a custom theme, effects on four shapes, and text and shape +/// hyperlinks of every common kind. +fn effects_and_links() -> Vec { + let mut p = defaults::new_presentation(None); + let layout = defaults::layout_of_kind(&p, LayoutType::Blank).unwrap_or_default(); + for _ in 0..2 { + let s = defaults::new_slide(&mut p, layout); + p.slides.push(Arc::new(s)); + } + p.slides.truncate(2); + let base = deckcraft_pptx::export(&p).expect("export"); + let rels = part(&base, "ppt/slides/_rels/slide1.xml.rels"); + let rels = rels.replace( + "", + r#""#, + ); + let shadow = sp( + 10, + "Shadowed", + r#""#, + 254000, + r#""#, + r#"Shadow"#, + ); + let glow = sp( + 11, + "Glowing", + "", + 3048000, + r#""#, + r#"Report and appendix"#, + ); + let soft = sp( + 12, + "Soft edged", + "", + 5842000, + r#""#, + r#"Next mail us"#, + ); + let refl = sp( + 13, + "Reflected", + "", + 8636000, + r#""#, + r#"Hover me"#, + ); + let slide = format!( + r#"{shadow}{glow}{soft}{refl}"# + ); + repack(&base, &[("ppt/slides/slide1.xml", slide), ("ppt/slides/_rels/slide1.xml.rels", rels)]) +} + +fn fx(s: &Shape) -> Effects { + s.effects.clone().unwrap_or_default() +} + +fn run_links(s: &Shape) -> Vec<(String, Option)> { + s.text.iter().flat_map(|t| t.paragraphs.iter()).flat_map(|p| p.runs.iter()).map(|r| (r.text.clone(), r.props.link.clone())).collect() +} + +#[test] +fn effects_and_links_survive_a_round_trip() { + let bytes = effects_and_links(); + let a = deckcraft_pptx::import(&bytes).expect("import"); + let get = shapes_named(&a, 0); + // Read as PowerPoint wrote them. + let sh = fx(get("Shadowed")).outer_shadow.expect("shadow"); + assert!((sh.blur - 6.0).abs() < 1e-6 && (sh.dist - 3.0).abs() < 1e-6 && (sh.dir - 45.0).abs() < 1e-6, "{sh:?}"); + assert_eq!((sh.align.as_str(), sh.rotate_with_shape), ("tl", false)); + assert!((sh.sx - 1.02).abs() < 1e-6); + assert!(fx(get("Glowing")).glow.is_some_and(|g| (g.radius - 11.0).abs() < 1e-6)); + assert_eq!(fx(get("Soft edged")).soft_edge, Some(5.0)); + let r = fx(get("Reflected")).reflection.expect("reflection"); + assert!((r.end_pos - 0.555).abs() < 1e-6 && (r.dist - 4.0).abs() < 1e-6, "{r:?}"); + let shape = get("Shadowed"); + assert!(matches!(shape.click.as_ref().map(|h| (&h.action, h.highlight_click)), Some((Action::EndShow, true)))); + assert!( + matches!(shape.hover.as_ref().map(|h| (&h.action, h.tooltip.as_str())), Some((Action::Url { url }, "Hover for the report")) if url.contains("q=1&r=2")) + ); + let links = run_links(get("Glowing")); + assert!(matches!(&links[0].1, Some(Hyperlink { action: Action::Url { .. }, tooltip, .. }) if tooltip == "Open the report")); + assert!(links[1].1.is_none()); + assert!(matches!(&links[2].1, Some(Hyperlink { action: Action::Slide { slide }, .. }) if *slide == a.slides[1].id)); + let links = run_links(get("Soft edged")); + assert!(matches!(&links[0].1, Some(Hyperlink { action: Action::NextSlide, .. }))); + assert!(matches!(&links[1].1, Some(Hyperlink { action: Action::Url { url }, .. }) if url.starts_with("mailto:"))); + let hover = get("Reflected").text.as_ref().and_then(|t| t.paragraphs[0].runs[0].props.hover.clone()); + assert!(matches!(hover, Some(Hyperlink { action: Action::Url { .. }, ref tooltip, .. }) if tooltip == "Preview"), "{hover:?}"); + + // Saved and reopened: the same effects and links. + let b = round_trip(&a); + let (ga, gb) = (shapes_named(&a, 0), shapes_named(&b, 0)); + for n in ["Shadowed", "Glowing", "Soft edged", "Reflected"] { + assert_eq!(fx(ga(n)), fx(gb(n)), "effects of {n}"); + assert_eq!(ga(n).click, gb(n).click, "click of {n}"); + assert_eq!(ga(n).hover, gb(n).hover, "hover of {n}"); + let la: Vec<_> = run_links(ga(n)) + .into_iter() + .map(|(t, l)| (t, l.map(|h| (format!("{:?}", h.action).replace(&format!("{:?}", a.slides[1].id), "S2"), h.tooltip)))) + .collect(); + let lb: Vec<_> = run_links(gb(n)) + .into_iter() + .map(|(t, l)| (t, l.map(|h| (format!("{:?}", h.action).replace(&format!("{:?}", b.slides[1].id), "S2"), h.tooltip)))) + .collect(); + assert_eq!(la, lb, "text links of {n}"); + let ha: Vec<_> = ga(n).text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs).map(|r| r.props.hover.clone()).collect(); + let hb: Vec<_> = gb(n).text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs).map(|r| r.props.hover.clone()).collect(); + assert_eq!(ha, hb, "text hover of {n}"); + } + let out = deckcraft_pptx::export(&b).expect("export"); + assert!(part(&out, "ppt/slides/slide1.xml").contains("hlinkMouseOver")); +} + +/// Two masters with their own themes (colours, fonts) and layouts, slides on each. +fn two_masters() -> Presentation { + let mut p = defaults::new_presentation(None); + let mut second = (*p.masters[0]).clone(); + second.id = deckcraft_model::MasterId(p.alloc_id()); + second.name = "Brand Dark".into(); + second.theme.name = "Brand Dark Theme".into(); + second.theme.colors.name = "Brand Colours".into(); + second.theme.colors.colors[4] = deckcraft_model::Rgba::rgb(0x12, 0x34, 0x56); + second.theme.colors.colors[2] = deckcraft_model::Rgba::rgb(0x20, 0x20, 0x30); + second.theme.fonts.name = "Brand Fonts".into(); + second.theme.fonts.major.latin = "Georgia".into(); + second.theme.fonts.minor.latin = "Verdana".into(); + second.background = Some(deckcraft_model::Background::Fill { + fill: deckcraft_model::Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_model::Rgba::rgb(0x10, 0x10, 0x18))), + }); + for l in &mut second.layouts { + l.id = deckcraft_model::LayoutId(p.alloc_id()); + l.name = format!("Dark {}", l.name); + } + second.layouts.truncate(4); + let dark_layout = second.layouts[1].id; + p.masters.push(Arc::new(second)); + let light_layout = p.masters[0].layouts[0].id; + let s1 = defaults::new_slide(&mut p, light_layout); + let s2 = defaults::new_slide(&mut p, dark_layout); + p.slides = vec![Arc::new(s1), Arc::new(s2)]; + p +} + +#[test] +fn themes_masters_and_layouts_survive_a_round_trip() { + let a = two_masters(); + let b = round_trip(&a); + assert_eq!(b.masters.len(), 2); + for (ma, mb) in a.masters.iter().zip(&b.masters) { + assert_eq!(ma.name, mb.name); + assert_eq!(ma.theme.name, mb.theme.name); + // The writer adds PowerPoint's empty objectDefaults/extraClrSchemeLst, kept as raw XML. + let bare = |t: &deckcraft_model::theme::Theme| deckcraft_model::theme::Theme { raw_extra: None, ..t.clone() }; + assert_eq!(bare(&ma.theme), bare(&mb.theme), "whole theme of {}", ma.name); + assert_eq!(ma.theme.colors, mb.theme.colors, "colour scheme of {}", ma.name); + assert_eq!(ma.theme.fonts.name, mb.theme.fonts.name); + assert_eq!(ma.theme.fonts.major.latin, mb.theme.fonts.major.latin); + assert_eq!(ma.theme.fonts.minor.latin, mb.theme.fonts.minor.latin); + assert_eq!(ma.background, mb.background, "background of {}", ma.name); + let names = |m: &deckcraft_model::Master| m.layouts.iter().map(|l| l.name.clone()).collect::>(); + assert_eq!(names(ma), names(mb)); + } + // Each slide stays on its master's layout. + let layout_name = + |p: &Presentation, i: usize| p.masters.iter().flat_map(|m| m.layouts.iter()).find(|l| l.id == p.slides[i].layout).map(|l| l.name.clone()); + for i in 0..2 { + assert_eq!(layout_name(&a, i), layout_name(&b, i), "slide {i}"); + } + // A second save keeps the same theme and master parts. + let names = |bytes: &[u8]| { + let z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut v: Vec = + z.file_names().filter(|n| n.contains("theme") || n.contains("slideMaster") || n.contains("slideLayout")).map(str::to_string).collect(); + v.sort(); + v + }; + let c = round_trip(&b); + for (mb, mc) in b.masters.iter().zip(&c.masters) { + assert_eq!(mb.theme, mc.theme, "stable after a second save"); + // An empty map is the default one, which the writer spells out. + assert_eq!(mb.color_map, mc.color_map); + } + let once = deckcraft_pptx::export(&b).expect("export"); + let twice = deckcraft_pptx::export(&deckcraft_pptx::import(&once).expect("import")).expect("export"); + assert_eq!(names(&once), names(&twice)); +} diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..ad52986d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -61,7 +61,9 @@ Detail in [file-format-parity.md](file-format-parity.md). ### PPTX has never met a corpus of real decks - **Evidence:** every PPTX test (`crates/pptx/tests`, 30 tests) uses decks DeckCraft generates; the 2026-10-07 alpha checklist already listed this as open. No reference renders from PowerPoint - exist to compare against. + exist to compare against. `crates/pptx/tests/corpus_themes.rs` starts on PowerPoint-shaped XML: + themes and two masters with their layouts, shadow/glow/soft-edge/reflection effects, and text + and shape hyperlinks and actions (text Mouse Over actions were dropped; now kept). - **Impact:** the main reason to switch is "open my PowerPoint decks". Fidelity on real decks (corporate templates, SmartArt, `chartex` charts, embedded fonts, EMF) is unknown. - **Estimate:** 20–35 h: build an owned corpus in `storytold/photocraft-corpus` style (craftrules @@ -256,6 +258,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Corpus slice: themes, masters, effects and links; text Mouse Over actions round-trip | | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) | | 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 |