mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-31 07:38:16 +00:00
Merge PR #589 from NikitkaRa: PPTX corpus: themes, masters, effects and links; keep text Mouse Over actions
This commit is contained in:
commit
1080d02dd0
5 files changed
+264
-1
No files matched your search
@@ -370,6 +370,8 @@ pub struct RunProps {
|
||||
pub outline: Option<Line>,
|
||||
pub highlight: Option<ColorRef>,
|
||||
pub link: Option<Hyperlink>,
|
||||
/// Action on mouse over (Insert ▸ Action ▸ Mouse Over on selected text, `a:hlinkMouseOver`).
|
||||
pub hover: Option<Hyperlink>,
|
||||
pub lang: Option<String>,
|
||||
pub shadow: Option<crate::style::Shadow>,
|
||||
/// `a:innerShdw` on the run. Outer shadow stays in `shadow`.
|
||||
@@ -406,6 +408,7 @@ impl RunProps {
|
||||
outline,
|
||||
highlight,
|
||||
link,
|
||||
hover,
|
||||
lang,
|
||||
shadow,
|
||||
inner_shadow,
|
||||
|
||||
@@ -522,6 +522,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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,7 +621,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;
|
||||
@@ -667,6 +668,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<u8> {
|
||||
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#"<p:sp><p:nvSpPr><p:cNvPr id="{id}" name="{name}">{nv_extra}</p:cNvPr><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="{x}" y="1270000"/><a:ext cx="2540000" cy="1270000"/></a:xfrm><a:prstGeom prst="roundRect"><a:avLst/></a:prstGeom><a:solidFill><a:schemeClr val="accent1"/></a:solidFill><a:effectLst>{effects}</a:effectLst></p:spPr><p:txBody><a:bodyPr rtlCol="0" anchor="ctr"/><a:lstStyle/>{body}</p:txBody></p:sp>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// 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<u8> {
|
||||
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(
|
||||
"</Relationships>",
|
||||
r#"<Relationship Id="rId90" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.com/report?q=1&r=2" TargetMode="External"/><Relationship Id="rId91" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slide2.xml"/><Relationship Id="rId92" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="mailto:team@example.com?subject=Deck" TargetMode="External"/></Relationships>"#,
|
||||
);
|
||||
let shadow = sp(
|
||||
10,
|
||||
"Shadowed",
|
||||
r#"<a:hlinkClick r:id="" action="ppaction://hlinkshowjump?jump=endshow" highlightClick="1"/><a:hlinkHover r:id="rId90" tooltip="Hover for the report"/>"#,
|
||||
254000,
|
||||
r#"<a:outerShdw blurRad="76200" dist="38100" dir="2700000" sx="102000" sy="102000" algn="tl" rotWithShape="0"><a:prstClr val="black"><a:alpha val="40000"/></a:prstClr></a:outerShdw>"#,
|
||||
r#"<a:p><a:r><a:rPr lang="en-US" dirty="0"/><a:t>Shadow</a:t></a:r></a:p>"#,
|
||||
);
|
||||
let glow = sp(
|
||||
11,
|
||||
"Glowing",
|
||||
"",
|
||||
3048000,
|
||||
r#"<a:glow rad="139700"><a:schemeClr val="accent2"><a:satMod val="175000"/><a:alpha val="40000"/></a:schemeClr></a:glow>"#,
|
||||
r#"<a:p><a:r><a:rPr lang="en-US"><a:hlinkClick r:id="rId90" tooltip="Open the report"/></a:rPr><a:t>Report</a:t></a:r><a:r><a:rPr lang="en-US"/><a:t> and </a:t></a:r><a:r><a:rPr lang="en-US"><a:hlinkClick r:id="rId91" action="ppaction://hlinksldjump"/></a:rPr><a:t>appendix</a:t></a:r></a:p>"#,
|
||||
);
|
||||
let soft = sp(
|
||||
12,
|
||||
"Soft edged",
|
||||
"",
|
||||
5842000,
|
||||
r#"<a:softEdge rad="63500"/>"#,
|
||||
r#"<a:p><a:r><a:rPr lang="en-US"><a:hlinkClick r:id="" action="ppaction://hlinkshowjump?jump=nextslide"/></a:rPr><a:t>Next</a:t></a:r><a:r><a:rPr lang="en-US"><a:hlinkClick r:id="rId92"/></a:rPr><a:t> mail us</a:t></a:r></a:p>"#,
|
||||
);
|
||||
let refl = sp(
|
||||
13,
|
||||
"Reflected",
|
||||
"",
|
||||
8636000,
|
||||
r#"<a:reflection blurRad="6350" stA="50000" endA="300" endPos="55500" dist="50800" dir="5400000" sy="-100000" algn="bl" rotWithShape="0"/>"#,
|
||||
r#"<a:p><a:r><a:rPr lang="en-US"><a:hlinkMouseOver r:id="rId90" tooltip="Preview"/></a:rPr><a:t>Hover me</a:t></a:r></a:p>"#,
|
||||
);
|
||||
let slide = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:sld {NS}><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>{shadow}{glow}{soft}{refl}</p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sld>"#
|
||||
);
|
||||
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<Hyperlink>)> {
|
||||
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::<Vec<_>>();
|
||||
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<String> =
|
||||
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));
|
||||
}
|
||||
@@ -108,6 +108,11 @@ Detail in [file-format-parity.md](file-format-parity.md).
|
||||
video bookmarks and trims, a section holding a hidden slide, slide-number and date fields, ink
|
||||
(saved as freeform lines), `.glb` 3-D models, and animation repeat, bounce end and triggers.
|
||||
Playback options (#74) and rewind (#77) are still lost.
|
||||
- **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. `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 still mostly unknown.
|
||||
- **Estimate:** 20–35 h: grow the owned corpus in `storytold/photocraft-corpus` style (craftrules
|
||||
@@ -336,6 +341,7 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
| 2026-10-13 | minor | Owned PPTX corpus for text effects, body layout, theme effects, fills, bevels and connectors. A run's inner shadow, reflection and soft edge, and a bevel preset with no kept 3-D XML, are kept. #70, #74, #77 and embedded fonts stay open |
|
||||
| 2026-10-13 | minor | Benchmark suite (`cargo xtask bench`, `deckcraft-cli bench`): the performance gap is now frame rate and a PowerPoint comparison |
|
||||
| 2026-10-14 | minor | Owned corpus `corpus_media.rs`: 3-D model parts and animation bounce end now round-trip. The real-deck corpus and #70/#74/#77 stay open |
|
||||
| 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-13 | minor | Interactive decks: triggers, return-to-slide state and click rules landed in the slide show; the remaining pieces are one new entry under Features |
|
||||
| 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 |
|
||||
|
||||
Reference in new issue
Block a user